Compare commits

...

97 Commits
1.5.0 ... 1.9.4

Author SHA1 Message Date
Felix Fontein
d784e0a52b Release 1.9.4. 2021-09-28 17:17:41 +02:00
Felix Fontein
d73a2942a2 Prepare 1.9.4 release. 2021-09-28 16:53:56 +02:00
Felix Fontein
8af4847373 Update CI matrix to include ansible-core's stable-2.12 branch (#286)
* Update CI matrix to include ansible-core's stable-2.12 branch.

* Adjust README.

* Fix stage names.
2021-09-28 15:35:26 +02:00
Felix Fontein
44f7367e21 Extend CI (#283)
* Run all tests on all targets. Remove hack in setup_acme.

* Fix some failing tests.

* OpenSSH tests do not work yet with default image on Ansible 2.9. Let's skip them on the cloud target.

* Make tests pass again.

* Make sure to install *latest* versions of cryptography and pyOpenSSL when not installing system packages, whenever possible.

ci_complete

* Update/fix aliases files.
2021-09-25 17:21:06 +02:00
Felix Fontein
0733b0d521 Prepare ansible-core devel branch version bump that is planned for later today. 2021-09-24 18:45:50 +02:00
Ajpantuso
771a9eebcf Initial commit (#285) 2021-09-24 06:59:52 +02:00
Felix Fontein
0fdede5d7a Fix CI (1/2) (#284)
* New default docker image no longer contains bcrypt.

* Install cryptography for ACME tests.

* Add constraints.
2021-09-23 21:56:03 +02:00
Felix Fontein
56b2130c6e openssl_privatekey_pipe is an action plugin. (#267) 2021-09-21 07:29:26 +02:00
Felix Fontein
6c018b94da Improve CI (#281)
* Install PyOpenSSL and cryptography from PyPi if target Python != system Python.

* Work around some CentOS6, 7, Ubuntu 16.04 problems. Improve jinja2 compatibility handling.

* Skip tasks that require properties that aren't always there.

* Only install OpenSSL when not present.

* Improve output.

* Improve get_certificate integration test graceful failing.

* Fix tests.

* Fix assert.

* OpenSSL peculiarities.

* Fix condition.
2021-09-18 15:21:40 +02:00
Felix Fontein
63f4598737 acme_challenge_cert_helper: fail better to avoid crashes in Ansible (#282)
* Prevent acme_challenge_cert_helper triggering a bug in Ansible.

* Add changelog fragment.
2021-09-17 19:35:43 +02:00
Felix Fontein
598cdf0a21 Older openssl versions (1.0.1/1.0.2) do not seem to support '-' for /dev/stdin. (#279) 2021-09-15 20:42:52 +02:00
Ajpantuso
eea7bfc6bf openssh_cert - adding signature_algorithm option (#277)
* Initial Commit

* Update supported OpenSSH versions for RSA SHA-2 signed certs

* Updating 'regenerate' documentation
2021-09-15 08:53:53 +02:00
Felix Fontein
8521c96e8a Next expected release is 1.10.0. 2021-09-14 12:33:06 +02:00
Felix Fontein
d90cc5142b Release 1.9.3. 2021-09-14 08:15:32 +02:00
Felix Fontein
37aab65396 Prepare 1.9.3 release. 2021-09-14 07:14:03 +02:00
Felix Fontein
baff003ea8 Fix changelog from last time. 2021-09-14 07:13:25 +02:00
Felix Fontein
03427e35a7 Fix idempotency for non-ASCII string comparisons. (#271) 2021-09-14 07:06:35 +02:00
Felix Fontein
170fa40014 ipaddress is part of stdlib for Python 3. (#275) 2021-09-12 17:21:10 +02:00
Felix Fontein
330b30d5d2 certificate_complete_chain tests need cryptography installed on the target, so use setup_openssl. (#272) 2021-09-11 11:29:03 +02:00
Felix Fontein
67b8274faf openssl_csr: fix error in docs (#269)
* Fix error in docs.

* Add missing word.
2021-09-10 20:53:50 +02:00
Felix Fontein
02ee3fb974 Improve CI (#268)
* Remove superfluous remote_src.

* Use temp dir twice instead of output_dir.

* Use remote temp directory instead of output_dir.

* Fix syntax error.

* Add some fixes.

* Copy more files to remote.

* More fixes.

* Fixing ACME/'cloud' tests.

* Forgot when.

* Try to fix filters.

* Skip unnecessary steps.

* Avoid collision.
2021-09-07 22:37:40 +02:00
Felix Fontein
93ced1956c The next expected release is again 1.10.0. 2021-08-30 22:01:49 +02:00
Felix Fontein
a9e358ea57 Bugfix release 1.9.2. 2021-08-30 22:01:16 +02:00
Felix Fontein
ffcdbc5d0c Add non-existing 1.9.1 release. 2021-08-30 22:00:39 +02:00
Felix Fontein
6740cae10f Next release is expected to be 1.10.0. 2021-08-30 20:46:46 +02:00
Felix Fontein
915379459d Release 1.9.0. 2021-08-30 20:12:47 +02:00
Felix Fontein
a4a12bae27 Prepare 1.9.0 release. 2021-08-27 05:54:45 +02:00
Felix Fontein
94fc356338 https://github.com/diafygi/acme-tiny/pull/254 has been merged. (#265) 2021-08-22 12:41:41 +02:00
Ajpantuso
08ada24a53 openssh_keypair - Add diff support and general cleanup (#260)
* Initial commit

* Matching tests to overwritten permissions behavior with cryptography

* Ensuring key validation only occurs when state=present and accomodating CentOS6 restrictions

* Making ssh-keygen behavior explicit by version in tests

* Ensuring cyrptography not excluded in new conditions

* Adding changelog fragment

* Fixing sanity checks

* Improving readability

* Applying review suggestions

* addressing restore_on_failure conflict
2021-08-18 09:22:31 +02:00
Ajpantuso
b59846b9fa get_certificate - add starttls option with support for mysql (#264)
* Initial commit

* Adding changelog fragment

* Applying initial review suggestion
2021-08-15 15:40:54 +02:00
Felix Fontein
c9ec463893 Fix sanity failures (#263)
* Fix sanity failures.

* Add changelog fragment.
2021-08-12 09:23:11 +00:00
Felix Fontein
38ce150f80 Next expected release is 1.9.0. 2021-08-10 18:26:55 +02:00
Felix Fontein
408b538a45 Release 1.8.0. 2021-08-10 17:06:23 +02:00
Felix Fontein
1e82465559 Prepare 1.8.0 release. 2021-08-10 07:56:43 +02:00
Ajpantuso
85ac60e2c3 openssh_keypair - integration test refactoring (#259)
* Initial commit

* Fixed CRLF and ed25519 handling on CentOS6

* Separated expected test results for file permissions between backends

* Fixed unprotected key base directory

* Fixed PEM encoded file test
2021-08-04 21:19:32 +02:00
Ajpantuso
aaba87ac57 openssh_cert - Adding regenerate option (#256)
* Initial commit

* Fixing unit tests

* More unit fixes

* Adding changelog fragment

* Minor refactor in Certificate.generate()

* Addressing option case-sensitivity and directive overrides

* Renaming idempotency to regenerate

* updating changelog

* Minor refactoring of default options

* Cleaning up with inline functions

* Fixing false failures when regenerate=fail and improving clarity

* Applying second round of review suggestions

* adding helper for safe atomic moves
2021-07-31 11:36:03 +02:00
Felix Fontein
d6403ace6e Update AZP config. (#258) 2021-07-30 17:57:02 +02:00
Charlie Wheeler-Robinson
6c989de994 fix custom file attributes for public keys (#257)
Use of the confusingly-named _permissions_changed() on both
sides of an `or` was resulting in the second invocation not
being reached if the first invocation returned True, which it
does any time it applied custom attributes to the private key.
As a result, custom file attributes were only ever being
applied to the private key (except in one specific case)

This is fixed by explicitly updating attributes of both files
before checking if changes have been made.

Signed-off-by: Charlie Wheeler-Robinson <cwheeler@redhat.com>
2021-07-20 17:23:56 +02:00
Ajpantuso
4908f1a8ec openssh_cert - cleanup and diff support (#255)
* Initial commit

* Fixing units

* Adding changelog fragment

* Enhanced encapsulation of certificate data

* Avoiding failure when path is not parseable

* Diff refactor

* Applying initial review suggestions
2021-07-16 19:00:22 +02:00
Felix Fontein
f3c6c1172e Remove unnecessary files, and update _text import in one more. (#254) 2021-06-26 14:20:48 +02:00
Felix Fontein
9658a34605 Replace ansible.module_utils._text by ansible.module_utils.common.text.converters. (#253) 2021-06-26 13:45:28 +02:00
Ajpantuso
5d153e05ef New module utils openssh.certificate (#246)
* Initial commit

* Adding informational comments

* Adding changelog fragment

* Fixing CRLF changelog fragment

* Refactoring public number parsing and added chaining for writer methods

* Adding more descriptive error for invalid certificate data

* Fixing signature data parsing

* Correcting ed25519 signature type to binary

* Applying initial review suggestions and fixing option-list writer

* Applying review suggestions

* Making OpensshWriter private
2021-06-22 12:54:56 +02:00
Felix Fontein
2ba77e015c Fix module name in docs. (#252) 2021-06-16 07:13:34 +02:00
Felix Fontein
9d958033a5 Make extra sanity test runner produce ansibullbot and JUnit output. (#250) 2021-06-14 07:39:52 +02:00
Felix Fontein
4adad5d98a CI: Remove scripts that are no longer needed (#249)
* Remove scripts that are no longer needed.

ci_complete

* Remove sanity ignores.
2021-06-13 22:11:59 +02:00
Felix Fontein
b9737101cd Next expected release is 1.8.0. 2021-06-11 23:27:25 +02:00
Felix Fontein
9f27e28a45 Release 1.7.1. 2021-06-11 23:05:56 +02:00
Felix Fontein
9a7b2f1d0d Prepare 1.7.1 release. 2021-06-11 20:04:26 +02:00
Felix Fontein
0df33de73e Fix openssl_pkcs12 crash with cryptography backend when loading passphrase-protected files (#248)
* Convert passphrase to bytes when loading PKCS#12 file with cryptography.

* Add tests with PKCS#12 passphrase.

* Add changelog fragment.
2021-06-11 18:03:16 +00:00
Felix Fontein
cda2edf92c Remove superfluous constraints. (#245) 2021-06-06 22:31:03 +02:00
Felix Fontein
d38f59c18c Next planned release is 1.8.0. 2021-06-02 19:42:49 +02:00
Felix Fontein
4a7150204c Release 1.7.0. 2021-06-02 18:18:45 +02:00
Felix Fontein
bfb8e5df82 Fix crash in x509_certificate (#241)
* Fix crash in x509_certificate.

* Add test.
2021-06-02 16:44:58 +02:00
Felix Fontein
376d7cde12 Avoid crash in check mode (#243)
* Do not let AnsibleModule crash when setting permissions on not yet existing files in check mode.

* Add tests.

* Fix bugs.
2021-06-02 16:44:26 +02:00
Felix Fontein
a466df9c52 Prepare 1.7.0 release. 2021-06-01 22:22:44 +02:00
Felix Fontein
117438cff0 Add some guides (#237)
* Add first guides for creating certificates.

* Add extra docs check.

* Introduce error to see whether extra checks work.

* Revert "Introduce error to see whether extra checks work."

This reverts commit 8656a158b8.

* Linting.

* Fix copy'n'paste error.
2021-05-27 22:59:06 +02:00
Ajpantuso
c6483751b5 openssh_keypair - Adding backend option and refactoring backend code (#236)
* Refactoring openssh_keypair for multiple backends

* Fixing cryptography backend validations

* Simplifying conditionals and excess variable assignments

* Fixing docs and adding cleanup for integration tests

* Fixing docs and public key validation bugs in crypto backend

* Enhancing cryptogagraphy utils to raise OpenSSHErrors when file not found

* Adding missed copyright and cleanup for idempotency test keys

* Fixing doc style

* Readding crypto/openssh for backwards compatibility

* Adding changelog fragment and final simplifications of conditional statements

* Applied initial review suggestions
2021-05-23 22:36:55 +02:00
Felix Fontein
2bf0bb5fb3 Add diff support (#150)
* Add diff support to openssl_privatekey.

* Add diff support to openssl_csr.

* Add diff support to x509_crl.

* Add diff support to x509_certificate.

* Add diff support to openssl_publickey.

* Add changelog fragment.

* Prefer one fingerprint for diff infos to reduce noise.

* Apply suggestions from code review

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-23 19:25:23 +00:00
Felix Fontein
e9bc7c7163 openssl_pkcs12: add cryptography backend (#234)
* Began refactoring.

* Continue.

* Factor PyOpenSSL backend out.

* Add basic cryptography backend.

* Update plugins/modules/openssl_pkcs12.py

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

* Only run tests when new enough pyOpenSSL or cryptography is around.

* Reduce required pyOpenSSL version from 17.1.0 to 0.15.

I have no idea why 17.1.0 was there (in the tests), and not something smaller.
The module itself did not mention any version.

* Linting.

* Linting.

* Increase compatibility by selecting pyopenssl backend when iter_size or maciter_size is used.

* Improve docs, add changelog fragment.

* Move hackish code to cryptography_support.

* Update plugins/modules/openssl_pkcs12.py

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

* Update plugins/modules/openssl_pkcs12.py

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

* Streamline cert creation.

* Convert range to list.

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-20 19:36:07 +02:00
Felix Fontein
0a0d0f2bdf openssl_csr_info and x509_certificate_info: return more public key information (#233)
* Return more public key information.

* Make sure bit size is converted to int first.

* Apply suggestions from code review

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

* Remove no longer necessary code.

* Use correct return value's name.

* Add trailing commas.

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-19 14:02:45 +02:00
Felix Fontein
3293b77f18 Remove unnecessary and undocumented return values. (#235) 2021-05-19 14:02:26 +02:00
Felix Fontein
69aeb2d86f x509_crl_info: allow to not enumerate revoked certificates (#232)
* Allow to not enumerate revoked certificates.

* Forgot to remove one instance.

* Add example.
2021-05-19 09:32:30 +02:00
Felix Fontein
7298c1f49a Add openssl_publickey_info module (#231)
* Add openssl_publickey_info module. Share code between openssl_privatekey_info and the new module, and improve documentation of it.

* Move public key loading to support module.

* Require pyOpenSSL 16.0.0 for public key loading.

* Linting.

* Apply suggestions from code review

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-18 17:47:10 +02:00
Felix Fontein
ba03580659 x509_certificate_info: move main code to module_utils to allow easier implementation of diff mode (#206)
* Move x509_certificate_info code to module_utils.

* Add changelog fragment.

* Apply suggestions from code review

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-15 22:48:08 +02:00
Felix Fontein
a93f07c651 openssl_privatekey_info: move main code to module_utils to allow easier implementation of diff mode (#205)
* Move openssl_privatekey_info code to module_utils.

* Add changelog fragment.

* Apply suggestions from code review

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-15 08:24:09 +02:00
Felix Fontein
c0edfb46bb openssl_csr_info: move main code to module_utils to allow easier implementation of diff mode (#204)
* Move openssl_csr_info code to module_utils.

* Add changelog fragment.

* Apply suggestions from code review

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-05-13 22:08:28 +02:00
Felix Fontein
6c5a0c6df1 x509_crl_info: move main code to module_utils to allow easier implementation of diff mode (#203)
* Move x509_crl_info code to module_utils.

* Add changelog fragment.

* Make sure PyOpenSSL is also installed.

* Implement review comments.
2021-05-12 23:30:19 +02:00
Ajpantuso
80d64e7b64 openssh_keypair: Populate return values when keypair exists and check_mode=true (#230)
* Swapping statement order for check_mode to initialize return values

* Adding changelog fragment

* Updated changelog to reflect bugfix
2021-05-12 16:10:08 +02:00
Felix Fontein
3e7362200a Update Python versions for integration tests: add Python 3.10 (#229)
* Update Python versions for integration tests.

* 3.10 in YAML is 3.1...
2021-05-10 19:28:59 +02:00
Felix Fontein
c400744040 Improve changelog fragment. 2021-05-10 17:58:42 +02:00
Felix Fontein
91552d5fd2 Clarify Windows (non-)support. (#228) 2021-05-10 17:14:41 +02:00
Ajpantuso
6100d9b4df openssh_keypair: Adding passphrase parameter (#225)
* Integrating openssh module utils with openssh_keypair

* Added explicit PEM formatting for OpenSSH < 7.8

* Adding changelog fragment

* Adding OpenSSL/cryptography dependency for integration tests

* Adding private_key_format option and removing forced cryptography update for CI

* Fixed version check for bcrypt and key_format option name

* Setting no_log=False for private_key_format

* Docs correction and simplification of control flow for private_key_format
2021-05-10 14:47:01 +02:00
Ajpantuso
37c1540ff4 New module_utils openssh (#213)
* Adding openssh utils and unit tests

* Adding changelog fragment and correcting RSA default size

* Adding changelog fragment

* Added passphrase update, test cases, and check for SSH private key loader

* corrected ecdsa type when loading

* Resolving inital review comments

* Fixed import in unit tests

* Cleaning up validation functions

* Separating private/public key related errors; Adding verify method

* Expressed generate/load functions as classmethods and cleaned up method comments

* Added support for loading asymmetric key pairs of PEM and DER formats

* Refactored loading/generation for Asym keypairs into classmethods

* Rescoped helper functions and classmethods for OpenSSH Keypair

* Corrected docstring for OpenSSH_Keypair.generate()

* Fixed import errors for sanity tests

* Improvements to comparison, key verification, and password validation

* Added comparison tests, simplified password validation, fixed Ed25519 load bug

* Adding additional equivalence tests with passphrases
2021-05-03 21:10:48 +02:00
Felix Fontein
3239701ba4 Add ansible-test config file. (#224) 2021-05-01 22:20:50 +02:00
Felix Fontein
81408bb853 Add Fedora 34 and remove Fedora 32 from devel tests. (#223) 2021-04-30 20:07:41 +00:00
Felix Fontein
6414301936 Use Ansible's codecov uploader. (#222) 2021-04-30 04:28:13 +02:00
Felix Fontein
db513d1b27 Next expected release is 1.7.0. 2021-04-28 08:46:14 +02:00
Felix Fontein
0ecdf2ccbd Release 1.6.2. 2021-04-28 07:53:36 +02:00
Felix Fontein
c05e20cf1e Prepare 1.6.2 release. 2021-04-28 07:32:33 +02:00
Felix Fontein
f4334d7307 acme_* modules: make sure 'meta' is always in directory (#221)
* Make sure 'meta' is always in directory.

* Update plugins/module_utils/acme/acme.py
2021-04-28 07:31:06 +02:00
Felix Fontein
201920d161 Replace FreeBSD 11.4 with 13.0 for devel testing. (#218) 2021-04-22 07:08:11 +02:00
Felix Fontein
e809ee19ee Next expected release is 1.7.0. 2021-04-11 16:51:35 +02:00
Felix Fontein
4684f36c38 Release 1.6.1. 2021-04-11 15:47:23 +02:00
Felix Fontein
bb3ddf1961 Prepare 1.6.1 bugfix release. 2021-04-11 15:45:37 +02:00
Felix Fontein
0e1f0fd730 ACME exception fixes (#217)
* Fix wrong usages of ACMEProtocolException.

* Add changelog fragment.

* Fix error handling when content could not be decoded.

* Make sure that content_json is a dict or None.

* Improve acme_inspect's ACMEProtocolException handling.

* Improve error handling.

* Add tests.

* Fix challenge error.

* Add challenges tests.

* Provide content if available.

* Add some order tests.

* Linting.
2021-04-11 14:44:44 +02:00
Felix Fontein
7b1d4770e9 ansible/ansible's stable-2.11 branch has been created. (#214) 2021-04-06 08:52:35 +02:00
Felix Fontein
befa690d9e Stop using ansible-galaxy collection install to install a collection due to https://github.com/ansible/galaxy/issues/2429. (#211) 2021-03-27 09:57:50 +01:00
Andrew Klychkov
bcf2a17257 AZP: update default container version (#210) 2021-03-26 12:31:36 +01:00
Felix Fontein
8c6b28cd81 Next expected release is 1.7.0. 2021-03-22 13:37:07 +01:00
Felix Fontein
b916f95d4d Release 1.6.0. 2021-03-22 12:55:25 +01:00
Felix Fontein
42d94dd44b Prepare 1.6.0 release. 2021-03-22 07:55:12 +01:00
Felix Fontein
f5fd5fdf5b acme: improve error handling in backend's parse_key() (#208)
* Improve error handling in backend's parse_key().

* Adjust unit tests.
2021-03-22 07:30:06 +01:00
Felix Fontein
e85554827f acme_* modules: support private key passprases (#207)
* Support private key passprases.

* Use c.c modules for key generation, add first passphrase tests.

* Some more passphrase tests.
2021-03-21 17:53:20 +01:00
Felix Fontein
5d32937321 ACME modules refactor (#187)
* Move acme.py to acme/__init__.py to prepare splitup.

* Began moving generic code out.

* Creating backends.

* Update unit tests.

* Move remaining new code out.

* Use new interface.

* Rewrite module init code.

* Add changelog.

* Add BackendException for crypto backend errors.

* Improve / uniformize ACME error reporting.

* Create ACMELegacyAccount for backwards compatibility.

* Split up ACMEAccount into ACMEClient and ACMEAccount.

* Move get_keyauthorization into module_utils.acme.challenges.

* Improve error handling.

* Move challenge and authorization handling code into module_utils.

* Add split_identifier helper.

* Move order code into module_utils.

* Move ACME v2 certificate handling code to module_utils.

* Fix/move ACME v1 certificate retrieval to module_utils as well.

* Refactor alternate chain handling code by splitting it up into simpler functions.

* Make chain matcher creation part of backend.
2021-03-21 09:40:25 +01:00
Felix Fontein
8de9376a10 Make action_module plugin utils compatible with latest changes in ansible-core 2.11.0b3 (#202)
* Make compatible with latest changes in ansible-core 2.11.0b3.

* Add missing import.

* Use correct class.
2021-03-20 23:36:48 +01:00
Felix Fontein
35a78dbc4e Improve openssl_privatekey docs. (#198) 2021-03-15 08:28:02 +01:00
Felix Fontein
2e69113688 Next expected version is 1.6.0. 2021-03-08 07:44:55 +01:00
266 changed files with 16065 additions and 7728 deletions

View File

@@ -36,7 +36,7 @@ variables:
resources: resources:
containers: containers:
- container: default - container: default
image: quay.io/ansible/azure-pipelines-test-container:1.8.0 image: quay.io/ansible/azure-pipelines-test-container:1.9.0
pool: Standard pool: Standard
@@ -55,6 +55,28 @@ stages:
test: 'devel/sanity/extra' test: 'devel/sanity/extra'
- name: Units - name: Units
test: 'devel/units/1' test: 'devel/units/1'
- stage: Ansible_2_12
displayName: Sanity & Units 2.12
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
targets:
- name: Sanity
test: '2.12/sanity/1'
- name: Units
test: '2.12/units/1'
- stage: Ansible_2_11
displayName: Sanity & Units 2.11
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
targets:
- name: Sanity
test: '2.11/sanity/1'
- name: Units
test: '2.11/units/1'
- stage: Ansible_2_10 - stage: Ansible_2_10
displayName: Sanity & Units 2.10 displayName: Sanity & Units 2.10
dependsOn: [] dependsOn: []
@@ -92,10 +114,10 @@ stages:
test: centos7 test: centos7
- name: CentOS 8 - name: CentOS 8
test: centos8 test: centos8
- name: Fedora 32
test: fedora32
- name: Fedora 33 - name: Fedora 33
test: fedora33 test: fedora33
- name: Fedora 34
test: fedora34
- name: openSUSE 15 py2 - name: openSUSE 15 py2
test: opensuse15py2 test: opensuse15py2
- name: openSUSE 15 py3 - name: openSUSE 15 py3
@@ -104,6 +126,40 @@ stages:
test: ubuntu1804 test: ubuntu1804
- name: Ubuntu 20.04 - name: Ubuntu 20.04
test: ubuntu2004 test: ubuntu2004
- stage: Docker_2_12
displayName: Docker 2.12
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.12/linux/{0}/1
targets:
- name: CentOS 8
test: centos8
- name: Fedora 33
test: fedora33
- name: openSUSE 15 py3
test: opensuse15
- name: Ubuntu 20.04
test: ubuntu2004
- stage: Docker_2_11
displayName: Docker 2.11
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.11/linux/{0}/1
targets:
- name: CentOS 7
test: centos7
- name: CentOS 8
test: centos8
- name: Fedora 32
test: fedora32
- name: openSUSE 15 py2
test: opensuse15py2
- name: Ubuntu 18.04
test: ubuntu1804
- stage: Docker_2_10 - stage: Docker_2_10
displayName: Docker 2.10 displayName: Docker 2.10
dependsOn: [] dependsOn: []
@@ -114,24 +170,10 @@ stages:
targets: targets:
- name: CentOS 6 - name: CentOS 6
test: centos6 test: centos6
- name: CentOS 7
test: centos7
- name: CentOS 8
test: centos8
- name: Fedora 30
test: fedora30
- name: Fedora 31 - name: Fedora 31
test: fedora31 test: fedora31
- name: Fedora 32
test: fedora32
- name: openSUSE 15 py2
test: opensuse15py2
- name: openSUSE 15 py3
test: opensuse15
- name: Ubuntu 16.04 - name: Ubuntu 16.04
test: ubuntu1604 test: ubuntu1604
- name: Ubuntu 18.04
test: ubuntu1804
- stage: Docker_2_9 - stage: Docker_2_9
displayName: Docker 2.9 displayName: Docker 2.9
dependsOn: [] dependsOn: []
@@ -144,16 +186,8 @@ stages:
test: centos6 test: centos6
- name: CentOS 7 - name: CentOS 7
test: centos7 test: centos7
- name: CentOS 8
test: centos8
- name: Fedora 30
test: fedora30
- name: Fedora 31 - name: Fedora 31
test: fedora31 test: fedora31
- name: openSUSE 15 py2
test: opensuse15py2
- name: openSUSE 15 py3
test: opensuse15
- name: Ubuntu 16.04 - name: Ubuntu 16.04
test: ubuntu1604 test: ubuntu1604
- name: Ubuntu 18.04 - name: Ubuntu 18.04
@@ -170,12 +204,40 @@ stages:
targets: targets:
- name: macOS 11.1 - name: macOS 11.1
test: macos/11.1 test: macos/11.1
- name: RHEL 7.9
test: rhel/7.9
- name: RHEL 8.4
test: rhel/8.4
- name: FreeBSD 12.2
test: freebsd/12.2
- name: FreeBSD 13.0
test: freebsd/13.0
- stage: Remote_2_12
displayName: Remote 2.12
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.12/{0}/1
targets:
- name: macOS 11.1
test: macos/11.1
- name: RHEL 8.4
test: rhel/8.4
- name: FreeBSD 13.0
test: freebsd/13.0
- stage: Remote_2_11
displayName: Remote 2.11
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.11/{0}/1
targets:
- name: RHEL 7.9 - name: RHEL 7.9
test: rhel/7.9 test: rhel/7.9
- name: RHEL 8.3 - name: RHEL 8.3
test: rhel/8.3 test: rhel/8.3
- name: FreeBSD 11.4
test: freebsd/11.4
- name: FreeBSD 12.2 - name: FreeBSD 12.2
test: freebsd/12.2 test: freebsd/12.2
- stage: Remote_2_10 - stage: Remote_2_10
@@ -186,8 +248,6 @@ stages:
parameters: parameters:
testFormat: 2.10/{0}/1 testFormat: 2.10/{0}/1
targets: targets:
- name: RHEL 7.8
test: rhel/7.8
- name: OS X 10.11 - name: OS X 10.11
test: osx/10.11 test: osx/10.11
- name: macOS 10.15 - name: macOS 10.15
@@ -221,6 +281,27 @@ stages:
- test: 3.7 - test: 3.7
- test: 3.8 - test: 3.8
- test: 3.9 - test: 3.9
- test: "3.10"
- stage: Cloud_2_12
displayName: Cloud 2.12
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
nameFormat: Python {0}
testFormat: 2.12/cloud/{0}/1
targets:
- test: 3.9
- stage: Cloud_2_11
displayName: Cloud 2.11
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
nameFormat: Python {0}
testFormat: 2.11/cloud/{0}/1
targets:
- test: 3.8
- stage: Cloud_2_10 - stage: Cloud_2_10
displayName: Cloud 2.10 displayName: Cloud 2.10
dependsOn: [] dependsOn: []
@@ -248,16 +329,24 @@ stages:
condition: succeededOrFailed() condition: succeededOrFailed()
dependsOn: dependsOn:
- Ansible_devel - Ansible_devel
- Ansible_2_12
- Ansible_2_11
- Ansible_2_10 - Ansible_2_10
- Ansible_2_9 - Ansible_2_9
- Remote_devel - Remote_devel
- Docker_devel - Remote_2_12
- Cloud_devel - Remote_2_11
- Remote_2_10 - Remote_2_10
- Docker_2_10
- Cloud_2_10
- Remote_2_9 - Remote_2_9
- Docker_devel
- Docker_2_12
- Docker_2_11
- Docker_2_10
- Docker_2_9 - Docker_2_9
- Cloud_devel
- Cloud_2_12
- Cloud_2_11
- Cloud_2_10
- Cloud_2_9 - Cloud_2_9
jobs: jobs:
- template: templates/coverage.yml - template: templates/coverage.yml

View File

@@ -7,7 +7,7 @@ set -o pipefail -eu
output_path="$1" output_path="$1"
curl --silent --show-error https://codecov.io/bash > codecov.sh curl --silent --show-error https://ansible-ci-files.s3.us-east-1.amazonaws.com/codecov/codecov.sh > codecov.sh
for file in "${output_path}"/reports/coverage*.xml; do for file in "${output_path}"/reports/coverage*.xml; do
name="${file}" name="${file}"

View File

@@ -5,6 +5,203 @@ Community Crypto Release Notes
.. contents:: Topics .. contents:: Topics
v1.9.4
======
Release Summary
---------------
Regular bugfix release.
Bugfixes
--------
- acme_* modules - fix commands composed for OpenSSL backend to retrieve information on CSRs and certificates from stdin to use ``/dev/stdin`` instead of ``-``. This is needed for OpenSSL 1.0.1 and 1.0.2, apparently (https://github.com/ansible-collections/community.crypto/pull/279).
- acme_challenge_cert_helper - only return exception when cryptography is not installed, not when a too old version of it is installed. This prevents Ansible's callback to crash (https://github.com/ansible-collections/community.crypto/pull/281).
v1.9.3
======
Release Summary
---------------
Regular bugfix release.
Bugfixes
--------
- openssl_csr and openssl_csr_pipe - make sure that Unicode strings are used to compare strings with the cryptography backend. This fixes idempotency problems with non-ASCII letters on Python 2 (https://github.com/ansible-collections/community.crypto/issues/270, https://github.com/ansible-collections/community.crypto/pull/271).
v1.9.2
======
Release Summary
---------------
Bugfix release to fix the changelog. No other change compared to 1.9.0.
v1.9.1
======
Release Summary
---------------
Accidental 1.9.1 release. Identical to 1.9.0.
v1.9.0
======
Release Summary
---------------
Regular feature release.
Minor Changes
-------------
- get_certificate - added ``starttls`` option to retrieve certificates from servers which require clients to request an encrypted connection (https://github.com/ansible-collections/community.crypto/pull/264).
- openssh_keypair - added ``diff`` support (https://github.com/ansible-collections/community.crypto/pull/260).
Bugfixes
--------
- keypair_backend module utils - simplify code to pass sanity tests (https://github.com/ansible-collections/community.crypto/pull/263).
- openssh_keypair - fixed ``cryptography`` backend to preserve original file permissions when regenerating a keypair requires existing files to be overwritten (https://github.com/ansible-collections/community.crypto/pull/260).
- openssh_keypair - fixed error handling to restore original keypair if regeneration fails (https://github.com/ansible-collections/community.crypto/pull/260).
- x509_crl - restore inherited function signature to pass sanity tests (https://github.com/ansible-collections/community.crypto/pull/263).
v1.8.0
======
Release Summary
---------------
Regular bugfix and feature release.
Minor Changes
-------------
- Avoid internal ansible-core module_utils in favor of equivalent public API available since at least Ansible 2.9 (https://github.com/ansible-collections/community.crypto/pull/253).
- openssh certificate module utils - new module_utils for parsing OpenSSH certificates (https://github.com/ansible-collections/community.crypto/pull/246).
- openssh_cert - added ``regenerate`` option to validate additional certificate parameters which trigger regeneration of an existing certificate (https://github.com/ansible-collections/community.crypto/pull/256).
- openssh_cert - adding ``diff`` support (https://github.com/ansible-collections/community.crypto/pull/255).
Bugfixes
--------
- openssh_cert - fixed certificate generation to restore original certificate if an error is encountered (https://github.com/ansible-collections/community.crypto/pull/255).
- openssh_keypair - fixed a bug that prevented custom file attributes being applied to public keys (https://github.com/ansible-collections/community.crypto/pull/257).
v1.7.1
======
Release Summary
---------------
Bugfix release.
Bugfixes
--------
- openssl_pkcs12 - fix crash when loading passphrase-protected PKCS#12 files with ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/issues/247, https://github.com/ansible-collections/community.crypto/pull/248).
v1.7.0
======
Release Summary
---------------
Regular feature and bugfix release.
Minor Changes
-------------
- cryptography_openssh module utils - new module_utils for managing asymmetric keypairs and OpenSSH formatted/encoded asymmetric keypairs (https://github.com/ansible-collections/community.crypto/pull/213).
- openssh_keypair - added ``backend`` parameter for selecting between the cryptography library or the OpenSSH binary for the execution of actions performed by ``openssh_keypair`` (https://github.com/ansible-collections/community.crypto/pull/236).
- openssh_keypair - added ``passphrase`` parameter for encrypting/decrypting OpenSSH private keys (https://github.com/ansible-collections/community.crypto/pull/225).
- openssl_csr - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_csr_info - now returns ``public_key_type`` and ``public_key_data`` (https://github.com/ansible-collections/community.crypto/pull/233).
- openssl_csr_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/204).
- openssl_csr_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_pkcs12 - added option ``select_crypto_backend`` and a ``cryptography`` backend. This requires cryptography 3.0 or newer, and does not support the ``iter_size`` and ``maciter_size`` options (https://github.com/ansible-collections/community.crypto/pull/234).
- openssl_privatekey - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_privatekey_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/205).
- openssl_privatekey_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_publickey - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- x509_certificate - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- x509_certificate_info - now returns ``public_key_type`` and ``public_key_data`` (https://github.com/ansible-collections/community.crypto/pull/233).
- x509_certificate_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/206).
- x509_certificate_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- x509_crl - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
- x509_crl_info - add ``list_revoked_certificates`` option to avoid enumerating all revoked certificates (https://github.com/ansible-collections/community.crypto/pull/232).
- x509_crl_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/203).
Bugfixes
--------
- openssh_keypair - fix ``check_mode`` to populate return values for existing keypairs (https://github.com/ansible-collections/community.crypto/issues/113, https://github.com/ansible-collections/community.crypto/pull/230).
- various modules - prevent crashes when modules try to set attributes on not yet existing files in check mode. This will be fixed in ansible-core 2.12, but it is not backported to every Ansible version we support (https://github.com/ansible-collections/community.crypto/issue/242, https://github.com/ansible-collections/community.crypto/pull/243).
- x509_certificate - fix crash when ``assertonly`` provider is used and some error conditions should be reported (https://github.com/ansible-collections/community.crypto/issues/240, https://github.com/ansible-collections/community.crypto/pull/241).
New Modules
-----------
- openssl_publickey_info - Provide information for OpenSSL public keys
v1.6.2
======
Release Summary
---------------
Bugfix release. Fixes compatibility issue of ACME modules with step-ca.
Bugfixes
--------
- acme_* modules - avoid crashing for ACME servers where the ``meta`` directory key is not present (https://github.com/ansible-collections/community.crypto/issues/220, https://github.com/ansible-collections/community.crypto/pull/221).
v1.6.1
======
Release Summary
---------------
Bugfix release.
Bugfixes
--------
- acme_* modules - fix wrong usages of ``ACMEProtocolException`` (https://github.com/ansible-collections/community.crypto/pull/216, https://github.com/ansible-collections/community.crypto/pull/217).
v1.6.0
======
Release Summary
---------------
Fixes compatibility issues with the latest ansible-core 2.11 beta, and contains a lot of internal refactoring for the ACME modules and support for private key passphrases for them.
Minor Changes
-------------
- acme module_utils - the ``acme`` module_utils has been split up into several Python modules (https://github.com/ansible-collections/community.crypto/pull/184).
- acme_* modules - codebase refactor which should not be visible to end-users (https://github.com/ansible-collections/community.crypto/pull/184).
- acme_* modules - support account key passphrases for ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/issues/197, https://github.com/ansible-collections/community.crypto/pull/207).
- acme_certificate_revoke - support revoking by private keys that are passphrase protected for ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/pull/207).
- acme_challenge_cert_helper - add ``private_key_passphrase`` parameter (https://github.com/ansible-collections/community.crypto/pull/207).
Deprecated Features
-------------------
- acme module_utils - the ``acme`` module_utils (``ansible_collections.community.crypto.plugins.module_utils.acme``) is deprecated and will be removed in community.crypto 2.0.0. Use the new Python modules in the ``acme`` package instead (``ansible_collections.community.crypto.plugins.module_utils.acme.xxx``) (https://github.com/ansible-collections/community.crypto/pull/184).
Bugfixes
--------
- action_module plugin helper - make compatible with latest changes in ansible-core 2.11.0b3 (https://github.com/ansible-collections/community.crypto/pull/202).
- openssl_privatekey_pipe - make compatible with latest changes in ansible-core 2.11.0b3 (https://github.com/ansible-collections/community.crypto/pull/202).
v1.5.0 v1.5.0
====== ======

View File

@@ -7,9 +7,11 @@ Provides modules for [Ansible](https://www.ansible.com/community) for various cr
You can find [documentation for this collection on the Ansible docs site](https://docs.ansible.com/ansible/latest/collections/community/crypto/). You can find [documentation for this collection on the Ansible docs site](https://docs.ansible.com/ansible/latest/collections/community/crypto/).
Please note that this collection does **not** support Windows targets.
## Tested with Ansible ## Tested with Ansible
Tested with both the current Ansible 2.9 and 2.10 releases and the current development version of Ansible. Ansible versions before 2.9.10 are not supported. Tested with the current Ansible 2.9, ansible-base 2.10, ansible-core 2.11 and ansible-core 2.12 releases and the current development version of ansible-core. Ansible versions before 2.9.10 are not supported.
## External requirements ## External requirements

View File

@@ -341,3 +341,224 @@ releases:
- 179-openssl-csr-basic-constraint.yml - 179-openssl-csr-basic-constraint.yml
- 193-luks_device-sector_size.yml - 193-luks_device-sector_size.yml
release_date: '2021-03-08' release_date: '2021-03-08'
1.6.0:
changes:
bugfixes:
- action_module plugin helper - make compatible with latest changes in ansible-core
2.11.0b3 (https://github.com/ansible-collections/community.crypto/pull/202).
- openssl_privatekey_pipe - make compatible with latest changes in ansible-core
2.11.0b3 (https://github.com/ansible-collections/community.crypto/pull/202).
deprecated_features:
- acme module_utils - the ``acme`` module_utils (``ansible_collections.community.crypto.plugins.module_utils.acme``)
is deprecated and will be removed in community.crypto 2.0.0. Use the new Python
modules in the ``acme`` package instead (``ansible_collections.community.crypto.plugins.module_utils.acme.xxx``)
(https://github.com/ansible-collections/community.crypto/pull/184).
minor_changes:
- acme module_utils - the ``acme`` module_utils has been split up into several
Python modules (https://github.com/ansible-collections/community.crypto/pull/184).
- acme_* modules - codebase refactor which should not be visible to end-users
(https://github.com/ansible-collections/community.crypto/pull/184).
- acme_* modules - support account key passphrases for ``cryptography`` backend
(https://github.com/ansible-collections/community.crypto/issues/197, https://github.com/ansible-collections/community.crypto/pull/207).
- acme_certificate_revoke - support revoking by private keys that are passphrase
protected for ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/pull/207).
- acme_challenge_cert_helper - add ``private_key_passphrase`` parameter (https://github.com/ansible-collections/community.crypto/pull/207).
release_summary: Fixes compatibility issues with the latest ansible-core 2.11
beta, and contains a lot of internal refactoring for the ACME modules and
support for private key passphrases for them.
fragments:
- 1.6.0.yml
- 184-acme-refactor.yml
- 202-actionmodule-plugin-utils-ansible-core-2.11.yml
- 207-acme-account-key-passphrase.yml
release_date: '2021-03-22'
1.6.1:
changes:
bugfixes:
- acme_* modules - fix wrong usages of ``ACMEProtocolException`` (https://github.com/ansible-collections/community.crypto/pull/216,
https://github.com/ansible-collections/community.crypto/pull/217).
release_summary: Bugfix release.
fragments:
- 1.6.1.yml
- 217-acme-exceptions.yml
release_date: '2021-04-11'
1.6.2:
changes:
bugfixes:
- acme_* modules - avoid crashing for ACME servers where the ``meta`` directory
key is not present (https://github.com/ansible-collections/community.crypto/issues/220,
https://github.com/ansible-collections/community.crypto/pull/221).
release_summary: Bugfix release. Fixes compatibility issue of ACME modules with
step-ca.
fragments:
- 1.6.2.yml
- 221-acme-meta.yml
release_date: '2021-04-28'
1.7.0:
changes:
bugfixes:
- openssh_keypair - fix ``check_mode`` to populate return values for existing
keypairs (https://github.com/ansible-collections/community.crypto/issues/113,
https://github.com/ansible-collections/community.crypto/pull/230).
- various modules - prevent crashes when modules try to set attributes on not
yet existing files in check mode. This will be fixed in ansible-core 2.12,
but it is not backported to every Ansible version we support (https://github.com/ansible-collections/community.crypto/issue/242,
https://github.com/ansible-collections/community.crypto/pull/243).
- x509_certificate - fix crash when ``assertonly`` provider is used and some
error conditions should be reported (https://github.com/ansible-collections/community.crypto/issues/240,
https://github.com/ansible-collections/community.crypto/pull/241).
minor_changes:
- cryptography_openssh module utils - new module_utils for managing asymmetric
keypairs and OpenSSH formatted/encoded asymmetric keypairs (https://github.com/ansible-collections/community.crypto/pull/213).
- openssh_keypair - added ``backend`` parameter for selecting between the cryptography
library or the OpenSSH binary for the execution of actions performed by ``openssh_keypair``
(https://github.com/ansible-collections/community.crypto/pull/236).
- openssh_keypair - added ``passphrase`` parameter for encrypting/decrypting
OpenSSH private keys (https://github.com/ansible-collections/community.crypto/pull/225).
- openssl_csr - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_csr_info - now returns ``public_key_type`` and ``public_key_data``
(https://github.com/ansible-collections/community.crypto/pull/233).
- openssl_csr_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/204).
- openssl_csr_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_pkcs12 - added option ``select_crypto_backend`` and a ``cryptography``
backend. This requires cryptography 3.0 or newer, and does not support the
``iter_size`` and ``maciter_size`` options (https://github.com/ansible-collections/community.crypto/pull/234).
- openssl_privatekey - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_privatekey_info - refactor module to allow code re-use for diff mode
(https://github.com/ansible-collections/community.crypto/pull/205).
- openssl_privatekey_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- openssl_publickey - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- x509_certificate - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- x509_certificate_info - now returns ``public_key_type`` and ``public_key_data``
(https://github.com/ansible-collections/community.crypto/pull/233).
- x509_certificate_info - refactor module to allow code re-use for diff mode
(https://github.com/ansible-collections/community.crypto/pull/206).
- x509_certificate_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- x509_crl - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38,
https://github.com/ansible-collections/community.crypto/pull/150).
- x509_crl_info - add ``list_revoked_certificates`` option to avoid enumerating
all revoked certificates (https://github.com/ansible-collections/community.crypto/pull/232).
- x509_crl_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/203).
release_summary: Regular feature and bugfix release.
fragments:
- 1.7.0.yml
- 150-diff.yml
- 203-x509_crl_info.yml
- 204-openssl_csr_info.yml
- 205-openssl_privatekey_info.yml
- 206-x509_certificate_info.yml
- 213-cryptography-openssh-module-utils.yml
- 225-openssh-keypair-passphrase.yml
- 230-openssh_keypair-check_mode-return-values.yml
- 232-x509_crl_info-list_revoked_certificates.yml
- 233-public-key-info.yml
- 234-openssl_pkcs12-cryptography.yml
- 236-openssh_keypair-backends.yml
- 241-x509_certificate-assertonly.yml
- 243-permission-check-crash.yml
modules:
- description: Provide information for OpenSSL public keys
name: openssl_publickey_info
namespace: ''
release_date: '2021-06-02'
1.7.1:
changes:
bugfixes:
- openssl_pkcs12 - fix crash when loading passphrase-protected PKCS#12 files
with ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/issues/247,
https://github.com/ansible-collections/community.crypto/pull/248).
release_summary: Bugfix release.
fragments:
- 1.7.1.yml
- 248-openssl_pkcs12-passphrase-fix.yml
release_date: '2021-06-11'
1.8.0:
changes:
bugfixes:
- openssh_cert - fixed certificate generation to restore original certificate
if an error is encountered (https://github.com/ansible-collections/community.crypto/pull/255).
- openssh_keypair - fixed a bug that prevented custom file attributes being
applied to public keys (https://github.com/ansible-collections/community.crypto/pull/257).
minor_changes:
- Avoid internal ansible-core module_utils in favor of equivalent public API
available since at least Ansible 2.9 (https://github.com/ansible-collections/community.crypto/pull/253).
- openssh certificate module utils - new module_utils for parsing OpenSSH certificates
(https://github.com/ansible-collections/community.crypto/pull/246).
- openssh_cert - added ``regenerate`` option to validate additional certificate
parameters which trigger regeneration of an existing certificate (https://github.com/ansible-collections/community.crypto/pull/256).
- openssh_cert - adding ``diff`` support (https://github.com/ansible-collections/community.crypto/pull/255).
release_summary: Regular bugfix and feature release.
fragments:
- 1.8.0.yml
- 246-openssh-certificate-module-utils.yml
- 255-openssh_cert-adding-diff-support.yml
- 256-openssh_cert-adding-idempotency-option.yml
- 257-openssh-keypair-fix-pubkey-permissions.yml
- ansible-core-_text.yml
release_date: '2021-08-10'
1.9.0:
changes:
bugfixes:
- keypair_backend module utils - simplify code to pass sanity tests (https://github.com/ansible-collections/community.crypto/pull/263).
- openssh_keypair - fixed ``cryptography`` backend to preserve original file
permissions when regenerating a keypair requires existing files to be overwritten
(https://github.com/ansible-collections/community.crypto/pull/260).
- openssh_keypair - fixed error handling to restore original keypair if regeneration
fails (https://github.com/ansible-collections/community.crypto/pull/260).
- x509_crl - restore inherited function signature to pass sanity tests (https://github.com/ansible-collections/community.crypto/pull/263).
minor_changes:
- get_certificate - added ``starttls`` option to retrieve certificates from
servers which require clients to request an encrypted connection (https://github.com/ansible-collections/community.crypto/pull/264).
- openssh_keypair - added ``diff`` support (https://github.com/ansible-collections/community.crypto/pull/260).
release_summary: Regular feature release.
fragments:
- 1.9.0.yml
- 260-openssh_keypair-diff-support.yml
- 263-sanity.yml
- 264-get_certificate-add-starttls-option.yml
release_date: '2021-08-30'
1.9.1:
changes:
release_summary: Accidental 1.9.1 release. Identical to 1.9.0.
release_date: '2021-08-30'
1.9.2:
changes:
release_summary: Bugfix release to fix the changelog. No other change compared
to 1.9.0.
fragments:
- 1.9.2.yml
release_date: '2021-08-30'
1.9.3:
changes:
bugfixes:
- openssl_csr and openssl_csr_pipe - make sure that Unicode strings are used
to compare strings with the cryptography backend. This fixes idempotency problems
with non-ASCII letters on Python 2 (https://github.com/ansible-collections/community.crypto/issues/270,
https://github.com/ansible-collections/community.crypto/pull/271).
release_summary: Regular bugfix release.
fragments:
- 1.9.3.yml
- 271-openssl_csr-utf8.yml
release_date: '2021-09-14'
1.9.4:
changes:
bugfixes:
- acme_* modules - fix commands composed for OpenSSL backend to retrieve information
on CSRs and certificates from stdin to use ``/dev/stdin`` instead of ``-``.
This is needed for OpenSSL 1.0.1 and 1.0.2, apparently (https://github.com/ansible-collections/community.crypto/pull/279).
- acme_challenge_cert_helper - only return exception when cryptography is not
installed, not when a too old version of it is installed. This prevents Ansible's
callback to crash (https://github.com/ansible-collections/community.crypto/pull/281).
release_summary: Regular bugfix release.
fragments:
- 1.9.4.yml
- 279-acme-openssl.yml
- 282-acme_challenge_cert_helper-error.yml
release_date: '2021-09-28'

View File

@@ -0,0 +1,6 @@
---
sections:
- title: Scenario Guides
toctree:
- guide_selfsigned
- guide_ownca

View File

@@ -0,0 +1,148 @@
.. _ansible_collections.community.crypto.docsite.guide_ownca:
How to create a small CA
========================
The `community.crypto collection <https://galaxy.ansible.com/community/crypto>`_ offers multiple modules that create private keys, certificate signing requests, and certificates. This guide shows how to create your own small CA and how to use it to sign certificates.
In all examples, we assume that the CA's private key is password protected, where the password is provided in the ``secret_ca_passphrase`` variable.
Set up the CA
-------------
Any certificate can be used as a CA certificate. You can create a self-signed certificate (see :ref:`ansible_collections.community.crypto.docsite.guide_selfsigned`), use another CA certificate to sign a new certificate (using the instructions below for signing a certificate), ask (and pay) a commercial CA to sign your CA certificate, etc.
The following instructions show how to set up a simple self-signed CA certificate.
.. code-block:: yaml+jinja
- name: Create private key with password protection
community.crypto.openssl_privatekey:
path: /path/to/ca-certificate.key
passphrase: "{{ secret_ca_passphrase }}"
- name: Create certificate signing request (CSR) for CA certificate
community.crypto.openssl_csr_pipe:
privatekey_path: /path/to/ca-certificate.key
privatekey_passphrase: "{{ secret_ca_passphrase }}"
common_name: Ansible CA
use_common_name_for_san: false # since we do not specify SANs, don't use CN as a SAN
basic_constraints:
- 'CA:TRUE'
basic_constraints_critical: yes
key_usage:
- keyCertSign
key_usage_critical: true
register: ca_csr
- name: Create self-signed CA certificate from CSR
community.crypto.x509_certificate:
path: /path/to/ca-certificate.pem
csr_content: "{{ ca_csr.csr }}"
privatekey_path: /path/to/ca-certificate.key
privatekey_passphrase: "{{ secret_ca_passphrase }}"
provider: selfsigned
Use the CA to sign a certificate
--------------------------------
To sign a certificate, you must pass a CSR to the :ref:`community.crypto.x509_certificate module <ansible_collections.community.crypto.x509_certificate_module>` or :ref:`community.crypto.x509_certificate_pipe module <ansible_collections.community.crypto.x509_certificate_pipe_module>`.
In the following example, we assume that the certificate to sign (including its private key) are on ``server_1``, while our CA certificate is on ``server_2``. We do not want any key material to leave each respective server.
.. code-block:: yaml+jinja
- name: Create private key for new certificate on server_1
community.crypto.openssl_privatekey:
path: /path/to/certificate.key
delegate_to: server_1
run_once: true
- name: Create certificate signing request (CSR) for new certificate
community.crypto.openssl_csr_pipe:
privatekey_path: /path/to/certificate.key
subject_alt_name:
- "DNS:ansible.com"
- "DNS:www.ansible.com"
- "DNS:docs.ansible.com"
delegate_to: server_1
run_once: true
register: csr
- name: Sign certificate with our CA
community.crypto.x509_certificate_pipe:
csr_content: "{{ ca_csr.csr }}"
provider: ownca
ownca_path: /path/to/ca-certificate.pem
ownca_privatekey_path: /path/to/ca-certificate.key
ownca_privatekey_passphrase: "{{ secret_ca_passphrase }}"
ownca_not_after: +365d # valid for one year
ownca_not_before: "-1d" # valid since yesterday
delegate_to: server_2
run_once: true
register: certificate
- name: Write certificate file on server_1
copy:
dest: /path/to/certificate.pem
content: "{{ certificate.certificate }}"
delegate_to: server_1
run_once: true
Please note that the above procedure is **not idempotent**. The following extended example reads the existing certificate from ``server_1`` (if exists) and provides it to the :ref:`community.crypto.x509_certificate_pipe module <ansible_collections.community.crypto.x509_certificate_pipe_module>`, and only writes the result back if it was changed:
.. code-block:: yaml+jinja
- name: Create private key for new certificate on server_1
community.crypto.openssl_privatekey:
path: /path/to/certificate.key
delegate_to: server_1
run_once: true
- name: Create certificate signing request (CSR) for new certificate
community.crypto.openssl_csr_pipe:
privatekey_path: /path/to/certificate.key
subject_alt_name:
- "DNS:ansible.com"
- "DNS:www.ansible.com"
- "DNS:docs.ansible.com"
delegate_to: server_1
run_once: true
register: csr
- name: Check whether certificate exists
stat:
path: /path/to/certificate.pem
delegate_to: server_1
run_once: true
register: certificate_exists
- name: Read existing certificate if exists
slurp:
src: /path/to/certificate.pem
when: certificate_exists.stat.exists
delegate_to: server_1
run_once: true
register: certificate
- name: Sign certificate with our CA
community.crypto.x509_certificate_pipe:
content: "{{ (certificate.content | b64decode) if certificate_exists.stat.exists else omit }}"
csr_content: "{{ ca_csr.csr }}"
provider: ownca
ownca_path: /path/to/ca-certificate.pem
ownca_privatekey_path: /path/to/ca-certificate.key
ownca_privatekey_passphrase: "{{ secret_ca_passphrase }}"
ownca_not_after: +365d # valid for one year
ownca_not_before: "-1d" # valid since yesterday
delegate_to: server_2
run_once: true
register: certificate
- name: Write certificate file on server_1
copy:
dest: /path/to/certificate.pem
content: "{{ certificate.certificate }}"
delegate_to: server_1
run_once: true
when: certificate is changed

View File

@@ -0,0 +1,60 @@
.. _ansible_collections.community.crypto.docsite.guide_selfsigned:
How to create self-signed certificates
======================================
The `community.crypto collection <https://galaxy.ansible.com/community/crypto>`_ offers multiple modules that create private keys, certificate signing requests, and certificates. This guide shows how to create self-signed certificates.
For creating any kind of certificate, you always have to start with a private key. You can use the :ref:`community.crypto.openssl_privatekey module <ansible_collections.community.crypto.openssl_privatekey_module>` to create a private key. If you only specify ``path``, the default parameters will be used. This will result in a 4096 bit RSA private key:
.. code-block:: yaml+jinja
- name: Create private key (RSA, 4096 bits)
community.crypto.openssl_privatekey:
path: /path/to/certificate.key
You can specify ``type`` to select another key type, ``size`` to select a different key size (only available for RSA and DSA keys), or ``passphrase`` if you want to store the key password-protected:
.. code-block:: yaml+jinja
- name: Create private key (X25519) with password protection
community.crypto.openssl_privatekey:
path: /path/to/certificate.key
type: X25519
passphrase: changeme
To create a very simple self-signed certificate with no specific information, you can proceed directly with the :ref:`community.crypto.x509_certificate module <ansible_collections.community.crypto.x509_certificate_module>`:
.. code-block:: yaml+jinja
- name: Create simple self-signed certificate
community.crypto.x509_certificate:
path: /path/to/certificate.pem
privatekey_path: /path/to/certificate.key
provider: selfsigned
(If you used ``passphrase`` for the private key, you have to provide ``privatekey_passphrase``.)
You can use ``selfsigned_not_after`` to define when the certificate expires (default: in roughly 10 years), and ``selfsigned_not_before`` to define from when the certificate is valid (default: now).
To define further properties of the certificate, like the subject, Subject Alternative Names (SANs), key usages, name constraints, etc., you need to first create a Certificate Signing Request (CSR) and provide it to the :ref:`community.crypto.x509_certificate module <ansible_collections.community.crypto.x509_certificate_module>`. If you do not need the CSR file, you can use the :ref:`community.crypto.openssl_csr_pipe module <ansible_collections.community.crypto.openssl_csr_pipe_module>` as in the example below. (To store it to disk, use the :ref:`community.crypto.openssl_csr module <ansible_collections.community.crypto.openssl_csr_module>` instead.)
.. code-block:: yaml+jinja
- name: Create certificate signing request (CSR) for self-signed certificate
community.crypto.openssl_csr_pipe:
privatekey_path: /path/to/certificate.key
common_name: ansible.com
organization_name: Ansible, Inc.
subject_alt_name:
- "DNS:ansible.com"
- "DNS:www.ansible.com"
- "DNS:docs.ansible.com"
register: csr
- name: Create self-signed certificate from CSR
community.crypto.x509_certificate:
path: /path/to/certificate.pem
csr_content: "{{ csr.csr }}"
privatekey_path: /path/to/certificate.key
provider: selfsigned

View File

@@ -1,6 +1,6 @@
namespace: community namespace: community
name: crypto name: crypto
version: 1.5.0 version: 1.9.4
readme: README.md readme: README.md
authors: authors:
- Ansible (github.com/ansible) - Ansible (github.com/ansible)

View File

@@ -9,7 +9,7 @@ __metaclass__ = type
import base64 import base64
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
from ansible_collections.community.crypto.plugins.plugin_utils.action_module import ActionModuleBase from ansible_collections.community.crypto.plugins.plugin_utils.action_module import ActionModuleBase

View File

@@ -57,6 +57,12 @@ options:
Ansible in the process of moving the module with its argument to Ansible in the process of moving the module with its argument to
the node where it is executed." the node where it is executed."
type: str 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: account_uri:
description: description:
- "If specified, assumes that the account URI is as given. If the - "If specified, assumes that the account URI is as given. If the

View File

@@ -227,12 +227,11 @@ options:
description: description:
- The authority key identifier as a hex string, where two bytes are separated by colons. - The authority key identifier as a hex string, where two bytes are separated by colons.
- "Example: C(00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33)" - "Example: C(00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33)"
- If specified, I(authority_cert_issuer) must also be specified.
- "Please note that commercial CAs ignore this value, respectively use a value of their - "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 own choice. Specifying this option is mostly useful for self-signed certificates
or for own CAs." or for own CAs."
- Note that this is only supported if the C(cryptography) backend is used! - Note that this is only supported if the C(cryptography) backend is used!
- The C(AuthorityKeyIdentifier) will only be added if at least one of I(authority_key_identifier), - The C(AuthorityKeyIdentifier) extension will only be added if at least one of I(authority_key_identifier),
I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. I(authority_cert_issuer) and I(authority_cert_serial_number) is specified.
type: str type: str
authority_cert_issuer: authority_cert_issuer:
@@ -241,23 +240,24 @@ options:
- Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName), - Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName),
C(otherName) and the ones specific to your CA) C(otherName) and the ones specific to your CA)
- "Example: C(DNS:ca.example.org)" - "Example: C(DNS:ca.example.org)"
- If specified, I(authority_key_identifier) must also be specified. - If specified, I(authority_cert_serial_number) must also be specified.
- "Please note that commercial CAs ignore this value, respectively use a value of their - "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 own choice. Specifying this option is mostly useful for self-signed certificates
or for own CAs." or for own CAs."
- Note that this is only supported if the C(cryptography) backend is used! - Note that this is only supported if the C(cryptography) backend is used!
- The C(AuthorityKeyIdentifier) will only be added if at least one of I(authority_key_identifier), - The C(AuthorityKeyIdentifier) extension will only be added if at least one of I(authority_key_identifier),
I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. I(authority_cert_issuer) and I(authority_cert_serial_number) is specified.
type: list type: list
elements: str elements: str
authority_cert_serial_number: authority_cert_serial_number:
description: description:
- The authority cert serial number. - The authority cert serial number.
- If specified, I(authority_cert_issuer) must also be specified.
- Note that this is only supported if the C(cryptography) backend is used! - 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 - "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 own choice. Specifying this option is mostly useful for self-signed certificates
or for own CAs." or for own CAs."
- The C(AuthorityKeyIdentifier) will only be added if at least one of I(authority_key_identifier), - The C(AuthorityKeyIdentifier) extension will only be added if at least one of I(authority_key_identifier),
I(authority_cert_issuer) and I(authority_cert_serial_number) is specified. I(authority_cert_issuer) and I(authority_cert_serial_number) is specified.
type: int type: int
crl_distribution_points: crl_distribution_points:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import base64
import binascii
import copy
import datetime
import hashlib
import json
import locale
import os
import re
import shutil
import sys
import tempfile
import traceback
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.urls import fetch_url
from ansible.module_utils.six.moves.urllib.parse import unquote
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
get_default_argspec,
ACMEDirectory,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
CryptographyBackend,
CRYPTOGRAPHY_VERSION,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
OpenSSLCLIBackend,
)
from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import (
handle_standard_module_arguments,
set_crypto_backend,
HAS_CURRENT_CRYPTOGRAPHY,
)
from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import ACMELegacyAccount as ACMEAccount
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
read_file,
write_file,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
pem_to_der,
process_links,
)
def openssl_get_csr_identifiers(openssl_binary, module, csr_filename, csr_content=None):
module.deprecate(
'Please adjust your custom module/plugin to the ACME module_utils refactor '
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
'your code', version='2.0.0', collection_name='community.crypto')
return OpenSSLCLIBackend(module, openssl_binary=openssl_binary).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
def cryptography_get_csr_identifiers(module, csr_filename, csr_content=None):
module.deprecate(
'Please adjust your custom module/plugin to the ACME module_utils refactor '
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
'your code', version='2.0.0', collection_name='community.crypto')
return CryptographyBackend(module).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
def cryptography_get_cert_days(module, cert_file, now=None):
module.deprecate(
'Please adjust your custom module/plugin to the ACME module_utils refactor '
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
'your code', version='2.0.0', collection_name='community.crypto')
return CryptographyBackend(module).get_cert_days(cert_filename=cert_file, now=now)

View File

@@ -0,0 +1,267 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import locale
from ansible.module_utils.basic import missing_required_lib
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import HAS_CURRENT_CRYPTOGRAPHY as _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
CryptographyBackend,
CRYPTOGRAPHY_VERSION,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
OpenSSLCLIBackend,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
create_key_authorization,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
KeyParsingError,
)
HAS_CURRENT_CRYPTOGRAPHY = _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY
def set_crypto_backend(module):
'''
Sets which crypto backend to use (default: auto detection).
Does not care whether a new enough cryptoraphy is available or not. Must
be called before any real stuff is done which might evaluate
``HAS_CURRENT_CRYPTOGRAPHY``.
'''
global HAS_CURRENT_CRYPTOGRAPHY
module.deprecate(
'Please adjust your custom module/plugin to the ACME module_utils refactor '
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
'your code', version='2.0.0', collection_name='community.crypto')
# Choose backend
backend = module.params['select_crypto_backend']
if backend == 'auto':
pass
elif backend == 'openssl':
HAS_CURRENT_CRYPTOGRAPHY = False
elif backend == 'cryptography':
if not _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY:
module.fail_json(msg=missing_required_lib('cryptography'))
HAS_CURRENT_CRYPTOGRAPHY = True
else:
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend))
# Inform about choices
if HAS_CURRENT_CRYPTOGRAPHY:
module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION))
return 'cryptography'
else:
module.debug('Using OpenSSL binary backend')
return 'openssl'
def handle_standard_module_arguments(module, needs_acme_v2=False):
'''
Do standard module setup, argument handling and warning emitting.
'''
backend = set_crypto_backend(module)
if not module.params['validate_certs']:
module.warn(
'Disabling certificate validation for communications with ACME endpoint. '
'This should only be done for testing against a local ACME server for '
'development purposes, but *never* for production purposes.'
)
if module.params['acme_version'] is None:
module.params['acme_version'] = 1
module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on",
version='2.0.0', collection_name='community.crypto')
if module.params['acme_directory'] is None:
module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory'
module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on",
version='2.0.0', collection_name='community.crypto')
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))
# AnsibleModule() changes the locale, so change it back to C because we rely on time.strptime() when parsing certificate dates.
module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
locale.setlocale(locale.LC_ALL, 'C')
return backend
def get_compatibility_backend(module):
if HAS_CURRENT_CRYPTOGRAPHY:
return CryptographyBackend(module)
else:
return OpenSSLCLIBackend(module)
class ACMELegacyAccount(object):
'''
ACME account object. Handles the authorized communication with the
ACME server. Provides access to account bound information like
the currently active authorizations and valid certificates
'''
def __init__(self, module):
module.deprecate(
'Please adjust your custom module/plugin to the ACME module_utils refactor '
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
'your code', version='2.0.0', collection_name='community.crypto')
backend = get_compatibility_backend(module)
self.client = ACMEClient(module, backend)
self.account = ACMEAccount(self.client)
self.key = self.client.account_key_file
self.key_content = self.client.account_key_content
self.uri = self.client.account_uri
self.key_data = self.client.account_key_data
self.jwk = self.client.account_jwk
self.jws_header = self.client.account_jws_header
self.directory = self.client.directory
def get_keyauthorization(self, token):
'''
Returns the key authorization for the given token
https://tools.ietf.org/html/rfc8555#section-8.1
'''
return create_key_authorization(self.client, token)
def parse_key(self, key_file=None, key_content=None):
'''
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
(error, key_data).
'''
try:
return None, self.client.parse_key(key_file=key_file, key_content=key_content)
except KeyParsingError as e:
return e.msg, {}
def sign_request(self, protected, payload, key_data, encode_payload=True):
return self.client.sign_request(protected, payload, key_data, encode_payload=encode_payload)
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True):
'''
Sends a JWS signed HTTP POST request to the ACME server and returns
the response as dictionary
https://tools.ietf.org/html/rfc8555#section-6.2
If payload is None, a POST-as-GET is performed.
(https://tools.ietf.org/html/rfc8555#section-6.3)
'''
return self.client.send_signed_request(
url,
payload,
key_data=key_data,
jws_header=jws_header,
parse_json_result=parse_json_result,
encode_payload=encode_payload,
fail_on_error=False,
)
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True):
'''
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.
'''
return self.client.get_request(
uri,
parse_json_result=parse_json_result,
headers=headers,
get_only=get_only,
fail_on_error=fail_on_error,
)
def set_account_uri(self, uri):
'''
Set account URI. For ACME v2, it needs to be used to sending signed
requests.
'''
self.client.set_account_uri(uri)
self.uri = self.client.account_uri
def get_account_data(self):
'''
Retrieve account information. Can only be called when the account
URI is already known (such as after calling setup_account).
Return None if the account was deactivated, or a dict otherwise.
'''
return self.account.get_account_data()
def setup_account(self, contact=None, agreement=None, terms_agreed=False,
allow_creation=True, remove_account_uri_if_not_exists=False,
external_account_binding=None):
'''
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
account exists is to try and create one with the provided account
key, this method will always result in an account being present
(except on error situations). For ACME v2, a new account will
only be created if ``allow_creation`` is set to True.
For ACME v2, ``check_mode`` is fully respected. For ACME v1, the
account might be created if it does not yet exist.
Return a pair ``(created, account_data)``. Here, ``created`` will
be ``True`` in case the account was created or would be created
(check mode). ``account_data`` will be the current account data,
or ``None`` if the account does not exist.
The account URI will be stored in ``self.uri``; if it is ``None``,
the account does not exist.
If specified, ``external_account_binding`` should be a dictionary
with keys ``kid``, ``alg`` and ``key``
(https://tools.ietf.org/html/rfc8555#section-7.3.4).
https://tools.ietf.org/html/rfc8555#section-7.3
'''
result = self.account.setup_account(
contact=contact,
agreement=agreement,
terms_agreed=terms_agreed,
allow_creation=allow_creation,
remove_account_uri_if_not_exists=remove_account_uri_if_not_exists,
external_account_binding=external_account_binding,
)
self.uri = self.client.account_uri
return result
def update_account(self, account_data, contact=None):
'''
Update an account on the ACME server. Check mode is fully respected.
The current account data must be provided as ``account_data``.
Return a pair ``(updated, account_data)``, where ``updated`` is
``True`` in case something changed (contact info updated) or
would be changed (check mode), and ``account_data`` the updated
account data.
https://tools.ietf.org/html/rfc8555#section-7.3.2
'''
return self.account.update_account(account_data, contact=contact)

View File

@@ -0,0 +1,251 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
ModuleFailException,
)
class ACMEAccount(object):
'''
ACME account object. Allows to create new accounts, check for existence of accounts,
retrieve account data.
'''
def __init__(self, client):
# Set to true to enable logging of all signed requests
self._debug = False
self.client = client
def _new_reg(self, contact=None, agreement=None, terms_agreed=False, allow_creation=True,
external_account_binding=None):
'''
Registers a new ACME account. Returns a pair ``(created, data)``.
Here, ``created`` is ``True`` if the account was created and
``False`` if it already existed (e.g. it was not newly created),
or does not exist. In case the account was created or exists,
``data`` contains the account data; otherwise, it is ``None``.
If specified, ``external_account_binding`` should be a dictionary
with keys ``kid``, ``alg`` and ``key``
(https://tools.ietf.org/html/rfc8555#section-7.3.4).
https://tools.ietf.org/html/rfc8555#section-7.3
'''
contact = contact or []
if self.client.version == 1:
new_reg = {
'resource': 'new-reg',
'contact': contact
}
if agreement:
new_reg['agreement'] = agreement
else:
new_reg['agreement'] = self.client.directory['meta']['terms-of-service']
if external_account_binding is not None:
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.
# Note that we pass contact here: ZeroSSL does not accept regisration calls without contacts, even
# if onlyReturnExisting is set to true.
created, data = self._new_reg(contact=contact, allow_creation=False)
if data:
# An account already exists! Return data
return created, data
# An account does not yet exist. Try to create one next.
new_reg = {
'contact': contact
}
if not allow_creation:
# https://tools.ietf.org/html/rfc8555#section-7.3.1
new_reg['onlyReturnExisting'] = True
if terms_agreed:
new_reg['termsOfServiceAgreed'] = True
url = self.client.directory['newAccount']
if external_account_binding is not None:
new_reg['externalAccountBinding'] = self.client.sign_request(
{
'alg': external_account_binding['alg'],
'kid': external_account_binding['kid'],
'url': url,
},
self.client.account_jwk,
self.client.backend.create_mac_key(external_account_binding['alg'], external_account_binding['key'])
)
elif self.client.directory['meta'].get('externalAccountRequired') and allow_creation:
raise ModuleFailException(
'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(url, new_reg, fail_on_error=False)
if info['status'] in ([200, 201] if self.client.version == 1 else [201]):
# Account did not exist
if 'location' in info:
self.client.set_account_uri(info['location'])
return True, result
elif info['status'] == (409 if self.client.version == 1 else 200):
# Account did exist
if result.get('status') == 'deactivated':
# A bug in Pebble (https://github.com/letsencrypt/pebble/issues/179) and
# Boulder (https://github.com/letsencrypt/boulder/issues/3971): this should
# not return a valid account object according to
# https://tools.ietf.org/html/rfc8555#section-7.3.6:
# "Once an account is deactivated, the server MUST NOT accept further
# requests authorized by that account's key."
if not allow_creation:
return False, None
else:
raise ModuleFailException("Account is deactivated")
if 'location' in info:
self.client.set_account_uri(info['location'])
return False, result
elif info['status'] == 400 and result['type'] == 'urn:ietf:params:acme:error:accountDoesNotExist' and not allow_creation:
# Account does not exist (and we didn't try to create it)
return False, None
elif info['status'] == 403 and result['type'] == 'urn:ietf:params:acme:error:unauthorized' and 'deactivated' in (result.get('detail') or ''):
# Account has been deactivated; currently works for Pebble; hasn't been
# implemented for Boulder (https://github.com/letsencrypt/boulder/issues/3971),
# might need adjustment in error detection.
if not allow_creation:
return False, None
else:
raise ModuleFailException("Account is deactivated")
else:
raise ACMEProtocolException(
self.client.module, msg='Registering ACME account failed', info=info, content_json=result)
def get_account_data(self):
'''
Retrieve account information. Can only be called when the account
URI is already known (such as after calling setup_account).
Return None if the account was deactivated, or a dict otherwise.
'''
if self.client.account_uri is None:
raise ModuleFailException("Account URI unknown")
if self.client.version == 1:
data = {}
data['resource'] = 'reg'
result, info = self.client.send_signed_request(self.client.account_uri, data, fail_on_error=False)
else:
# 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 info['status'] in (400, 403) and result.get('type') == 'urn:ietf:params:acme:error:unauthorized':
# Returned when account is deactivated
return None
if info['status'] in (400, 404) and result.get('type') == 'urn:ietf:params:acme:error:accountDoesNotExist':
# Returned when account does not exist
return None
if info['status'] < 200 or info['status'] >= 300:
raise ACMEProtocolException(
self.client.module, msg='Error retrieving account data', info=info, content_json=result)
return result
def setup_account(self, contact=None, agreement=None, terms_agreed=False,
allow_creation=True, remove_account_uri_if_not_exists=False,
external_account_binding=None):
'''
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
account exists is to try and create one with the provided account
key, this method will always result in an account being present
(except on error situations). For ACME v2, a new account will
only be created if ``allow_creation`` is set to True.
For ACME v2, ``check_mode`` is fully respected. For ACME v1, the
account might be created if it does not yet exist.
Return a pair ``(created, account_data)``. Here, ``created`` will
be ``True`` in case the account was created or would be created
(check mode). ``account_data`` will be the current account data,
or ``None`` if the account does not exist.
The account URI will be stored in ``client.account_uri``; if it is ``None``,
the account does not exist.
If specified, ``external_account_binding`` should be a dictionary
with keys ``kid``, ``alg`` and ``key``
(https://tools.ietf.org/html/rfc8555#section-7.3.4).
https://tools.ietf.org/html/rfc8555#section-7.3
'''
if self.client.account_uri is not None:
created = False
# Verify that the account key belongs to the URI.
# (If update_contact is True, this will be done below.)
account_data = self.get_account_data()
if account_data is None:
if remove_account_uri_if_not_exists and not allow_creation:
self.client.account_uri = None
else:
raise ModuleFailException("Account is deactivated or does not exist!")
else:
created, account_data = self._new_reg(
contact,
agreement=agreement,
terms_agreed=terms_agreed,
allow_creation=allow_creation and not self.client.module.check_mode,
external_account_binding=external_account_binding,
)
if self.client.module.check_mode and self.client.account_uri is None and allow_creation:
created = True
account_data = {
'contact': contact or []
}
return created, account_data
def update_account(self, account_data, contact=None):
'''
Update an account on the ACME server. Check mode is fully respected.
The current account data must be provided as ``account_data``.
Return a pair ``(updated, account_data)``, where ``updated`` is
``True`` in case something changed (contact info updated) or
would be changed (check mode), and ``account_data`` the updated
account data.
https://tools.ietf.org/html/rfc8555#section-7.3.2
'''
# Create request
update_request = {}
if contact is not None and account_data.get('contact', []) != contact:
update_request['contact'] = list(contact)
# No change?
if not update_request:
return False, dict(account_data)
# Apply change
if self.client.module.check_mode:
account_data = dict(account_data)
account_data.update(update_request)
else:
if self.client.version == 1:
update_request['resource'] = 'reg'
account_data, dummy = self.client.send_signed_request(self.client.account_uri, update_request)
return True, account_data

View File

@@ -0,0 +1,373 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import copy
import datetime
import json
import locale
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.urls import fetch_url
from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
OpenSSLCLIBackend,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
CryptographyBackend,
CRYPTOGRAPHY_VERSION,
HAS_CURRENT_CRYPTOGRAPHY,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
NetworkException,
ModuleFailException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
)
def _assert_fetch_url_success(module, response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True):
if info['status'] < 0:
raise NetworkException(msg="Failure downloading %s, %s" % (info['url'], info['msg']))
if (300 <= info['status'] < 400 and not allow_redirect) or \
(400 <= info['status'] < 500 and not allow_client_error) or \
(info['status'] >= 500 and not allow_server_error):
raise ACMEProtocolException(module, info=info, response=response)
def _is_failed(info, expected_status_codes=None):
if info['status'] < 200 or info['status'] >= 400:
return True
if expected_status_codes is not None and info['status'] not in expected_status_codes:
return True
return False
class ACMEDirectory(object):
'''
The ACME server directory. Gives access to the available resources,
and allows to obtain a Replay-Nonce. The acme_directory URL
needs to support unauthenticated GET requests; ACME endpoints
requiring authentication are not supported.
https://tools.ietf.org/html/rfc8555#section-7.1.1
'''
def __init__(self, module, account):
self.module = module
self.directory_root = module.params['acme_directory']
self.version = module.params['acme_version']
self.directory, dummy = account.get_request(self.directory_root, get_only=True)
# 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:
for key in ('newNonce', 'newAccount', 'newOrder'):
if key not in self.directory:
raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2")
# Make sure that 'meta' is always available
if 'meta' not in self.directory:
self.directory['meta'] = {}
def __getitem__(self, key):
return self.directory[key]
def get_nonce(self, resource=None):
url = self.directory_root if self.version == 1 else self.directory['newNonce']
if resource is not None:
url = resource
dummy, info = fetch_url(self.module, url, method='HEAD')
if info['status'] not in (200, 204):
raise NetworkException("Failed to get replay-nonce, got status {0}".format(info['status']))
return info['replay-nonce']
class ACMEClient(object):
'''
ACME client object. Handles the authorized communication with the
ACME server.
'''
def __init__(self, module, backend):
# Set to true to enable logging of all signed requests
self._debug = False
self.module = module
self.backend = backend
self.version = module.params['acme_version']
# account_key path and content are mutually exclusive
self.account_key_file = module.params['account_key_src']
self.account_key_content = module.params['account_key_content']
self.account_key_passphrase = module.params['account_key_passphrase']
# Grab account URI from module parameters.
# Make sure empty string is treated as None.
self.account_uri = module.params.get('account_uri') or None
self.account_key_data = None
self.account_jwk = None
self.account_jws_header = None
if self.account_key_file is not None or self.account_key_content is not None:
try:
self.account_key_data = self.parse_key(
key_file=self.account_key_file,
key_content=self.account_key_content,
passphrase=self.account_key_passphrase)
except KeyParsingError as e:
raise ModuleFailException("Error while parsing account key: {msg}".format(msg=e.msg))
self.account_jwk = self.account_key_data['jwk']
self.account_jws_header = {
"alg": self.account_key_data['alg'],
"jwk": self.account_jwk,
}
if self.account_uri:
# Make sure self.account_jws_header is updated
self.set_account_uri(self.account_uri)
self.directory = ACMEDirectory(module, self)
def set_account_uri(self, uri):
'''
Set account URI. For ACME v2, it needs to be used to sending signed
requests.
'''
self.account_uri = uri
if self.version != 1:
self.account_jws_header.pop('jwk')
self.account_jws_header['kid'] = self.account_uri
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.
In case of an error, raises KeyParsingError.
'''
if key_file is None and key_content is None:
raise AssertionError('One of key_file and key_content must be specified!')
return self.backend.parse_key(key_file, key_content, passphrase=passphrase)
def sign_request(self, protected, payload, key_data, encode_payload=True):
'''
Signs an ACME request.
'''
try:
if payload is None:
# POST-as-GET
payload64 = ''
else:
# POST
if encode_payload:
payload = self.module.jsonify(payload).encode('utf8')
payload64 = nopad_b64(to_bytes(payload))
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
except Exception as e:
raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e))
return self.backend.sign(payload64, protected64, key_data)
def _log(self, msg, data=None):
'''
Write arguments to acme.log when logging is enabled.
'''
if self._debug:
with open('acme.log', 'ab') as f:
f.write('[{0}] {1}\n'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%s'), msg).encode('utf-8'))
if data is not None:
f.write('{0}\n\n'.format(json.dumps(data, indent=2, sort_keys=True)).encode('utf-8'))
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True,
encode_payload=True, fail_on_error=True, error_msg=None, expected_status_codes=None):
'''
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
(if parse_json_result is False).
https://tools.ietf.org/html/rfc8555#section-6.2
If payload is None, a POST-as-GET is performed.
(https://tools.ietf.org/html/rfc8555#section-6.3)
'''
key_data = key_data or self.account_key_data
jws_header = jws_header or self.account_jws_header
failed_tries = 0
while True:
protected = copy.deepcopy(jws_header)
protected["nonce"] = self.directory.get_nonce()
if self.version != 1:
protected["url"] = url
self._log('URL', url)
self._log('protected', protected)
self._log('payload', payload)
data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload)
if self.version == 1:
data["header"] = jws_header.copy()
for k, v in protected.items():
dummy = data["header"].pop(k, None)
self._log('signed request', data)
data = self.module.jsonify(data)
headers = {
'Content-Type': 'application/jose+json',
}
resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST')
_assert_fetch_url_success(self.module, resp, info)
result = {}
try:
content = resp.read()
except AttributeError:
content = info.pop('body', None)
if content or not parse_json_result:
if (parse_json_result and info['content-type'].startswith('application/json')) or 400 <= info['status'] < 600:
try:
decoded_result = self.module.from_json(content.decode('utf8'))
self._log('parsed result', decoded_result)
# In case of badNonce error, try again (up to 5 times)
# (https://tools.ietf.org/html/rfc8555#section-6.7)
if all((
400 <= info['status'] < 600,
decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce',
failed_tries <= 5,
)):
failed_tries += 1
continue
if parse_json_result:
result = decoded_result
else:
result = content
except ValueError:
raise NetworkException("Failed to parse the ACME response: {0} {1}".format(url, content))
else:
result = content
if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes):
raise ACMEProtocolException(
self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None)
return result, info
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False,
fail_on_error=True, error_msg=None, expected_status_codes=None):
'''
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.
'''
if not get_only and self.version != 1:
# Try POST-as-GET
content, info = self.send_signed_request(uri, None, parse_json_result=False, fail_on_error=False)
if info['status'] == 405:
# Instead, do unauthenticated GET
get_only = True
else:
# Do unauthenticated GET
get_only = True
if get_only:
# Perform unauthenticated GET
resp, info = fetch_url(self.module, uri, method='GET', headers=headers)
_assert_fetch_url_success(self.module, resp, info)
try:
content = resp.read()
except AttributeError:
content = info.pop('body', None)
# Process result
parsed_json_result = False
if parse_json_result:
result = {}
if content:
if info['content-type'].startswith('application/json'):
try:
result = self.module.from_json(content.decode('utf8'))
parsed_json_result = True
except ValueError:
raise NetworkException("Failed to parse the ACME response: {0} {1}".format(uri, content))
else:
result = content
else:
result = content
if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes):
raise ACMEProtocolException(
self.module, msg=error_msg, info=info, content=content, content_json=result if parsed_json_result else None)
return result, info
def get_default_argspec():
'''
Provides default argument spec for the options documented in the acme doc fragment.
'''
return dict(
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'),
acme_directory=dict(type='str'),
acme_version=dict(type='int', choices=[1, 2]),
validate_certs=dict(type='bool', default=True),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']),
)
def create_backend(module, needs_acme_v2):
backend = module.params['select_crypto_backend']
# Backend autodetect
if backend == 'auto':
backend = 'cryptography' if HAS_CURRENT_CRYPTOGRAPHY else 'openssl'
# Create backend object
if backend == 'cryptography':
if not HAS_CURRENT_CRYPTOGRAPHY:
module.fail_json(msg=missing_required_lib('cryptography'))
module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION))
module_backend = CryptographyBackend(module)
elif backend == 'openssl':
module.debug('Using OpenSSL binary backend')
module_backend = OpenSSLCLIBackend(module)
else:
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend))
# Check common module parameters
if not module.params['validate_certs']:
module.warn(
'Disabling certificate validation for communications with ACME endpoint. '
'This should only be done for testing against a local ACME server for '
'development purposes, but *never* for production purposes.'
)
if module.params['acme_version'] is None:
module.params['acme_version'] = 1
module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on",
version='2.0.0', collection_name='community.crypto')
if module.params['acme_directory'] is None:
module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory'
module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on",
version='2.0.0', collection_name='community.crypto')
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))
# AnsibleModule() changes the locale, so change it back to C because we rely
# on datetime.datetime.strptime() when parsing certificate dates.
locale.setlocale(locale.LC_ALL, 'C')
return module_backend

View File

@@ -0,0 +1,375 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import base64
import binascii
import datetime
import os
import sys
from ansible.module_utils.common.text.converters import to_bytes, to_native
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import (
CryptoBackend,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
ChainMatcher,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
BackendException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.io import read_file
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
parse_name_field,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_name_to_oid,
)
try:
import cryptography
import cryptography.hazmat.backends
import cryptography.hazmat.primitives.hashes
import cryptography.hazmat.primitives.hmac
import cryptography.hazmat.primitives.asymmetric.ec
import cryptography.hazmat.primitives.asymmetric.padding
import cryptography.hazmat.primitives.asymmetric.rsa
import cryptography.hazmat.primitives.asymmetric.utils
import cryptography.hazmat.primitives.serialization
import cryptography.x509
import cryptography.x509.oid
from distutils.version import LooseVersion
CRYPTOGRAPHY_VERSION = cryptography.__version__
HAS_CURRENT_CRYPTOGRAPHY = (LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion('1.5'))
if HAS_CURRENT_CRYPTOGRAPHY:
_cryptography_backend = cryptography.hazmat.backends.default_backend()
except Exception as dummy:
HAS_CURRENT_CRYPTOGRAPHY = False
CRYPTOGRAPHY_VERSION = None
if sys.version_info[0] >= 3:
# Python 3 (and newer)
def _count_bytes(n):
return (n.bit_length() + 7) // 8 if n > 0 else 0
def _convert_int_to_bytes(count, no):
return no.to_bytes(count, byteorder='big')
def _pad_hex(n, digits):
res = hex(n)[2:]
if len(res) < digits:
res = '0' * (digits - len(res)) + res
return res
else:
# Python 2
def _count_bytes(n):
if n <= 0:
return 0
h = '%x' % n
return (len(h) + 1) // 2
def _convert_int_to_bytes(count, n):
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 _pad_hex(n, digits):
h = '%x' % n
if len(h) < digits:
h = '0' * (digits - len(h)) + h
return h
class CryptographyChainMatcher(ChainMatcher):
@staticmethod
def _parse_key_identifier(key_identifier, name, criterium_idx, module):
if key_identifier:
try:
return binascii.unhexlify(key_identifier.replace(':', ''))
except Exception:
if criterium_idx is None:
module.warn('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
def __init__(self, criterium, module):
self.criterium = criterium
self.test_certificates = criterium.test_certificates
self.subject = []
self.issuer = []
if criterium.subject:
self.subject = [
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.subject)
]
if criterium.issuer:
self.issuer = [
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.issuer)
]
self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier(
criterium.subject_key_identifier, 'subject_key_identifier', criterium.index, module)
self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier(
criterium.authority_key_identifier, 'authority_key_identifier', criterium.index, module)
def _match_subject(self, x509_subject, match_subject):
for oid, value in match_subject:
found = False
for attribute in x509_subject:
if attribute.oid == oid and value == to_native(attribute.value):
found = True
break
if not found:
return False
return True
def match(self, certificate):
'''
Check whether an alternate chain matches the specified criterium.
'''
chain = certificate.chain
if self.test_certificates == 'last':
chain = chain[-1:]
elif self.test_certificates == 'first':
chain = chain[:1]
for cert in chain:
try:
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend())
matches = True
if not self._match_subject(x509.subject, self.subject):
matches = False
if not self._match_subject(x509.issuer, self.issuer):
matches = False
if self.subject_key_identifier:
try:
ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier)
if self.subject_key_identifier != ext.value.digest:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if self.authority_key_identifier:
try:
ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier)
if self.authority_key_identifier != ext.value.key_identifier:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if matches:
return True
except Exception as e:
self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e))
return False
class CryptographyBackend(CryptoBackend):
def __init__(self, module):
super(CryptographyBackend, self).__init__(module)
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.
'''
# If key_content isn't given, read key_file
if key_content is None:
key_content = read_file(key_file)
else:
key_content = to_bytes(key_content)
# Parse key
try:
key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
key_content,
password=to_bytes(passphrase) if passphrase is not None else None,
backend=_cryptography_backend)
except Exception as e:
raise KeyParsingError('error while loading key: {0}'.format(e))
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
pk = key.public_key().public_numbers()
return {
'key_obj': key,
'type': 'rsa',
'alg': 'RS256',
'jwk': {
"kty": "RSA",
"e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)),
"n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)),
},
'hash': 'sha256',
}
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
pk = key.public_key().public_numbers()
if pk.curve.name == 'secp256r1':
bits = 256
alg = 'ES256'
hashalg = 'sha256'
point_size = 32
curve = 'P-256'
elif pk.curve.name == 'secp384r1':
bits = 384
alg = 'ES384'
hashalg = 'sha384'
point_size = 48
curve = 'P-384'
elif pk.curve.name == 'secp521r1':
# Not yet supported on Let's Encrypt side, see
# https://github.com/letsencrypt/boulder/issues/2217
bits = 521
alg = 'ES512'
hashalg = 'sha512'
point_size = 66
curve = 'P-521'
else:
raise KeyParsingError('unknown elliptic curve: {0}'.format(pk.curve.name))
num_bytes = (bits + 7) // 8
return {
'key_obj': key,
'type': 'ec',
'alg': alg,
'jwk': {
"kty": "EC",
"crv": curve,
"x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)),
"y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)),
},
'hash': hashalg,
'point_size': point_size,
}
else:
raise KeyParsingError('unknown key type "{0}"'.format(type(key)))
def sign(self, payload64, protected64, key_data):
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
if 'mac_obj' in key_data:
mac = key_data['mac_obj']()
mac.update(sign_payload)
signature = mac.finalize()
elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15()
hashalg = cryptography.hazmat.primitives.hashes.SHA256
signature = key_data['key_obj'].sign(sign_payload, padding, hashalg())
elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
if key_data['hash'] == 'sha256':
hashalg = cryptography.hazmat.primitives.hashes.SHA256
elif key_data['hash'] == 'sha384':
hashalg = cryptography.hazmat.primitives.hashes.SHA384
elif key_data['hash'] == 'sha512':
hashalg = cryptography.hazmat.primitives.hashes.SHA512
ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg())
r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(key_data['key_obj'].sign(sign_payload, ecdsa))
rr = _pad_hex(r, 2 * key_data['point_size'])
ss = _pad_hex(s, 2 * key_data['point_size'])
signature = binascii.unhexlify(rr) + binascii.unhexlify(ss)
return {
"protected": protected64,
"payload": payload64,
"signature": nopad_b64(signature),
}
def create_mac_key(self, alg, key):
'''Create a MAC key.'''
if alg == 'HS256':
hashalg = cryptography.hazmat.primitives.hashes.SHA256
hashbytes = 32
elif alg == 'HS384':
hashalg = cryptography.hazmat.primitives.hashes.SHA384
hashbytes = 48
elif alg == 'HS512':
hashalg = cryptography.hazmat.primitives.hashes.SHA512
hashbytes = 64
else:
raise BackendException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg))
key_bytes = base64.urlsafe_b64decode(key)
if len(key_bytes) < hashbytes:
raise BackendException(
'{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes))
return {
'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC(
key_bytes,
hashalg(),
_cryptography_backend),
'type': 'hmac',
'alg': alg,
'jwk': {
'kty': 'oct',
'k': key,
},
}
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'.
'''
identifiers = set([])
if csr_content is None:
csr_content = read_file(csr_filename)
else:
csr_content = to_bytes(csr_content)
csr = cryptography.x509.load_pem_x509_csr(csr_content, _cryptography_backend)
for sub in csr.subject:
if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME:
identifiers.add(('dns', sub.value))
for extension in csr.extensions:
if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
for name in extension.value:
if isinstance(name, cryptography.x509.DNSName):
identifiers.add(('dns', name.value))
elif isinstance(name, cryptography.x509.IPAddress):
identifiers.add(('ip', name.value.compressed))
else:
raise BackendException('Found unsupported SAN identifier {0}'.format(name))
return identifiers
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.
'''
if cert_filename is not None:
cert_content = None
if os.path.exists(cert_filename):
cert_content = read_file(cert_filename)
else:
cert_content = to_bytes(cert_content)
if cert_content is None:
return -1
try:
cert = cryptography.x509.load_pem_x509_certificate(cert_content, _cryptography_backend)
except Exception as e:
if cert_filename is None:
raise BackendException('Cannot parse certificate: {0}'.format(e))
raise BackendException('Cannot parse certificate {0}: {1}'.format(cert_filename, e))
if now is None:
now = datetime.datetime.now()
return (cert.not_valid_after - now).days
def create_chain_matcher(self, criterium):
'''
Given a Criterium object, creates a ChainMatcher object.
'''
return CryptographyChainMatcher(criterium, self.module)

View File

@@ -0,0 +1,298 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import base64
import binascii
import datetime
import os
import re
import tempfile
import traceback
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import (
CryptoBackend,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
BackendException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
class OpenSSLCLIBackend(CryptoBackend):
def __init__(self, module, openssl_binary=None):
super(OpenSSLCLIBackend, self).__init__(module)
if openssl_binary is None:
openssl_binary = module.get_bin_path('openssl', True)
self.openssl_binary = openssl_binary
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.
'''
if passphrase is not None:
raise KeyParsingError('openssl backend does not support key passphrases')
# If key_file isn't given, but key_content, write that to a temporary file
if key_file is None:
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(key_content.encode('utf-8'))
key_file = tmpsrc
except Exception as err:
try:
f.close()
except Exception as dummy:
pass
raise KeyParsingError("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
f.close()
# Parse key
account_key_type = None
with open(key_file, "rt") as f:
for line in f:
m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line)
if m is not None:
account_key_type = m.group(1).lower()
break
if account_key_type is None:
# This happens for example if openssl_privatekey created this key
# (as opposed to the OpenSSL binary). For now, we assume this is
# an RSA key.
# FIXME: add some kind of auto-detection
account_key_type = "rsa"
if account_key_type not in ("rsa", "ec"):
raise KeyParsingError('unknown key type "%s"' % account_key_type)
openssl_keydump_cmd = [self.openssl_binary, account_key_type, "-in", key_file, "-noout", "-text"]
dummy, out, dummy = self.module.run_command(
openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
if account_key_type == 'rsa':
pub_hex, pub_exp = re.search(
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups()
pub_exp = "{0:x}".format(int(pub_exp))
if len(pub_exp) % 2:
pub_exp = "0{0}".format(pub_exp)
return {
'key_file': key_file,
'type': 'rsa',
'alg': 'RS256',
'jwk': {
"kty": "RSA",
"e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
"n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
},
'hash': 'sha256',
}
elif account_key_type == 'ec':
pub_data = re.search(
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
if pub_data is None:
raise KeyParsingError('cannot parse elliptic curve key')
pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8"))
asn1_oid_curve = pub_data.group(2).lower()
nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None
if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256':
bits = 256
alg = 'ES256'
hashalg = 'sha256'
point_size = 32
curve = 'P-256'
elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384':
bits = 384
alg = 'ES384'
hashalg = 'sha384'
point_size = 48
curve = 'P-384'
elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521':
# Not yet supported on Let's Encrypt side, see
# https://github.com/letsencrypt/boulder/issues/2217
bits = 521
alg = 'ES512'
hashalg = 'sha512'
point_size = 66
curve = 'P-521'
else:
raise KeyParsingError('unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve))
num_bytes = (bits + 7) // 8
if len(pub_hex) != 2 * num_bytes:
raise KeyParsingError('bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve))
return {
'key_file': key_file,
'type': 'ec',
'alg': alg,
'jwk': {
"kty": "EC",
"crv": curve,
"x": nopad_b64(pub_hex[:num_bytes]),
"y": nopad_b64(pub_hex[num_bytes:]),
},
'hash': hashalg,
'point_size': point_size,
}
def sign(self, payload64, protected64, key_data):
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
if key_data['type'] == 'hmac':
hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k'])))
cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"]
else:
cmd_postfix = ["-sign", key_data['key_file']]
openssl_sign_cmd = [self.openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix
dummy, out, dummy = self.module.run_command(
openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
if key_data['type'] == 'ec':
dummy, der_out, dummy = self.module.run_command(
[self.openssl_binary, "asn1parse", "-inform", "DER"],
data=out, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
expected_len = 2 * key_data['point_size']
sig = re.findall(
r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len,
to_text(der_out, errors='surrogate_or_strict'))
if len(sig) != 2:
raise BackendException(
"failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
to_text(der_out, errors='surrogate_or_strict')))
sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
return {
"protected": protected64,
"payload": payload64,
"signature": nopad_b64(to_bytes(out)),
}
def create_mac_key(self, alg, key):
'''Create a MAC key.'''
if alg == 'HS256':
hashalg = 'sha256'
hashbytes = 32
elif alg == 'HS384':
hashalg = 'sha384'
hashbytes = 48
elif alg == 'HS512':
hashalg = 'sha512'
hashbytes = 64
else:
raise BackendException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg))
key_bytes = base64.urlsafe_b64decode(key)
if len(key_bytes) < hashbytes:
raise BackendException(
'{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes))
return {
'type': 'hmac',
'alg': alg,
'jwk': {
'kty': 'oct',
'k': key,
},
'hash': hashalg,
}
@staticmethod
def _normalize_ip(ip):
try:
return to_native(compat_ipaddress.ip_address(to_text(ip)).compressed)
except ValueError:
# We don't want to error out on something IPAddress() can't parse
return ip
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'.
'''
filename = csr_filename
data = None
if csr_content is not None:
filename = '/dev/stdin'
data = csr_content.encode('utf-8')
openssl_csr_cmd = [self.openssl_binary, "req", "-in", filename, "-noout", "-text"]
dummy, out, dummy = self.module.run_command(
openssl_csr_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
identifiers = set([])
common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict'))
if common_name is not None:
identifiers.add(('dns', common_name.group(1)))
subject_alt_names = re.search(
r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n",
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
if subject_alt_names is not None:
for san in subject_alt_names.group(1).split(", "):
if san.lower().startswith("dns:"):
identifiers.add(('dns', san[4:]))
elif san.lower().startswith("ip:"):
identifiers.add(('ip', self._normalize_ip(san[3:])))
elif san.lower().startswith("ip address:"):
identifiers.add(('ip', self._normalize_ip(san[11:])))
else:
raise BackendException('Found unsupported SAN identifier "{0}"'.format(san))
return identifiers
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.
'''
filename = cert_filename
data = None
if cert_content is not None:
filename = '/dev/stdin'
data = cert_content.encode('utf-8')
cert_filename_suffix = ''
elif cert_filename is not None:
if not os.path.exists(cert_filename):
return -1
cert_filename_suffix = ' in {0}'.format(cert_filename)
else:
return -1
openssl_cert_cmd = [self.openssl_binary, "x509", "-in", filename, "-noout", "-text"]
dummy, out, dummy = self.module.run_command(
openssl_cert_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
try:
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", to_text(out, errors='surrogate_or_strict')).group(1)
not_after = datetime.datetime.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')
except AttributeError:
raise BackendException("No 'Not after' date found{0}".format(cert_filename_suffix))
except ValueError:
raise BackendException("Failed to parse 'Not after' date{0}".format(cert_filename_suffix))
if now is None:
now = datetime.datetime.now()
return (not_after - now).days
def create_chain_matcher(self, criterium):
'''
Given a Criterium object, creates a ChainMatcher object.
'''
raise BackendException('Alternate chain matching can only be used with the "cryptography" backend.')

View File

@@ -0,0 +1,58 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
from ansible.module_utils import six
@six.add_metaclass(abc.ABCMeta)
class CryptoBackend(object):
def __init__(self, module):
self.module = module
@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.'''
@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.
'''

View File

@@ -0,0 +1,128 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
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

@@ -0,0 +1,299 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
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.compat import ipaddress as compat_ipaddress
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
format_error_problem,
ACMEProtocolException,
ModuleFailException,
)
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 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 = (resource + identifier[1:]) if identifier.startswith('*.') else '{0}.{1}'.format(resource, 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 = compat_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
self.challenges = [Challenge.from_json(client, challenge) for challenge in data['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 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 self.status != 'valid':
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

View File

@@ -0,0 +1,131 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible.module_utils.six import binary_type
from ansible.module_utils.common.text.converters import to_text
def format_error_problem(problem, subproblem_prefix=''):
if 'title' in problem:
msg = 'Error "{title}" ({type})'.format(
type=problem['type'],
title=problem['title'],
)
else:
msg = 'Error {type}'.format(type=problem['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:
content = response.read()
except AttributeError:
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 as e:
pass
extras = extras or dict()
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
if code is not None and code >= 400 and content_json is not None and 'type' in content_json:
if 'status' in content_json and content_json['status'] != code:
code = 'status {problem_code} (HTTP status: {http_code})'.format(http_code=code, problem_code=content_json['status'])
else:
code = 'status {problem_code}'.format(problem_code=code)
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 = 'HTTP status {code}'.format(code=code)
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)
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 = []
for k, v in extras.items():
setattr(self, k, v)
class BackendException(ModuleFailException):
pass
class NetworkException(ModuleFailException):
pass
class KeyParsingError(ModuleFailException):
pass

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2013, Romeo Theriault <romeot () hawaii.edu>
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
import shutil
import tempfile
import traceback
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
def read_file(fn, mode='b'):
try:
with open(fn, 'r' + mode) as f:
return f.read()
except Exception as e:
raise ModuleFailException('Error while reading file "{0}": {1}'.format(fn, e))
# 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):
'''
Write content to destination file dest, only if the content
has changed.
'''
changed = False
# create a tempfile
fd, tmpsrc = tempfile.mkstemp(text=False)
f = os.fdopen(fd, 'wb')
try:
f.write(content)
except Exception as err:
try:
f.close()
except Exception as dummy:
pass
os.remove(tmpsrc)
raise ModuleFailException("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
f.close()
checksum_src = None
checksum_dest = None
# raise an error if there is no tmpsrc file
if not os.path.exists(tmpsrc):
try:
os.remove(tmpsrc)
except Exception as dummy:
pass
raise ModuleFailException("Source %s does not exist" % (tmpsrc))
if not os.access(tmpsrc, os.R_OK):
os.remove(tmpsrc)
raise ModuleFailException("Source %s not readable" % (tmpsrc))
checksum_src = module.sha1(tmpsrc)
# check if there is no dest file
if os.path.exists(dest):
# raise an error if copy has no permission on dest
if not os.access(dest, os.W_OK):
os.remove(tmpsrc)
raise ModuleFailException("Destination %s not writable" % (dest))
if not os.access(dest, os.R_OK):
os.remove(tmpsrc)
raise ModuleFailException("Destination %s not readable" % (dest))
checksum_dest = module.sha1(dest)
else:
dirname = os.path.dirname(dest) or '.'
if not os.access(dirname, os.W_OK):
os.remove(tmpsrc)
raise ModuleFailException("Destination dir %s not writable" % (dirname))
if checksum_src != checksum_dest:
try:
shutil.copyfile(tmpsrc, dest)
changed = True
except Exception as err:
os.remove(tmpsrc)
raise ModuleFailException("failed to copy %s to %s: %s" % (tmpsrc, dest, to_native(err)), exception=traceback.format_exc())
os.remove(tmpsrc)
return changed

View File

@@ -0,0 +1,129 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import time
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
Authorization,
)
class Order(object):
def _setup(self, client, data):
self.data = data
self.status = data['status']
self.identifiers = []
for identifier in data['identifiers']:
self.identifiers.append((identifier['type'], identifier['value']))
self.finalize_uri = data.get('finalize')
self.certificate_uri = data.get('certificate')
self.authorization_uris = data['authorizations']
self.authorizations = {}
def __init__(self, url):
self.url = url
self.data = None
self.status = None
self.identifiers = []
self.finalize_uri = None
self.certificate_uri = None
self.authorization_uris = []
self.authorizations = {}
@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, identifiers):
'''
Start a new certificate order (ACME v2 protocol).
https://tools.ietf.org/html/rfc8555#section-7.4
'''
acme_identifiers = []
for identifier_type, identifier in identifiers:
acme_identifiers.append({
'type': identifier_type,
'value': identifier,
})
new_order = {
"identifiers": acme_identifiers
}
result, info = client.send_signed_request(
client.directory['newOrder'], new_order, error_msg='Failed to start new order', expected_status_codes=[201])
return cls.from_json(client, result, info['location'])
def refresh(self, client):
result, dummy = client.get_request(self.url)
changed = self.data != result
self._setup(client, result)
return changed
def load_authorizations(self, client):
for auth_uri in self.authorization_uris:
authz = Authorization.from_url(client, auth_uri)
self.authorizations[authz.combined_identifier] = authz
def wait_for_finalization(self, client):
while True:
self.refresh(client)
if self.status in ['valid', 'invalid', 'pending', 'ready']:
break
time.sleep(2)
if self.status != 'valid':
raise ACMEProtocolException(
client.module,
'Failed to wait for order to complete; got status "{status}"'.format(status=self.status),
content_json=self.data)
def finalize(self, client, csr_der, wait=True):
'''
Create a new certificate based on the csr.
Return the certificate object as dict
https://tools.ietf.org/html/rfc8555#section-7.4
'''
new_cert = {
"csr": nopad_b64(csr_der),
}
result, info = client.send_signed_request(
self.finalize_uri, new_cert, error_msg='Failed to finalizing order', expected_status_codes=[200])
# It is not clear from the RFC whether the finalize call returns the order object or not.
# Instead of using the result, we call self.refresh(client) below.
if wait:
self.wait_for_finalization(client)
else:
self.refresh(client)
if self.status not in ['procesing', 'valid', 'invalid']:
raise ACMEProtocolException(
client.module,
'Failed to finalize order; got status "{status}"'.format(status=self.status),
info=info,
content_json=result)

View File

@@ -0,0 +1,71 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import base64
import re
import textwrap
import traceback
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.six.moves.urllib.parse import unquote
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
def nopad_b64(data):
return base64.urlsafe_b64encode(data).decode('utf8').replace("=", "")
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):
'''
Load PEM file, or use PEM file's content, and convert to DER.
If PEM contains multiple entities, the first entity will be used.
'''
certificate_lines = []
if pem_content is not None:
lines = pem_content.splitlines()
elif pem_filename is not None:
try:
with open(pem_filename, "rt") as f:
lines = list(f)
except Exception as err:
raise ModuleFailException("cannot load PEM file {0}: {1}".format(pem_filename, to_native(err)), exception=traceback.format_exc())
else:
raise ModuleFailException('One of pem_filename and pem_content must be provided')
header_line_count = 0
for line in lines:
if line.startswith('-----'):
header_line_count += 1
if header_line_count == 2:
# If certificate file contains other certs appended
# (like intermediate certificates), ignore these.
break
continue
certificate_lines.append(line.strip())
return base64.b64decode(''.join(certificate_lines))
def process_links(info, callback):
'''
Process link header, calls callback for every link header with the URL and relation as options.
'''
if 'link' in info:
link = info['link']
for url, relation in re.findall(r'<([^>]+)>;\s*rel="(\w+)"', link):
callback(unquote(url), relation)

View File

@@ -8,7 +8,7 @@ __metaclass__ = type
import re import re
from ansible.module_utils._text import to_bytes from ansible.module_utils.common.text.converters import to_bytes
""" """

View File

@@ -23,7 +23,7 @@ import base64
import binascii import binascii
import re import re
from ansible.module_utils._text import to_text from ansible.module_utils.common.text.converters import to_text, to_bytes
from ._asn1 import serialize_asn1_string_as_der from ._asn1 import serialize_asn1_string_as_der
try: try:
@@ -35,6 +35,15 @@ except ImportError:
# Error handled in the calling module. # Error handled in the calling module.
pass pass
try:
# This is a separate try/except since this is only present in cryptography 2.5 or newer
from cryptography.hazmat.primitives.serialization.pkcs12 import (
load_key_and_certificates as _load_key_and_certificates,
)
except ImportError:
# Error handled in the calling module.
_load_key_and_certificates = None
from .basic import ( from .basic import (
CRYPTOGRAPHY_HAS_ED25519, CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448, CRYPTOGRAPHY_HAS_ED448,
@@ -428,3 +437,22 @@ def cryptography_serial_number_of_cert(cert):
except AttributeError: except AttributeError:
# The property was called "serial" before cryptography 1.4 # The property was called "serial" before cryptography 1.4
return cert.serial return cert.serial
def parse_pkcs12(pkcs12_bytes, passphrase=None):
'''Returns a tuple (private_key, certificate, additional_certificates, friendly_name).
'''
if _load_key_and_certificates is None:
raise ValueError('load_key_and_certificates() not present in the current cryptography version')
private_key, certificate, additional_certificates = _load_key_and_certificates(
pkcs12_bytes, to_bytes(passphrase) if passphrase is not None else None)
friendly_name = None
if certificate:
# See https://github.com/pyca/cryptography/issues/5760#issuecomment-842687238
maybe_name = certificate._backend._lib.X509_alias_get0(
certificate._x509, certificate._backend._ffi.NULL)
if maybe_name != certificate._backend._ffi.NULL:
friendly_name = certificate._backend._ffi.string(maybe_name)
return private_key, certificate, additional_certificates, friendly_name

View File

@@ -33,6 +33,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp
cryptography_compare_public_keys, cryptography_compare_public_keys,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import (
get_certificate_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6' MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
MINIMAL_PYOPENSSL_VERSION = '0.15' MINIMAL_PYOPENSSL_VERSION = '0.15'
@@ -95,6 +99,19 @@ class CertificateBackend(object):
self.check_csr_subject = True self.check_csr_subject = True
self.check_csr_extensions = 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 as exc:
return dict(can_parse_certificate=False)
@abc.abstractmethod @abc.abstractmethod
def generate_certificate(self): def generate_certificate(self):
"""(Re-)Generate certificate.""" """(Re-)Generate certificate."""
@@ -108,6 +125,7 @@ class CertificateBackend(object):
def set_existing(self, certificate_bytes): def set_existing(self, certificate_bytes):
"""Set existing certificate bytes. None indicates that the key does not exist.""" """Set existing certificate bytes. None indicates that the key does not exist."""
self.existing_certificate_bytes = certificate_bytes self.existing_certificate_bytes = certificate_bytes
self.diff_after = self.diff_before = self._get_info(self.existing_certificate_bytes)
def has_existing(self): def has_existing(self):
"""Query whether an existing certificate is/has been there.""" """Query whether an existing certificate is/has been there."""
@@ -284,13 +302,19 @@ class CertificateBackend(object):
'privatekey': self.privatekey_path, 'privatekey': self.privatekey_path,
'csr': self.csr_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: if include_certificate:
# Get hold of certificate bytes
certificate_bytes = self.existing_certificate_bytes
if self.cert is not None:
certificate_bytes = self.get_certificate_data()
# Store result # Store result
result['certificate'] = certificate_bytes.decode('utf-8') if certificate_bytes else None 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 return result

View File

@@ -12,7 +12,7 @@ import os
import tempfile import tempfile
import traceback import traceback
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
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 (
CertificateError, CertificateError,

View File

@@ -11,7 +11,7 @@ __metaclass__ = type
import abc import abc
import datetime import datetime
from ansible.module_utils._text import to_native, to_bytes, to_text from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text
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,
@@ -177,25 +177,25 @@ class AssertOnlyCertificateBackend(CertificateBackend):
if self.privatekey_path is not None or self.privatekey_content is not None: if self.privatekey_path is not None or self.privatekey_content is not None:
if not self._validate_privatekey(): if not self._validate_privatekey():
messages.append( messages.append(
'Certificate %s and private key %s do not match' % 'Certificate and private key %s do not match' %
(self.path, self.privatekey_path or '(provided in module options)') (self.privatekey_path or '(provided in module options)')
) )
if self.csr_path is not None or self.csr_content is not None: if self.csr_path is not None or self.csr_content is not None:
if not self._validate_csr_signature(): if not self._validate_csr_signature():
messages.append( messages.append(
'Certificate %s and CSR %s do not match: private key mismatch' % 'Certificate and CSR %s do not match: private key mismatch' %
(self.path, self.csr_path or '(provided in module options)') (self.csr_path or '(provided in module options)')
) )
if not self._validate_csr_subject(): if not self._validate_csr_subject():
messages.append( messages.append(
'Certificate %s and CSR %s do not match: subject mismatch' % 'Certificate and CSR %s do not match: subject mismatch' %
(self.path, self.csr_path or '(provided in module options)') (self.csr_path or '(provided in module options)')
) )
if not self._validate_csr_extensions(): if not self._validate_csr_extensions():
messages.append( messages.append(
'Certificate %s and CSR %s do not match: extensions mismatch' % 'Certificate and CSR %s do not match: extensions mismatch' %
(self.path, self.csr_path or '(provided in module options)') (self.csr_path or '(provided in module options)')
) )
if self.signature_algorithms is not None: if self.signature_algorithms is not None:

View File

@@ -12,7 +12,7 @@ import datetime
import time import time
import os import os
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.ecs.api import ECSClient, RestOperationException, SessionConfigurationException from ansible_collections.community.crypto.plugins.module_utils.ecs.api import ECSClient, RestOperationException, SessionConfigurationException

View File

@@ -0,0 +1,565 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import binascii
import datetime
import re
import traceback
from distutils.version import LooseVersion
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, to_text, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
load_certificate,
get_fingerprint_of_bytes,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_decode_name,
cryptography_get_extensions_from_cert,
cryptography_oid_to_name,
cryptography_serial_number_of_cert,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import (
pyopenssl_get_extensions_from_cert,
pyopenssl_normalize_name,
pyopenssl_normalize_name_attribute,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
get_publickey_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# OpenSSL 1.1.0 or newer
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
else:
# OpenSSL 1.0.x or older
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
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 CertificateInfoRetrieval(object):
def __init__(self, module, backend, content):
# content must be a bytes string
self.module = module
self.backend = backend
self.content = content
@abc.abstractmethod
def _get_der_bytes(self):
pass
@abc.abstractmethod
def _get_signature_algorithm(self):
pass
@abc.abstractmethod
def _get_subject_ordered(self):
pass
@abc.abstractmethod
def _get_issuer_ordered(self):
pass
@abc.abstractmethod
def _get_version(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_not_before(self):
pass
@abc.abstractmethod
def get_not_after(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_serial_number(self):
pass
@abc.abstractmethod
def _get_all_extensions(self):
pass
@abc.abstractmethod
def _get_ocsp_uri(self):
pass
def get_info(self, prefer_one_fingerprint=False):
result = dict()
self.cert = load_certificate(None, content=self.content, backend=self.backend)
result['signature_algorithm'] = self._get_signature_algorithm()
subject = self._get_subject_ordered()
issuer = self._get_issuer_ordered()
result['subject'] = dict()
for k, v in subject:
result['subject'][k] = v
result['subject_ordered'] = subject
result['issuer'] = dict()
for k, v in issuer:
result['issuer'][k] = v
result['issuer_ordered'] = issuer
result['version'] = self._get_version()
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()
not_before = self.get_not_before()
not_after = self.get_not_after()
result['not_before'] = not_before.strftime(TIMESTAMP_FORMAT)
result['not_after'] = not_after.strftime(TIMESTAMP_FORMAT)
result['expired'] = not_after < datetime.datetime.utcnow()
result['public_key'] = 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'],
})
result['fingerprints'] = get_fingerprint_of_bytes(
self._get_der_bytes(), prefer_one=prefer_one_fingerprint)
if self.backend != 'pyopenssl':
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['serial_number'] = self._get_serial_number()
result['extensions_by_oid'] = self._get_all_extensions()
result['ocsp_uri'] = self._get_ocsp_uri()
return result
class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval):
"""Validate the supplied cert, using the cryptography backend"""
def __init__(self, module, content):
super(CertificateInfoRetrievalCryptography, self).__init__(module, 'cryptography', content)
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) for san in san_ext.value]
return result, san_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def get_not_before(self):
return self.cert.not_valid_before
def get_not_after(self):
return self.cert.not_valid_after
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) 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 as dummy:
pass
return None
class CertificateInfoRetrievalPyOpenSSL(CertificateInfoRetrieval):
"""validate the supplied certificate."""
def __init__(self, module, content):
super(CertificateInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content)
def _get_der_bytes(self):
return crypto.dump_certificate(crypto.FILETYPE_ASN1, self.cert)
def _get_signature_algorithm(self):
return to_text(self.cert.get_signature_algorithm())
def __get_name(self, name):
result = []
for sub in name.get_components():
result.append([pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
return result
def _get_subject_ordered(self):
return self.__get_name(self.cert.get_subject())
def _get_issuer_ordered(self):
return self.__get_name(self.cert.get_issuer())
def _get_version(self):
# Version numbers in certs are off by one:
# v1: 0, v2: 1, v3: 2 ...
return self.cert.get_version() + 1
def _get_extension(self, short_name):
for extension_idx in range(0, self.cert.get_extension_count()):
extension = self.cert.get_extension(extension_idx)
if extension.get_short_name() == short_name:
result = [
pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
]
return sorted(result), bool(extension.get_critical())
return None, False
def _get_key_usage(self):
return self._get_extension(b'keyUsage')
def _get_extended_key_usage(self):
return self._get_extension(b'extendedKeyUsage')
def _get_basic_constraints(self):
return self._get_extension(b'basicConstraints')
def _get_ocsp_must_staple(self):
extensions = [self.cert.get_extension(i) for i in range(0, self.cert.get_extension_count())]
oms_ext = [
ext for ext in extensions
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
]
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
# Older versions of libssl don't know about OCSP Must Staple
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
if oms_ext:
return True, bool(oms_ext[0].get_critical())
else:
return None, False
def _get_subject_alt_name(self):
for extension_idx in range(0, self.cert.get_extension_count()):
extension = self.cert.get_extension(extension_idx)
if extension.get_short_name() == b'subjectAltName':
result = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
to_text(extension, errors='surrogate_or_strict').split(', ')]
return result, bool(extension.get_critical())
return None, False
def get_not_before(self):
time_string = to_native(self.cert.get_notBefore())
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
def get_not_after(self):
time_string = to_native(self.cert.get_notAfter())
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
def _get_public_key_pem(self):
try:
return crypto.dump_publickey(
crypto.FILETYPE_PEM,
self.cert.get_pubkey(),
)
except AttributeError:
try:
# pyOpenSSL < 16.0:
bio = crypto._new_mem_buf()
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.cert.get_pubkey()._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def _get_public_key_object(self):
return self.cert.get_pubkey()
def _get_subject_key_identifier(self):
# Won't be implemented
return None
def _get_authority_key_identifier(self):
# Won't be implemented
return None, None, None
def _get_serial_number(self):
return self.cert.get_serial_number()
def _get_all_extensions(self):
return pyopenssl_get_extensions_from_cert(self.cert)
def _get_ocsp_uri(self):
for i in range(self.cert.get_extension_count()):
ext = self.cert.get_extension(i)
if ext.get_short_name() == b'authorityInfoAccess':
v = str(ext)
m = re.search('^OCSP - URI:(.*)$', v, flags=re.MULTILINE)
if m:
return m.group(1)
return None
def get_certificate_info(module, backend, content, prefer_one_fingerprint=False):
if backend == 'cryptography':
info = CertificateInfoRetrievalCryptography(module, content)
elif backend == 'pyopenssl':
info = CertificateInfoRetrievalPyOpenSSL(module, content)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(module, backend, content):
if backend == 'auto':
# Detection what is possible
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# First try cryptography, then pyOpenSSL
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
try:
getattr(crypto.X509Req, 'get_extensions')
except AttributeError:
module.fail_json(msg='You need to have PyOpenSSL>=0.15 to generate CSRs')
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
return backend, CertificateInfoRetrievalPyOpenSSL(module, content)
elif 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

@@ -13,7 +13,7 @@ import os
from distutils.version import LooseVersion from distutils.version import LooseVersion
from random import randrange from random import randrange
from ansible.module_utils._text import to_bytes 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 (
OpenSSLBadPassphraseError, OpenSSLBadPassphraseError,

View File

@@ -12,7 +12,7 @@ import os
from random import randrange from random import randrange
from ansible.module_utils._text import to_bytes from ansible.module_utils.common.text.converters import to_bytes
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
get_relative_time_option, get_relative_time_option,

View File

@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2020, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import traceback
from distutils.version import LooseVersion
from ansible.module_utils.basic import missing_required_lib
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.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.pem import (
identify_pem_format,
)
# 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
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))
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

@@ -16,7 +16,7 @@ from distutils.version import LooseVersion
from ansible.module_utils import six 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._text import to_bytes, to_text from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError, OpenSSLObjectError,
@@ -48,6 +48,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_
pyopenssl_parse_name_constraints, pyopenssl_parse_name_constraints,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import (
get_csr_info,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec
@@ -177,6 +181,20 @@ class CertificateSigningRequestBackend(object):
self.existing_csr = None self.existing_csr = None
self.existing_csr_bytes = None self.existing_csr_bytes = None
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_csr_info(
self.module, self.backend, data, validate_signature=False, prefer_one_fingerprint=True)
result['can_parse_csr'] = True
return result
except Exception as exc:
return dict(can_parse_csr=False)
@abc.abstractmethod @abc.abstractmethod
def generate_csr(self): def generate_csr(self):
"""(Re-)Generate CSR.""" """(Re-)Generate CSR."""
@@ -190,6 +208,7 @@ class CertificateSigningRequestBackend(object):
def set_existing(self, csr_bytes): def set_existing(self, csr_bytes):
"""Set existing CSR bytes. None indicates that the CSR does not exist.""" """Set existing CSR bytes. None indicates that the CSR does not exist."""
self.existing_csr_bytes = csr_bytes self.existing_csr_bytes = csr_bytes
self.diff_after = self.diff_before = self._get_info(self.existing_csr_bytes)
def has_existing(self): def has_existing(self):
"""Query whether an existing CSR is/has been there.""" """Query whether an existing CSR is/has been there."""
@@ -238,13 +257,19 @@ class CertificateSigningRequestBackend(object):
'name_constraints_permitted': self.name_constraints_permitted, 'name_constraints_permitted': self.name_constraints_permitted,
'name_constraints_excluded': self.name_constraints_excluded, 'name_constraints_excluded': self.name_constraints_excluded,
} }
# Get hold of CSR bytes
csr_bytes = self.existing_csr_bytes
if self.csr is not None:
csr_bytes = self.get_csr_data()
self.diff_after = self._get_info(csr_bytes)
if include_csr: if include_csr:
# Get hold of CSR bytes
csr_bytes = self.existing_csr_bytes
if self.csr is not None:
csr_bytes = self.get_csr_data()
# Store result # Store result
result['csr'] = csr_bytes.decode('utf-8') if csr_bytes else None result['csr'] = csr_bytes.decode('utf-8') if csr_bytes else None
result['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result return result
@@ -567,7 +592,7 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def _check_csr(self): def _check_csr(self):
"""Check whether provided parameters, assuming self.existing_csr and self.privatekey have been populated.""" """Check whether provided parameters, assuming self.existing_csr and self.privatekey have been populated."""
def _check_subject(csr): def _check_subject(csr):
subject = [(cryptography_name_to_oid(entry[0]), entry[1]) for entry in self.subject] subject = [(cryptography_name_to_oid(entry[0]), to_text(entry[1])) for entry in self.subject]
current_subject = [(sub.oid, sub.value) for sub in csr.subject] current_subject = [(sub.oid, sub.value) for sub in csr.subject]
return set(subject) == set(current_subject) return set(subject) == set(current_subject)
@@ -579,8 +604,8 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def _check_subjectAltName(extensions): def _check_subjectAltName(extensions):
current_altnames_ext = _find_extension(extensions, cryptography.x509.SubjectAlternativeName) current_altnames_ext = _find_extension(extensions, cryptography.x509.SubjectAlternativeName)
current_altnames = [str(altname) for altname in current_altnames_ext.value] if current_altnames_ext else [] current_altnames = [to_text(altname) for altname in current_altnames_ext.value] if current_altnames_ext else []
altnames = [str(cryptography_get_name(altname)) for altname in self.subjectAltName] if self.subjectAltName else [] altnames = [to_text(cryptography_get_name(altname)) for altname in self.subjectAltName] if self.subjectAltName else []
if set(altnames) != set(current_altnames): if set(altnames) != set(current_altnames):
return False return False
if altnames: if altnames:
@@ -653,10 +678,10 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def _check_nameConstraints(extensions): def _check_nameConstraints(extensions):
current_nc_ext = _find_extension(extensions, cryptography.x509.NameConstraints) current_nc_ext = _find_extension(extensions, cryptography.x509.NameConstraints)
current_nc_perm = [str(altname) for altname in current_nc_ext.value.permitted_subtrees] if current_nc_ext else [] current_nc_perm = [to_text(altname) for altname in current_nc_ext.value.permitted_subtrees] if current_nc_ext else []
current_nc_excl = [str(altname) for altname in current_nc_ext.value.excluded_subtrees] if current_nc_ext else [] current_nc_excl = [to_text(altname) for altname in current_nc_ext.value.excluded_subtrees] if current_nc_ext else []
nc_perm = [str(cryptography_get_name(altname, 'name constraints permitted')) for altname in self.name_constraints_permitted] nc_perm = [to_text(cryptography_get_name(altname, 'name constraints permitted')) for altname in self.name_constraints_permitted]
nc_excl = [str(cryptography_get_name(altname, 'name constraints excluded')) for altname in self.name_constraints_excluded] nc_excl = [to_text(cryptography_get_name(altname, 'name constraints excluded')) for altname in self.name_constraints_excluded]
if set(nc_perm) != set(current_nc_perm) or set(nc_excl) != set(current_nc_excl): if set(nc_perm) != set(current_nc_perm) or set(nc_excl) != set(current_nc_excl):
return False return False
if nc_perm or nc_excl: if nc_perm or nc_excl:
@@ -685,9 +710,9 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
aci = None aci = None
csr_aci = None csr_aci = None
if self.authority_cert_issuer is not None: if self.authority_cert_issuer is not None:
aci = [str(cryptography_get_name(n, 'authority cert issuer')) for n in self.authority_cert_issuer] aci = [to_text(cryptography_get_name(n, 'authority cert issuer')) for n in self.authority_cert_issuer]
if ext.value.authority_cert_issuer is not None: if ext.value.authority_cert_issuer is not None:
csr_aci = [str(n) for n in ext.value.authority_cert_issuer] csr_aci = [to_text(n) for n in ext.value.authority_cert_issuer]
return (ext.value.key_identifier == self.authority_key_identifier return (ext.value.key_identifier == self.authority_key_identifier
and csr_aci == aci and csr_aci == aci
and ext.value.authority_cert_serial_number == self.authority_cert_serial_number) and ext.value.authority_cert_serial_number == self.authority_cert_serial_number)

View File

@@ -0,0 +1,481 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import binascii
import traceback
from distutils.version import LooseVersion
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, to_text, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
load_certificate_request,
get_fingerprint_of_bytes,
)
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.pyopenssl_support import (
pyopenssl_get_extensions_from_csr,
pyopenssl_normalize_name,
pyopenssl_normalize_name_attribute,
pyopenssl_parse_name_constraints,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
get_publickey_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.3'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# OpenSSL 1.1.0 or newer
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
else:
# OpenSSL 1.0.x or older
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
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'] = 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'],
})
if self.backend != 'pyopenssl':
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)
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) 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) for san in nc_ext.value.permitted_subtrees or []]
excluded = [cryptography_decode_name(san) 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) 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
class CSRInfoRetrievalPyOpenSSL(CSRInfoRetrieval):
"""validate the supplied CSR."""
def __init__(self, module, content, validate_signature):
super(CSRInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content, validate_signature)
def __get_name(self, name):
result = []
for sub in name.get_components():
result.append([pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
return result
def _get_subject_ordered(self):
return self.__get_name(self.csr.get_subject())
def _get_extension(self, short_name):
for extension in self.csr.get_extensions():
if extension.get_short_name() == short_name:
result = [
pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
]
return sorted(result), bool(extension.get_critical())
return None, False
def _get_key_usage(self):
return self._get_extension(b'keyUsage')
def _get_extended_key_usage(self):
return self._get_extension(b'extendedKeyUsage')
def _get_basic_constraints(self):
return self._get_extension(b'basicConstraints')
def _get_ocsp_must_staple(self):
extensions = self.csr.get_extensions()
oms_ext = [
ext for ext in extensions
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
]
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
# Older versions of libssl don't know about OCSP Must Staple
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
if oms_ext:
return True, bool(oms_ext[0].get_critical())
else:
return None, False
def _get_subject_alt_name(self):
for extension in self.csr.get_extensions():
if extension.get_short_name() == b'subjectAltName':
result = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
to_text(extension, errors='surrogate_or_strict').split(', ')]
return result, bool(extension.get_critical())
return None, False
def _get_name_constraints(self):
for extension in self.csr.get_extensions():
if extension.get_short_name() == b'nameConstraints':
permitted, excluded = pyopenssl_parse_name_constraints(extension)
return permitted, excluded, bool(extension.get_critical())
return None, None, False
def _get_public_key_pem(self):
try:
return crypto.dump_publickey(
crypto.FILETYPE_PEM,
self.csr.get_pubkey(),
)
except AttributeError:
try:
bio = crypto._new_mem_buf()
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.csr.get_pubkey()._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def _get_public_key_object(self):
return self.csr.get_pubkey()
def _get_subject_key_identifier(self):
# Won't be implemented
return None
def _get_authority_key_identifier(self):
# Won't be implemented
return None, None, None
def _get_all_extensions(self):
return pyopenssl_get_extensions_from_csr(self.csr)
def _is_signature_valid(self):
try:
return bool(self.csr.verify(self.csr.get_pubkey()))
except crypto.Error:
# OpenSSL error means that key is not consistent
return False
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)
elif backend == 'pyopenssl':
info = CSRInfoRetrievalPyOpenSSL(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)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# First try cryptography, then pyOpenSSL
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
try:
getattr(crypto.X509Req, 'get_extensions')
except AttributeError:
module.fail_json(msg='You need to have PyOpenSSL>=0.15 to generate CSRs')
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
return backend, CSRInfoRetrievalPyOpenSSL(module, content, validate_signature=validate_signature)
elif 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))

View File

@@ -16,7 +16,7 @@ from distutils.version import LooseVersion
from ansible.module_utils import six 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._text import to_bytes 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 (
CRYPTOGRAPHY_HAS_X25519, CRYPTOGRAPHY_HAS_X25519,
@@ -37,6 +37,12 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import
identify_private_key_format, identify_private_key_format,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_info import (
PrivateKeyConsistencyError,
PrivateKeyParseError,
get_privatekey_info,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec
@@ -102,6 +108,25 @@ class PrivateKeyBackend:
self.existing_private_key = None self.existing_private_key = None
self.existing_private_key_bytes = None self.existing_private_key_bytes = None
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()
result = dict(can_parse_key=False)
try:
result.update(get_privatekey_info(
self.module, self.backend, data, passphrase=self.passphrase,
return_private_key_data=False, prefer_one_fingerprint=True))
except PrivateKeyConsistencyError as exc:
result.update(exc.result)
except PrivateKeyParseError as exc:
result.update(exc.result)
except Exception as exc:
pass
return result
@abc.abstractmethod @abc.abstractmethod
def generate_private_key(self): def generate_private_key(self):
"""(Re-)Generate private key.""" """(Re-)Generate private key."""
@@ -125,6 +150,7 @@ class PrivateKeyBackend:
def set_existing(self, privatekey_bytes): def set_existing(self, privatekey_bytes):
"""Set existing private key bytes. None indicates that the key does not exist.""" """Set existing private key bytes. None indicates that the key does not exist."""
self.existing_private_key_bytes = privatekey_bytes self.existing_private_key_bytes = privatekey_bytes
self.diff_after = self.diff_before = self._get_info(self.existing_private_key_bytes)
def has_existing(self): def has_existing(self):
"""Query whether an existing private key is/has been there.""" """Query whether an existing private key is/has been there."""
@@ -215,11 +241,12 @@ class PrivateKeyBackend:
} }
if self.type == 'ECC': if self.type == 'ECC':
result['curve'] = self.curve result['curve'] = self.curve
# Get hold of private key bytes
pk_bytes = self.existing_private_key_bytes
if self.private_key is not None:
pk_bytes = self.get_private_key_data()
self.diff_after = self._get_info(pk_bytes)
if include_key: if include_key:
# Get hold of private key bytes
pk_bytes = self.existing_private_key_bytes
if self.private_key is not None:
pk_bytes = self.get_private_key_data()
# Store result # Store result
if pk_bytes: if pk_bytes:
if identify_private_key_format(pk_bytes) == 'raw': if identify_private_key_format(pk_bytes) == 'raw':
@@ -229,6 +256,10 @@ class PrivateKeyBackend:
else: else:
result['privatekey'] = None result['privatekey'] = None
result['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result return result

View File

@@ -0,0 +1,434 @@
# -*- 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 COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import traceback
from distutils.version import LooseVersion
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, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448,
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
load_privatekey,
get_fingerprint_of_bytes,
)
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,
_bigint_to_int,
_get_pyopenssl_public_key_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
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
SIGNATURE_TEST_DATA = b'1234'
def _get_cryptography_private_key_info(key):
key_type, key_public_data = _get_cryptography_public_key_info(key.public_key())
key_private_data = dict()
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
private_numbers = key.private_numbers()
key_private_data['p'] = private_numbers.p
key_private_data['q'] = private_numbers.q
key_private_data['exponent'] = private_numbers.d
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
private_numbers = key.private_numbers()
key_private_data['x'] = private_numbers.x
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
private_numbers = key.private_numbers()
key_private_data['multiplier'] = private_numbers.private_value
return key_type, key_public_data, key_private_data
def _check_dsa_consistency(key_public_data, key_private_data):
# Get parameters
p = key_public_data.get('p')
q = key_public_data.get('q')
g = key_public_data.get('g')
y = key_public_data.get('y')
x = key_private_data.get('x')
for v in (p, q, g, y, x):
if v 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, p) != 1:
return False
# Check whether g**x mod p == y
if binary_exp_mod(g, x, 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, key_public_data, key_private_data):
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
return bool(key._backend._lib.RSA_check_key(key._rsa_cdata))
if isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
result = _check_dsa_consistency(key_public_data, key_private_data)
if result is not None:
return result
try:
signature = key.sign(SIGNATURE_TEST_DATA, cryptography.hazmat.primitives.hashes.SHA256())
except AttributeError:
# sign() was added in cryptography 1.5, but we support older versions
return None
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):
try:
signature = key.sign(
SIGNATURE_TEST_DATA,
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256())
)
except AttributeError:
# sign() was added in cryptography 1.5, but we support older versions
return None
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 CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
has_simple_sign_function = True
if CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
has_simple_sign_function = True
if has_simple_sign_function:
signature = key.sign(SIGNATURE_TEST_DATA)
try:
key.public_key().verify(signature, SIGNATURE_TEST_DATA)
return True
except cryptography.exceptions.InvalidSignature:
return False
# For X25519 and X448, there's no test yet.
return None
class PrivateKeyConsistencyError(OpenSSLObjectError):
def __init__(self, msg, result):
super(PrivateKeyConsistencyError, self).__init__(msg)
self.error_message = msg
self.result = result
class PrivateKeyParseError(OpenSSLObjectError):
def __init__(self, msg, result):
super(PrivateKeyParseError, self).__init__(msg)
self.error_message = msg
self.result = result
@six.add_metaclass(abc.ABCMeta)
class PrivateKeyInfoRetrieval(object):
def __init__(self, module, backend, content, passphrase=None, return_private_key_data=False):
# content must be a bytes string
self.module = module
self.backend = backend
self.content = content
self.passphrase = passphrase
self.return_private_key_data = return_private_key_data
@abc.abstractmethod
def _get_public_key(self, binary):
pass
@abc.abstractmethod
def _get_key_info(self):
pass
@abc.abstractmethod
def _is_key_consistent(self, key_public_data, key_private_data):
pass
def get_info(self, prefer_one_fingerprint=False):
result = dict(
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,
backend=self.backend
)
result['can_parse_key'] = True
except OpenSSLObjectError as exc:
raise PrivateKeyParseError(to_native(exc), result)
result['public_key'] = 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 dict()
key_type, key_public_data, key_private_data = self._get_key_info()
result['type'] = key_type
result['public_data'] = key_public_data
if self.return_private_key_data:
result['private_data'] = key_private_data
result['key_is_consistent'] = self._is_key_consistent(key_public_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 don't 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)
return result
class PrivateKeyInfoRetrievalCryptography(PrivateKeyInfoRetrieval):
"""Validate the supplied private key, using the cryptography backend"""
def __init__(self, module, content, **kwargs):
super(PrivateKeyInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, **kwargs)
def _get_public_key(self, binary):
return self.key.public_key().public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
def _get_key_info(self):
return _get_cryptography_private_key_info(self.key)
def _is_key_consistent(self, key_public_data, key_private_data):
return _is_cryptography_key_consistent(self.key, key_public_data, key_private_data)
class PrivateKeyInfoRetrievalPyOpenSSL(PrivateKeyInfoRetrieval):
"""validate the supplied private key."""
def __init__(self, module, content, **kwargs):
super(PrivateKeyInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content, **kwargs)
def _get_public_key(self, binary):
try:
return crypto.dump_publickey(
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
self.key
)
except AttributeError:
try:
# pyOpenSSL < 16.0:
bio = crypto._new_mem_buf()
if binary:
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.key._pkey)
else:
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.key._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def _get_key_info(self):
key_type, key_public_data, try_fallback = _get_pyopenssl_public_key_info(self.key)
key_private_data = dict()
openssl_key_type = self.key.type()
if crypto.TYPE_RSA == openssl_key_type:
try:
# Use OpenSSL directly to extract key data
key = OpenSSL._util.lib.EVP_PKEY_get1_RSA(self.key._pkey)
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.RSA_free)
# OpenSSL 1.1 and newer have functions to extract the parameters
# from the EVP PKEY data structures. Older versions didn't have
# these getters, and it was common use to simply access the values
# directly. Since there's no guarantee that these data structures
# will still be accessible in the future, we use the getters for
# 1.1 and later, and directly access the values for 1.0.x and
# earlier.
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# Get modulus and exponents
n = OpenSSL._util.ffi.new("BIGNUM **")
e = OpenSSL._util.ffi.new("BIGNUM **")
d = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.RSA_get0_key(key, n, e, d)
key_private_data['exponent'] = _bigint_to_int(d[0])
# Get factors
p = OpenSSL._util.ffi.new("BIGNUM **")
q = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.RSA_get0_factors(key, p, q)
key_private_data['p'] = _bigint_to_int(p[0])
key_private_data['q'] = _bigint_to_int(q[0])
else:
# Get private exponent
key_private_data['exponent'] = _bigint_to_int(key.d)
# Get factors
key_private_data['p'] = _bigint_to_int(key.p)
key_private_data['q'] = _bigint_to_int(key.q)
except AttributeError:
try_fallback = True
elif crypto.TYPE_DSA == openssl_key_type:
try:
# Use OpenSSL directly to extract key data
key = OpenSSL._util.lib.EVP_PKEY_get1_DSA(self.key._pkey)
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.DSA_free)
# OpenSSL 1.1 and newer have functions to extract the parameters
# from the EVP PKEY data structures. Older versions didn't have
# these getters, and it was common use to simply access the values
# directly. Since there's no guarantee that these data structures
# will still be accessible in the future, we use the getters for
# 1.1 and later, and directly access the values for 1.0.x and
# earlier.
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# Get private key exponents
y = OpenSSL._util.ffi.new("BIGNUM **")
x = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.DSA_get0_key(key, y, x)
key_private_data['x'] = _bigint_to_int(x[0])
else:
# Get private key exponents
key_private_data['x'] = _bigint_to_int(key.priv_key)
except AttributeError:
try_fallback = True
else:
# Return 'unknown'
key_type = 'unknown ({0})'.format(self.key.type())
# If needed and if possible, fall back to cryptography
if try_fallback and PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
return _get_cryptography_private_key_info(self.key.to_cryptography_key())
return key_type, key_public_data, key_private_data
def _is_key_consistent(self, key_public_data, key_private_data):
openssl_key_type = self.key.type()
if crypto.TYPE_RSA == openssl_key_type:
try:
return self.key.check()
except crypto.Error:
# OpenSSL error means that key is not consistent
return False
if crypto.TYPE_DSA == openssl_key_type:
result = _check_dsa_consistency(key_public_data, key_private_data)
if result is not None:
return result
signature = crypto.sign(self.key, SIGNATURE_TEST_DATA, 'sha256')
# Verify wants a cert (where it can get the public key from)
cert = crypto.X509()
cert.set_pubkey(self.key)
try:
crypto.verify(cert, signature, SIGNATURE_TEST_DATA, 'sha256')
return True
except crypto.Error:
return False
# If needed and if possible, fall back to cryptography
if PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
return _is_cryptography_key_consistent(self.key.to_cryptography_key(), key_public_data, key_private_data)
return None
def get_privatekey_info(module, backend, content, passphrase=None, return_private_key_data=False, prefer_one_fingerprint=False):
if backend == 'cryptography':
info = PrivateKeyInfoRetrievalCryptography(
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
elif backend == 'pyopenssl':
info = PrivateKeyInfoRetrievalPyOpenSSL(
module, 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, backend, content, passphrase=None, return_private_key_data=False):
if backend == 'auto':
# Detection what is possible
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# First try cryptography, then pyOpenSSL
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
return backend, PrivateKeyInfoRetrievalPyOpenSSL(
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
elif 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, PrivateKeyInfoRetrievalCryptography(
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))

View File

@@ -0,0 +1,320 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2020-2021, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import traceback
from distutils.version import LooseVersion
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.basic import (
CRYPTOGRAPHY_HAS_X25519,
CRYPTOGRAPHY_HAS_X448,
CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448,
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
get_fingerprint_of_bytes,
load_publickey,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
MINIMAL_PYOPENSSL_VERSION = '16.0.0' # when working with public key objects, the minimal required version is 0.15
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
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
def _get_cryptography_public_key_info(key):
key_public_data = dict()
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey):
key_type = 'RSA'
public_numbers = key.public_numbers()
key_public_data['size'] = key.key_size
key_public_data['modulus'] = public_numbers.n
key_public_data['exponent'] = public_numbers.e
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey):
key_type = 'DSA'
parameter_numbers = key.parameters().parameter_numbers()
public_numbers = key.public_numbers()
key_public_data['size'] = key.key_size
key_public_data['p'] = parameter_numbers.p
key_public_data['q'] = parameter_numbers.q
key_public_data['g'] = parameter_numbers.g
key_public_data['y'] = public_numbers.y
elif CRYPTOGRAPHY_HAS_X25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey):
key_type = 'X25519'
elif CRYPTOGRAPHY_HAS_X448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey):
key_type = 'X448'
elif CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey):
key_type = 'Ed25519'
elif CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey):
key_type = 'Ed448'
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey):
key_type = 'ECC'
public_numbers = key.public_numbers()
key_public_data['curve'] = key.curve.name
key_public_data['x'] = public_numbers.x
key_public_data['y'] = public_numbers.y
key_public_data['exponent_size'] = key.curve.key_size
else:
key_type = 'unknown ({0})'.format(type(key))
return key_type, key_public_data
def _bigint_to_int(bn):
'''Convert OpenSSL BIGINT to Python integer'''
if bn == OpenSSL._util.ffi.NULL:
return None
hexstr = OpenSSL._util.lib.BN_bn2hex(bn)
try:
return int(OpenSSL._util.ffi.string(hexstr), 16)
finally:
OpenSSL._util.lib.OPENSSL_free(hexstr)
def _get_pyopenssl_public_key_info(key):
key_public_data = dict()
try_fallback = True
openssl_key_type = key.type()
if crypto.TYPE_RSA == openssl_key_type:
key_type = 'RSA'
key_public_data['size'] = key.bits()
try:
# Use OpenSSL directly to extract key data
key = OpenSSL._util.lib.EVP_PKEY_get1_RSA(key._pkey)
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.RSA_free)
# OpenSSL 1.1 and newer have functions to extract the parameters
# from the EVP PKEY data structures. Older versions didn't have
# these getters, and it was common use to simply access the values
# directly. Since there's no guarantee that these data structures
# will still be accessible in the future, we use the getters for
# 1.1 and later, and directly access the values for 1.0.x and
# earlier.
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# Get modulus and exponents
n = OpenSSL._util.ffi.new("BIGNUM **")
e = OpenSSL._util.ffi.new("BIGNUM **")
d = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.RSA_get0_key(key, n, e, d)
key_public_data['modulus'] = _bigint_to_int(n[0])
key_public_data['exponent'] = _bigint_to_int(e[0])
else:
# Get modulus and exponents
key_public_data['modulus'] = _bigint_to_int(key.n)
key_public_data['exponent'] = _bigint_to_int(key.e)
try_fallback = False
except AttributeError:
# Use fallback if available
pass
elif crypto.TYPE_DSA == openssl_key_type:
key_type = 'DSA'
key_public_data['size'] = key.bits()
try:
# Use OpenSSL directly to extract key data
key = OpenSSL._util.lib.EVP_PKEY_get1_DSA(key._pkey)
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.DSA_free)
# OpenSSL 1.1 and newer have functions to extract the parameters
# from the EVP PKEY data structures. Older versions didn't have
# these getters, and it was common use to simply access the values
# directly. Since there's no guarantee that these data structures
# will still be accessible in the future, we use the getters for
# 1.1 and later, and directly access the values for 1.0.x and
# earlier.
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# Get public parameters (primes and group element)
p = OpenSSL._util.ffi.new("BIGNUM **")
q = OpenSSL._util.ffi.new("BIGNUM **")
g = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.DSA_get0_pqg(key, p, q, g)
key_public_data['p'] = _bigint_to_int(p[0])
key_public_data['q'] = _bigint_to_int(q[0])
key_public_data['g'] = _bigint_to_int(g[0])
# Get public key exponents
y = OpenSSL._util.ffi.new("BIGNUM **")
x = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.DSA_get0_key(key, y, x)
key_public_data['y'] = _bigint_to_int(y[0])
else:
# Get public parameters (primes and group element)
key_public_data['p'] = _bigint_to_int(key.p)
key_public_data['q'] = _bigint_to_int(key.q)
key_public_data['g'] = _bigint_to_int(key.g)
# Get public key exponents
key_public_data['y'] = _bigint_to_int(key.pub_key)
try_fallback = False
except AttributeError:
# Use fallback if available
pass
else:
# Return 'unknown'
key_type = 'unknown ({0})'.format(key.type())
return key_type, key_public_data, try_fallback
class PublicKeyParseError(OpenSSLObjectError):
def __init__(self, msg, result):
super(PublicKeyParseError, self).__init__(msg)
self.error_message = msg
self.result = result
@six.add_metaclass(abc.ABCMeta)
class PublicKeyInfoRetrieval(object):
def __init__(self, module, backend, content=None, key=None):
# content must be a bytes string
self.module = module
self.backend = backend
self.content = content
self.key = key
@abc.abstractmethod
def _get_public_key(self, binary):
pass
@abc.abstractmethod
def _get_key_info(self):
pass
def get_info(self, prefer_one_fingerprint=False):
result = dict()
if self.key is None:
try:
self.key = load_publickey(content=self.content, backend=self.backend)
except OpenSSLObjectError as e:
raise PublicKeyParseError(to_native(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 dict()
key_type, key_public_data = self._get_key_info()
result['type'] = key_type
result['public_data'] = key_public_data
return result
class PublicKeyInfoRetrievalCryptography(PublicKeyInfoRetrieval):
"""Validate the supplied public key, using the cryptography backend"""
def __init__(self, module, content=None, key=None):
super(PublicKeyInfoRetrievalCryptography, self).__init__(module, 'cryptography', content=content, key=key)
def _get_public_key(self, binary):
return self.key.public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
def _get_key_info(self):
return _get_cryptography_public_key_info(self.key)
class PublicKeyInfoRetrievalPyOpenSSL(PublicKeyInfoRetrieval):
"""validate the supplied public key."""
def __init__(self, module, content=None, key=None):
super(PublicKeyInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content=content, key=key)
def _get_public_key(self, binary):
try:
return crypto.dump_publickey(
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
self.key
)
except AttributeError:
try:
# pyOpenSSL < 16.0:
bio = crypto._new_mem_buf()
if binary:
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.key._pkey)
else:
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.key._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def _get_key_info(self):
key_type, key_public_data, try_fallback = _get_pyopenssl_public_key_info(self.key)
# If needed and if possible, fall back to cryptography
if try_fallback and PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
return _get_cryptography_public_key_info(self.key.to_cryptography_key())
return key_type, key_public_data
def get_publickey_info(module, backend, content=None, key=None, prefer_one_fingerprint=False):
if backend == 'cryptography':
info = PublicKeyInfoRetrievalCryptography(module, content=content, key=key)
elif backend == 'pyopenssl':
info = PublicKeyInfoRetrievalPyOpenSSL(module, content=content, key=key)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(module, backend, content=None, key=None):
if backend == 'auto':
# Detection what is possible
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# First try cryptography, then pyOpenSSL
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
return backend, PublicKeyInfoRetrievalPyOpenSSL(module, content=content, key=key)
elif 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, PublicKeyInfoRetrievalCryptography(module, content=content, key=key)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))

View File

@@ -18,19 +18,7 @@
from __future__ import absolute_import, division, print_function from __future__ import absolute_import, division, print_function
__metaclass__ = type __metaclass__ = type
# This import is only to maintain backwards compatibility
import re from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import (
parse_openssh_version
)
def parse_openssh_version(version_string):
"""Parse the version output of ssh -V and return version numbers that can be compared"""
parsed_result = re.match(
r"^.*openssh_(?P<version>[0-9.]+)(p?[0-9]+)[^0-9]*.*$", version_string.lower()
)
if parsed_result is not None:
version = parsed_result.group("version").strip()
else:
version = None
return version

View File

@@ -21,7 +21,7 @@ __metaclass__ = type
import base64 import base64
from ansible.module_utils._text import to_bytes, to_text from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress

View File

@@ -27,7 +27,7 @@ import os
import re import re
from ansible.module_utils import six from ansible.module_utils import six
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
try: try:
from OpenSSL import crypto from OpenSSL import crypto
@@ -52,7 +52,14 @@ from .basic import (
) )
def get_fingerprint_of_bytes(source): # 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, prefer_one=False):
"""Generate the fingerprint of the given bytes.""" """Generate the fingerprint of the given bytes."""
fingerprint = {} fingerprint = {}
@@ -65,6 +72,12 @@ def get_fingerprint_of_bytes(source):
except AttributeError: except AttributeError:
return None return None
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: for algo in algorithms:
f = getattr(hashlib, algo) f = getattr(hashlib, algo)
try: try:
@@ -79,11 +92,13 @@ def get_fingerprint_of_bytes(source):
except TypeError: except TypeError:
pubkey_digest = h.hexdigest(32) pubkey_digest = h.hexdigest(32)
fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2)) fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2))
if prefer_one:
break
return fingerprint return fingerprint
def get_fingerprint_of_privatekey(privatekey, backend='pyopenssl'): def get_fingerprint_of_privatekey(privatekey, backend='pyopenssl', prefer_one=False):
"""Generate the fingerprint of the public key. """ """Generate the fingerprint of the public key. """
if backend == 'pyopenssl': if backend == 'pyopenssl':
@@ -107,15 +122,15 @@ def get_fingerprint_of_privatekey(privatekey, backend='pyopenssl'):
serialization.PublicFormat.SubjectPublicKeyInfo serialization.PublicFormat.SubjectPublicKeyInfo
) )
return get_fingerprint_of_bytes(publickey) return get_fingerprint_of_bytes(publickey, prefer_one=prefer_one)
def get_fingerprint(path, passphrase=None, content=None, backend='pyopenssl'): def get_fingerprint(path, passphrase=None, content=None, backend='pyopenssl', prefer_one=False):
"""Generate the fingerprint of the public key. """ """Generate the fingerprint of the public key. """
privatekey = load_privatekey(path, passphrase=passphrase, content=content, check_passphrase=False, backend=backend) privatekey = load_privatekey(path, passphrase=passphrase, content=content, check_passphrase=False, backend=backend)
return get_fingerprint_of_privatekey(privatekey, backend=backend) return get_fingerprint_of_privatekey(privatekey, backend=backend, prefer_one=prefer_one)
def load_privatekey(path, passphrase=None, check_passphrase=True, content=None, backend='pyopenssl'): def load_privatekey(path, passphrase=None, check_passphrase=True, content=None, backend='pyopenssl'):
@@ -183,6 +198,28 @@ def load_privatekey(path, passphrase=None, check_passphrase=True, content=None,
return result return result
def load_publickey(path=None, content=None, backend=None):
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)
if backend == 'cryptography':
try:
return serialization.load_pem_public_key(content, backend=cryptography_backend())
except Exception as e:
raise OpenSSLObjectError('Error while deserializing key: {0}'.format(e))
else:
try:
return crypto.load_publickey(crypto.FILETYPE_PEM, content)
except crypto.Error as e:
raise OpenSSLObjectError('Error while deserializing key: {0}'.format(e))
def load_certificate(path, content=None, backend='pyopenssl'): def load_certificate(path, content=None, backend='pyopenssl'):
"""Load the specified certificate.""" """Load the specified certificate."""
@@ -334,6 +371,8 @@ class OpenSSLObject(object):
def _check_perms(module): def _check_perms(module):
file_args = module.load_file_common_arguments(module.params) 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) return not module.set_fs_attributes_if_different(file_args, False)
if not perms_required: if not perms_required:

View File

@@ -37,7 +37,7 @@ import re
import time import time
import traceback import traceback
from ansible.module_utils._text import to_text, to_native from ansible.module_utils.common.text.converters import to_text, to_native
from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.six.moves.urllib.error import HTTPError

View File

@@ -92,7 +92,8 @@ def write_file(module, content, default_mode=None, path=None):
# Move tempfile to final destination # Move tempfile to final destination
module.atomic_move(tmp_name, file_args['path']) module.atomic_move(tmp_name, file_args['path'])
# Try to update permissions again # Try to update permissions again
module.set_fs_attributes_if_different(file_args, False) if not module.check_file_absent_if_check_mode(file_args['path']):
module.set_fs_attributes_if_different(file_args, False)
except Exception as e: except Exception as e:
try: try:
os.remove(tmp_name) os.remove(tmp_name)

View File

@@ -0,0 +1,328 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import os
import stat
from ansible.module_utils import six
from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import (
parse_openssh_version,
)
def restore_on_failure(f):
def backup_and_restore(module, path, *args, **kwargs):
backup_file = module.backup_local(path) if os.path.exists(path) else None
try:
f(module, path, *args, **kwargs)
except Exception:
if backup_file is not None:
module.atomic_move(backup_file, path)
raise
else:
module.add_cleanup_file(backup_file)
return backup_and_restore
@restore_on_failure
def safe_atomic_move(module, path, destination):
module.atomic_move(path, destination)
def _restore_all_on_failure(f):
def backup_and_restore(self, sources_and_destinations, *args, **kwargs):
backups = [(d, self.module.backup_local(d)) for s, d in sources_and_destinations if os.path.exists(d)]
try:
f(self, sources_and_destinations, *args, **kwargs)
except Exception:
for destination, backup in backups:
self.module.atomic_move(backup, destination)
raise
else:
for destination, backup in backups:
self.module.add_cleanup_file(backup)
return backup_and_restore
@six.add_metaclass(abc.ABCMeta)
class OpensshModule(object):
def __init__(self, module):
self.module = module
self.changed = False
self.check_mode = self.module.check_mode
def execute(self):
self._execute()
self.module.exit_json(**self.result)
@abc.abstractmethod
def _execute(self):
pass
@property
def result(self):
result = self._result
result['changed'] = self.changed
if self.module._diff:
result['diff'] = self.diff
return result
@property
@abc.abstractmethod
def _result(self):
pass
@property
@abc.abstractmethod
def diff(self):
pass
@staticmethod
def skip_if_check_mode(f):
def wrapper(self, *args, **kwargs):
if not self.check_mode:
f(self, *args, **kwargs)
return wrapper
@staticmethod
def trigger_change(f):
def wrapper(self, *args, **kwargs):
f(self, *args, **kwargs)
self.changed = True
return wrapper
def _check_if_base_dir(self, path):
base_dir = os.path.dirname(path) or '.'
if not os.path.isdir(base_dir):
self.module.fail_json(
name=base_dir,
msg='The directory %s does not exist or the file is not a directory' % base_dir
)
def _get_ssh_version(self):
ssh_bin = self.module.get_bin_path('ssh')
if not ssh_bin:
return ""
return parse_openssh_version(self.module.run_command([ssh_bin, '-V', '-q'])[2].strip())
@_restore_all_on_failure
def _safe_secure_move(self, sources_and_destinations):
"""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
exposing protected data ('atomic_move' uses the 'destination' base directory mask for
permissions if 'destination' does not already exists).
"""
for source, destination in sources_and_destinations:
if os.path.exists(destination):
self.module.atomic_move(source, destination)
else:
self.module.preserved_copy(source, destination)
def _update_permissions(self, path):
file_args = self.module.load_file_common_arguments(self.module.params)
file_args['path'] = path
if not self.module.check_file_absent_if_check_mode(path):
self.changed = self.module.set_fs_attributes_if_different(file_args, self.changed)
else:
self.changed = True
class KeygenCommand(object):
def __init__(self, module):
self._bin_path = module.get_bin_path('ssh-keygen', True)
self._run_command = module.run_command
def generate_certificate(self, certificate_path, identifier, options, pkcs11_provider, principals,
serial_number, signature_algorithm, signing_key_path, type,
time_parameters, use_agent, **kwargs):
args = [self._bin_path, '-s', signing_key_path, '-P', '', '-I', identifier]
if options:
for option in options:
args.extend(['-O', option])
if pkcs11_provider:
args.extend(['-D', pkcs11_provider])
if principals:
args.extend(['-n', ','.join(principals)])
if serial_number is not None:
args.extend(['-z', str(serial_number)])
if type == 'host':
args.extend(['-h'])
if use_agent:
args.extend(['-U'])
if time_parameters.validity_string:
args.extend(['-V', time_parameters.validity_string])
if signature_algorithm:
args.extend(['-t', signature_algorithm])
args.append(certificate_path)
return self._run_command(args, **kwargs)
def generate_keypair(self, private_key_path, size, type, comment, **kwargs):
args = [
self._bin_path,
'-q',
'-N', '',
'-b', str(size),
'-t', type,
'-f', private_key_path,
'-C', comment or ''
]
# "y" must be entered in response to the "overwrite" prompt
data = 'y' if os.path.exists(private_key_path) else None
return self._run_command(args, data=data, **kwargs)
def get_certificate_info(self, certificate_path, **kwargs):
return self._run_command([self._bin_path, '-L', '-f', certificate_path], **kwargs)
def get_matching_public_key(self, private_key_path, **kwargs):
return self._run_command([self._bin_path, '-P', '', '-y', '-f', private_key_path], **kwargs)
def get_private_key(self, private_key_path, **kwargs):
return self._run_command([self._bin_path, '-l', '-f', private_key_path], **kwargs)
def update_comment(self, private_key_path, comment, **kwargs):
if os.path.exists(private_key_path) and not os.access(private_key_path, os.W_OK):
try:
os.chmod(private_key_path, stat.S_IWUSR + stat.S_IRUSR)
except (IOError, OSError) as e:
raise e("The private key at %s is not writeable preventing a comment update" % private_key_path)
return self._run_command([self._bin_path, '-q', '-o', '-c', '-C', comment, '-f', private_key_path], **kwargs)
class PrivateKey(object):
def __init__(self, size, key_type, fingerprint):
self._size = size
self._type = key_type
self._fingerprint = fingerprint
@property
def size(self):
return self._size
@property
def type(self):
return self._type
@property
def fingerprint(self):
return self._fingerprint
@classmethod
def from_string(cls, string):
properties = string.split()
return cls(
size=int(properties[0]),
key_type=properties[-1][1:-1].lower(),
fingerprint=properties[1],
)
def to_dict(self):
return {
'size': self._size,
'type': self._type,
'fingerprint': self._fingerprint,
}
class PublicKey(object):
def __init__(self, type_string, data, comment):
self._type_string = type_string
self._data = data
self._comment = comment
def __eq__(self, other):
if not isinstance(other, type(self)):
return NotImplemented
return all([
self._type_string == other._type_string,
self._data == other._data,
(self._comment == other._comment) if self._comment is not None and other._comment is not None else True
])
def __ne__(self, other):
return not self == other
def __str__(self):
return "%s %s" % (self._type_string, self._data)
@property
def comment(self):
return self._comment
@comment.setter
def comment(self, value):
self._comment = value
@property
def data(self):
return self._data
@property
def type_string(self):
return self._type_string
@classmethod
def from_string(cls, string):
properties = string.strip('\n').split(' ', 2)
return cls(
type_string=properties[0],
data=properties[1],
comment=properties[2] if len(properties) > 2 else ""
)
@classmethod
def load(cls, path):
try:
with open(path, 'r') as f:
properties = f.read().strip(' \n').split(' ', 2)
except (IOError, OSError):
raise
if len(properties) < 2:
return None
return cls(
type_string=properties[0],
data=properties[1],
comment='' if len(properties) <= 2 else properties[2],
)
def to_dict(self):
return {
'comment': self._comment,
'public_key': self._data,
}

View File

@@ -0,0 +1,464 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2018, David Kainz <dkainz@mgit.at> <dave.jokain@gmx.at>
# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import os
from distutils.version import LooseVersion
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, to_text, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.openssh.cryptography import (
HAS_OPENSSH_SUPPORT,
HAS_OPENSSH_PRIVATE_FORMAT,
InvalidCommentError,
InvalidPassphraseError,
InvalidPrivateKeyFileError,
OpenSSHError,
OpensshKeypair,
)
from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.common import (
KeygenCommand,
OpensshModule,
PrivateKey,
PublicKey,
)
from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import (
any_in,
file_mode,
secure_write,
)
@six.add_metaclass(abc.ABCMeta)
class KeypairBackend(OpensshModule):
def __init__(self, module):
super(KeypairBackend, self).__init__(module)
self.comment = self.module.params['comment']
self.private_key_path = self.module.params['path']
self.public_key_path = self.private_key_path + '.pub'
self.regenerate = self.module.params['regenerate'] if not self.module.params['force'] else 'always'
self.state = self.module.params['state']
self.type = self.module.params['type']
self.size = self._get_size(self.module.params['size'])
self._validate_path()
self.original_private_key = None
self.original_public_key = None
self.private_key = None
self.public_key = None
def _get_size(self, size):
if self.type in ('rsa', 'rsa1'):
result = 4096 if size is None else size
if result < 1024:
return self.module.fail_json(
msg="For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. " +
"Attempting to use bit lengths under 1024 will cause the module to fail."
)
elif self.type == 'dsa':
result = 1024 if size is None else size
if result != 1024:
return self.module.fail_json(msg="DSA keys must be exactly 1024 bits as specified by FIPS 186-2.")
elif self.type == 'ecdsa':
result = 256 if size is None else size
if result not in (256, 384, 521):
return self.module.fail_json(
msg="For ECDSA keys, size determines the key length by selecting from one of " +
"three elliptic curve sizes: 256, 384 or 521 bits. " +
"Attempting to use bit lengths other than these three values for ECDSA keys will " +
"cause this module to fail."
)
elif self.type == 'ed25519':
# User input is ignored for `key size` when `key type` is ed25519
result = 256
else:
return self.module.fail_json(msg="%s is not a valid value for key type" % self.type)
return result
def _validate_path(self):
self._check_if_base_dir(self.private_key_path)
if os.path.isdir(self.private_key_path):
self.module.fail_json(msg='%s is a directory. Please specify a path to a file.' % self.private_key_path)
def _execute(self):
self.original_private_key = self._load_private_key()
self.original_public_key = self._load_public_key()
if self.state == 'present':
self._validate_key_load()
if self._should_generate():
self._generate()
elif not self._public_key_valid():
self._restore_public_key()
self.private_key = self._load_private_key()
self.public_key = self._load_public_key()
for path in (self.private_key_path, self.public_key_path):
self._update_permissions(path)
else:
if self._should_remove():
self._remove()
def _load_private_key(self):
result = None
if self._private_key_exists():
try:
result = self._get_private_key()
except Exception:
pass
return result
def _private_key_exists(self):
return os.path.exists(self.private_key_path)
@abc.abstractmethod
def _get_private_key(self):
pass
def _load_public_key(self):
result = None
if self._public_key_exists():
try:
result = PublicKey.load(self.public_key_path)
except (IOError, OSError):
pass
return result
def _public_key_exists(self):
return os.path.exists(self.public_key_path)
def _validate_key_load(self):
if (self._private_key_exists()
and self.regenerate in ('never', 'fail', 'partial_idempotence')
and (self.original_private_key is None or not self._private_key_readable())):
self.module.fail_json(
msg="Unable to read the key. The key is protected with a passphrase or broken. " +
"Will not proceed. To force regeneration, call the module with `generate` " +
"set to `full_idempotence` or `always`, or with `force=yes`."
)
@abc.abstractmethod
def _private_key_readable(self):
pass
def _should_generate(self):
if self.regenerate == 'never':
return self.original_private_key is None
elif self.regenerate == 'fail':
if not self._private_key_valid():
self.module.fail_json(
msg="Key has wrong type and/or size. Will not proceed. " +
"To force regeneration, call the module with `generate` set to " +
"`partial_idempotence`, `full_idempotence` or `always`, or with `force=yes`."
)
return self.original_private_key is None
elif self.regenerate in ('partial_idempotence', 'full_idempotence'):
return not self._private_key_valid()
else:
return True
def _private_key_valid(self):
if self.original_private_key is None:
return False
return all([
self.size == self.original_private_key.size,
self.type == self.original_private_key.type,
])
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _generate(self):
temp_private_key, temp_public_key = self._generate_temp_keypair()
try:
self._safe_secure_move([(temp_private_key, self.private_key_path), (temp_public_key, self.public_key_path)])
except OSError as e:
self.module.fail_json(msg=to_native(e))
def _generate_temp_keypair(self):
temp_private_key = os.path.join(self.module.tmpdir, os.path.basename(self.private_key_path))
temp_public_key = temp_private_key + '.pub'
try:
self._generate_keypair(temp_private_key)
except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e))
for f in (temp_private_key, temp_public_key):
self.module.add_cleanup_file(f)
return temp_private_key, temp_public_key
@abc.abstractmethod
def _generate_keypair(self, private_key_path):
pass
def _public_key_valid(self):
if self.original_public_key is None:
return False
valid_public_key = self._get_public_key()
valid_public_key.comment = self.comment
return self.original_public_key == valid_public_key
@abc.abstractmethod
def _get_public_key(self):
pass
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _restore_public_key(self):
try:
temp_public_key = self._create_temp_public_key(str(self._get_public_key()) + '\n')
self._safe_secure_move([
(temp_public_key, self.public_key_path)
])
except (IOError, OSError):
self.module.fail_json(
msg="The public key is missing or does not match the private key. " +
"Unable to regenerate the public key."
)
if self.comment:
self._update_comment()
def _create_temp_public_key(self, content):
temp_public_key = os.path.join(self.module.tmpdir, os.path.basename(self.public_key_path))
default_permissions = 0o644
existing_permissions = file_mode(self.public_key_path)
try:
secure_write(temp_public_key, existing_permissions or default_permissions, to_bytes(content))
except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e))
self.module.add_cleanup_file(temp_public_key)
return temp_public_key
@abc.abstractmethod
def _update_comment(self):
pass
def _should_remove(self):
return self._private_key_exists() or self._public_key_exists()
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _remove(self):
try:
if self._private_key_exists():
os.remove(self.private_key_path)
if self._public_key_exists():
os.remove(self.public_key_path)
except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e))
@property
def _result(self):
private_key = self.private_key or self.original_private_key
public_key = self.public_key or self.original_public_key
return {
'size': self.size,
'type': self.type,
'filename': self.private_key_path,
'fingerprint': private_key.fingerprint if private_key else '',
'public_key': str(public_key) if public_key else '',
'comment': public_key.comment if public_key else '',
}
@property
def diff(self):
before = self.original_private_key.to_dict() if self.original_private_key else {}
before.update(self.original_public_key.to_dict() if self.original_public_key else {})
after = self.private_key.to_dict() if self.private_key else {}
after.update(self.public_key.to_dict() if self.public_key else {})
return {
'before': before,
'after': after,
}
class KeypairBackendOpensshBin(KeypairBackend):
def __init__(self, module):
super(KeypairBackendOpensshBin, self).__init__(module)
self.ssh_keygen = KeygenCommand(self.module)
def _generate_keypair(self, private_key_path):
self.ssh_keygen.generate_keypair(private_key_path, self.size, self.type, self.comment)
def _get_private_key(self):
private_key_content = self.ssh_keygen.get_private_key(self.private_key_path)[1]
return PrivateKey.from_string(private_key_content)
def _get_public_key(self):
public_key_content = self.ssh_keygen.get_matching_public_key(self.private_key_path)[1]
return PublicKey.from_string(public_key_content)
def _private_key_readable(self):
rc, stdout, stderr = self.ssh_keygen.get_matching_public_key(self.private_key_path)
return not (rc == 255 or any_in(stderr, 'is not a public key file', 'incorrect passphrase', 'load failed'))
def _update_comment(self):
try:
self.ssh_keygen.update_comment(self.private_key_path, self.comment)
except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e))
class KeypairBackendCryptography(KeypairBackend):
def __init__(self, module):
super(KeypairBackendCryptography, self).__init__(module)
if self.type == 'rsa1':
self.module.fail_json(msg="RSA1 keys are not supported by the cryptography backend")
self.passphrase = to_bytes(module.params['passphrase']) if module.params['passphrase'] else None
self.private_key_format = self._get_key_format(module.params['private_key_format'])
def _get_key_format(self, key_format):
result = 'SSH'
if key_format == 'auto':
# Default to OpenSSH 7.8 compatibility when OpenSSH is not installed
ssh_version = self._get_ssh_version() or "7.8"
if LooseVersion(ssh_version) < LooseVersion("7.8") and self.type != 'ed25519':
# OpenSSH made SSH formatted private keys available in version 6.5,
# but still defaulted to PKCS1 format with the exception of ed25519 keys
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"
)
)
return result
def _generate_keypair(self, private_key_path):
keypair = OpensshKeypair.generate(
keytype=self.type,
size=self.size,
passphrase=self.passphrase,
comment=self.comment or '',
)
encoded_private_key = OpensshKeypair.encode_openssh_privatekey(
keypair.asymmetric_keypair, self.private_key_format
)
secure_write(private_key_path, 0o600, encoded_private_key)
public_key_path = private_key_path + '.pub'
secure_write(public_key_path, 0o644, keypair.public_key)
def _get_private_key(self):
keypair = OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True)
return PrivateKey(
size=keypair.size,
key_type=keypair.key_type,
fingerprint=keypair.fingerprint,
)
def _get_public_key(self):
try:
keypair = OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True)
except OpenSSHError:
# Simulates the null output of ssh-keygen
return ""
return PublicKey.from_string(to_text(keypair.public_key))
def _private_key_readable(self):
try:
OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True)
except (InvalidPrivateKeyFileError, InvalidPassphraseError):
return False
# Cryptography >= 3.0 uses a SSH key loader which does not raise an exception when a passphrase is provided
# when loading an unencrypted key
if self.passphrase:
try:
OpensshKeypair.load(path=self.private_key_path, passphrase=None, no_public_key=True)
except (InvalidPrivateKeyFileError, InvalidPassphraseError):
return True
else:
return False
return True
def _update_comment(self):
keypair = OpensshKeypair.load(path=self.private_key_path, passphrase=self.passphrase, no_public_key=True)
try:
keypair.comment = self.comment
except InvalidCommentError as e:
self.module.fail_json(msg=to_native(e))
try:
temp_public_key = self._create_temp_public_key(keypair.public_key + b'\n')
self._safe_secure_move([(temp_public_key, self.public_key_path)])
except (IOError, OSError) as e:
self.module.fail_json(msg=to_native(e))
def select_backend(module, backend):
can_use_cryptography = HAS_OPENSSH_SUPPORT
can_use_opensshbin = bool(module.get_bin_path('ssh-keygen'))
if backend == 'auto':
if can_use_opensshbin and not module.params['passphrase']:
backend = 'opensshbin'
elif can_use_cryptography:
backend = 'cryptography'
else:
module.fail_json(msg="Cannot find either the OpenSSH binary in the PATH " +
"or cryptography >= 2.6 installed on this system")
if backend == 'opensshbin':
if not can_use_opensshbin:
module.fail_json(msg="Cannot find the OpenSSH binary in the PATH")
return backend, KeypairBackendOpensshBin(module)
elif backend == 'cryptography':
if not can_use_cryptography:
module.fail_json(msg=missing_required_lib("cryptography >= 2.6"))
return backend, KeypairBackendCryptography(module)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))

View File

@@ -0,0 +1,677 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function
__metaclass__ = type
# 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
import abc
import binascii
import os
from base64 import b64encode
from datetime import datetime
from hashlib import sha256
from ansible.module_utils import six
from ansible.module_utils.common.text.converters import to_text
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import convert_relative_to_datetime
from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import (
OpensshParser,
_OpensshWriter,
)
# See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD
_USER_TYPE = 1
_HOST_TYPE = 2
_SSH_TYPE_STRINGS = {
'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 = datetime(1970, 1, 1)
_FOREVER = datetime.max
_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'
)
if six.PY3:
long = int
class OpensshCertificateTimeParameters(object):
def __init__(self, valid_from, valid_to):
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("Valid from: %s must not be greater than Valid to: %s" % (valid_from, valid_to))
def __eq__(self, other):
if not isinstance(other, type(self)):
return NotImplemented
else:
return self._valid_from == other._valid_from and self._valid_to == other._valid_to
def __ne__(self, other):
return not self == other
@property
def validity_string(self):
if not (self._valid_from == _ALWAYS and self._valid_to == _FOREVER):
return "%s:%s" % (
self.valid_from(date_format='openssh'), self.valid_to(date_format='openssh')
)
return ""
def valid_from(self, date_format):
return self.format_datetime(self._valid_from, date_format)
def valid_to(self, date_format):
return self.format_datetime(self._valid_to, date_format)
def within_range(self, valid_at):
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
@staticmethod
def format_datetime(dt, date_format):
if date_format in ('human_readable', 'openssh'):
if dt == _ALWAYS:
result = 'always'
elif dt == _FOREVER:
result = 'forever'
else:
result = dt.isoformat() if date_format == 'human_readable' else dt.strftime("%Y%m%d%H%M%S")
elif date_format == 'timestamp':
td = dt - _ALWAYS
result = int((td.microseconds + (td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6)
else:
raise ValueError("%s is not a valid format" % date_format)
return result
@staticmethod
def to_datetime(time_string_or_timestamp):
try:
if isinstance(time_string_or_timestamp, six.string_types):
result = OpensshCertificateTimeParameters._time_string_to_datetime(time_string_or_timestamp.strip())
elif isinstance(time_string_or_timestamp, (long, int)):
result = OpensshCertificateTimeParameters._timestamp_to_datetime(time_string_or_timestamp)
else:
raise ValueError(
"Value must be of type (str, unicode, int, long) not %s" % type(time_string_or_timestamp)
)
except ValueError:
raise
return result
@staticmethod
def _timestamp_to_datetime(timestamp):
if timestamp == 0x0:
result = _ALWAYS
elif timestamp == 0xFFFFFFFFFFFFFFFF:
result = _FOREVER
else:
try:
result = datetime.utcfromtimestamp(timestamp)
except OverflowError as e:
raise ValueError
return result
@staticmethod
def _time_string_to_datetime(time_string):
result = None
if time_string == 'always':
result = _ALWAYS
elif time_string == 'forever':
result = _FOREVER
elif is_relative_time_string(time_string):
result = convert_relative_to_datetime(time_string)
else:
for time_format in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
result = datetime.strptime(time_string, time_format)
except ValueError:
pass
if result is None:
raise ValueError
return result
class OpensshCertificateOption(object):
def __init__(self, option_type, name, data):
if option_type not in ('critical', 'extension'):
raise ValueError("type must be either 'critical' or 'extension'")
if not isinstance(name, six.string_types):
raise TypeError("name must be a string not %s" % type(name))
if not isinstance(data, six.string_types):
raise TypeError("data must be a string not %s" % type(data))
self._option_type = option_type
self._name = name.lower()
self._data = data
def __eq__(self, other):
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):
return hash((self._option_type, self._name, self._data))
def __ne__(self, other):
return not self == other
def __str__(self):
if self._data:
return "%s=%s" % (self._name, self._data)
return self._name
@property
def data(self):
return self._data
@property
def name(self):
return self._name
@property
def type(self):
return self._option_type
@classmethod
def from_string(cls, option_string):
if not isinstance(option_string, six.string_types):
raise ValueError("option_string must be a string not %s" % 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(
option_type=option_type or get_option_type(name.lower()),
name=name,
data=data
)
@six.add_metaclass(abc.ABCMeta)
class OpensshCertificateInfo:
"""Encapsulates all certificate information which is signed by a CA key"""
def __init__(self,
nonce=None,
serial=None,
cert_type=None,
key_id=None,
principals=None,
valid_after=None,
valid_before=None,
critical_options=None,
extensions=None,
reserved=None,
signing_key=None):
self.nonce = nonce
self.serial = serial
self._cert_type = 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 = None
@property
def cert_type(self):
if self._cert_type == _USER_TYPE:
return 'user'
elif self._cert_type == _HOST_TYPE:
return 'host'
else:
return ''
@cert_type.setter
def cert_type(self, cert_type):
if cert_type == 'user' or cert_type == _USER_TYPE:
self._cert_type = _USER_TYPE
elif cert_type == 'host' or cert_type == _HOST_TYPE:
self._cert_type = _HOST_TYPE
else:
raise ValueError("%s is not a valid certificate type" % cert_type)
def signing_key_fingerprint(self):
return fingerprint(self.signing_key)
@abc.abstractmethod
def public_key_fingerprint(self):
pass
@abc.abstractmethod
def parse_public_numbers(self, parser):
pass
class OpensshRSACertificateInfo(OpensshCertificateInfo):
def __init__(self, e=None, n=None, **kwargs):
super(OpensshRSACertificateInfo, self).__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):
if any([self.e is None, 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):
self.e = parser.mpint()
self.n = parser.mpint()
class OpensshDSACertificateInfo(OpensshCertificateInfo):
def __init__(self, p=None, q=None, g=None, y=None, **kwargs):
super(OpensshDSACertificateInfo, self).__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):
if any([self.p is None, self.q is None, self.g is None, 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):
self.p = parser.mpint()
self.q = parser.mpint()
self.g = parser.mpint()
self.y = parser.mpint()
class OpensshECDSACertificateInfo(OpensshCertificateInfo):
def __init__(self, curve=None, public_key=None, **kwargs):
super(OpensshECDSACertificateInfo, self).__init__(**kwargs)
self._curve = None
if curve is not None:
self.curve = curve
self.public_key = public_key
@property
def curve(self):
return self._curve
@curve.setter
def curve(self, curve):
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 %s" % (b','.join(list(_ECDSA_CURVE_IDENTIFIERS.values()))).decode('UTF-8')
)
# See https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
def public_key_fingerprint(self):
if any([self.curve is None, 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):
self.curve = parser.string()
self.public_key = parser.string()
class OpensshED25519CertificateInfo(OpensshCertificateInfo):
def __init__(self, pk=None, **kwargs):
super(OpensshED25519CertificateInfo, self).__init__(**kwargs)
self.type_string = _SSH_TYPE_STRINGS['ed25519'] + _CERT_SUFFIX_V01
self.pk = pk
def public_key_fingerprint(self):
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):
self.pk = parser.string()
# See https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?annotate=HEAD
class OpensshCertificate(object):
"""Encapsulates a formatted OpenSSH certificate including signature and signing key"""
def __init__(self, cert_info, signature):
self._cert_info = cert_info
self.signature = signature
@classmethod
def load(cls, path):
if not os.path.exists(path):
raise ValueError("%s is not a valid path." % path)
try:
with open(path, 'rb') as cert_file:
data = cert_file.read()
except (IOError, OSError) as e:
raise ValueError("%s cannot be opened for reading: %s" % (path, e))
try:
format_identifier, b64_cert = data.split(b' ')[:2]
cert = binascii.a2b_base64(b64_cert)
except (binascii.Error, ValueError):
raise ValueError("Certificate not in OpenSSH format")
for key_type, string in _SSH_TYPE_STRINGS.items():
if format_identifier == string + _CERT_SUFFIX_V01:
pub_key_type = key_type
break
else:
raise ValueError("Invalid certificate format identifier: %s" % format_identifier)
parser = OpensshParser(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("Invalid certificate data: %s" % e)
if parser.remaining_bytes():
raise ValueError(
"%s bytes of additional data was not parsed while loading %s" % (parser.remaining_bytes(), path)
)
return cls(
cert_info=cert_info,
signature=signature,
)
@property
def type_string(self):
return to_text(self._cert_info.type_string)
@property
def nonce(self):
return self._cert_info.nonce
@property
def public_key(self):
return to_text(self._cert_info.public_key_fingerprint())
@property
def serial(self):
return self._cert_info.serial
@property
def type(self):
return self._cert_info.cert_type
@property
def key_id(self):
return to_text(self._cert_info.key_id)
@property
def principals(self):
return [to_text(p) for p in self._cert_info.principals]
@property
def valid_after(self):
return self._cert_info.valid_after
@property
def valid_before(self):
return self._cert_info.valid_before
@property
def critical_options(self):
return [
OpensshCertificateOption('critical', to_text(n), to_text(d)) for n, d in self._cert_info.critical_options
]
@property
def extensions(self):
return [OpensshCertificateOption('extension', to_text(n), to_text(d)) for n, d in self._cert_info.extensions]
@property
def reserved(self):
return self._cert_info.reserved
@property
def signing_key(self):
return to_text(self._cert_info.signing_key_fingerprint())
@property
def signature_type(self):
signature_data = OpensshParser.signature_data(self.signature)
return to_text(signature_data['signature_type'])
@staticmethod
def _parse_cert_info(pub_key_type, parser):
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()
cert_info.cert_type = parser.uint32()
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):
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):
if any(d not in _DIRECTIVES for d in directives):
raise ValueError("directives must be one of %s" % ", ".join(_DIRECTIVES))
directive_to_option = {
'no-x11-forwarding': OpensshCertificateOption('extension', 'permit-x11-forwarding', ''),
'no-agent-forwarding': OpensshCertificateOption('extension', 'permit-agent-forwarding', ''),
'no-port-forwarding': OpensshCertificateOption('extension', 'permit-port-forwarding', ''),
'no-pty': OpensshCertificateOption('extension', 'permit-pty', ''),
'no-user-rc': OpensshCertificateOption('extension', 'permit-user-rc', ''),
}
if 'clear' in directives:
return []
else:
return list(set(default_options()) - set(directive_to_option[d] for d in directives))
def default_options():
return [OpensshCertificateOption('extension', name, '') for name in _EXTENSIONS]
def fingerprint(public_key):
"""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):
if key_type == 'rsa':
cert_info = OpensshRSACertificateInfo()
elif key_type == 'dsa':
cert_info = OpensshDSACertificateInfo()
elif key_type in ('ecdsa-nistp256', 'ecdsa-nistp384', 'ecdsa-nistp521'):
cert_info = OpensshECDSACertificateInfo()
elif key_type == 'ed25519':
cert_info = OpensshED25519CertificateInfo()
else:
raise ValueError("%s is not a valid key type" % key_type)
return cert_info
def get_option_type(name):
if name in _CRITICAL_OPTIONS:
result = 'critical'
elif name in _EXTENSIONS:
result = 'extension'
else:
raise ValueError("%s is not a valid option. " % name +
"Custom options must start with 'critical:' or 'extension:' to indicate type")
return result
def is_relative_time_string(time_string):
return time_string.startswith("+") or time_string.startswith("-")
def parse_option_list(option_list):
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)))

View File

@@ -0,0 +1,695 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
from base64 import b64encode, b64decode
from distutils.version import LooseVersion
from getpass import getuser
from socket import gethostname
try:
from cryptography import __version__ as CRYPTOGRAPHY_VERSION
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.backends.openssl import backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa, padding
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
if LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion("3.0"):
HAS_OPENSSH_PRIVATE_FORMAT = True
else:
HAS_OPENSSH_PRIVATE_FORMAT = False
HAS_OPENSSH_SUPPORT = True
_ALGORITHM_PARAMETERS = {
'rsa': {
'default_size': 2048,
'valid_sizes': range(1024, 16384),
'signer_params': {
'padding': padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
),
'algorithm': hashes.SHA256(),
},
},
'dsa': {
'default_size': 1024,
'valid_sizes': [1024],
'signer_params': {
'algorithm': hashes.SHA256(),
},
},
'ed25519': {
'default_size': 256,
'valid_sizes': [256],
'signer_params': {},
},
'ecdsa': {
'default_size': 256,
'valid_sizes': [256, 384, 521],
'signer_params': {
'signature_algorithm': ec.ECDSA(hashes.SHA256()),
},
'curves': {
256: ec.SECP256R1(),
384: ec.SECP384R1(),
521: ec.SECP521R1(),
}
}
}
except ImportError:
HAS_OPENSSH_PRIVATE_FORMAT = False
HAS_OPENSSH_SUPPORT = False
CRYPTOGRAPHY_VERSION = "0.0"
_ALGORITHM_PARAMETERS = {}
_TEXT_ENCODING = 'UTF-8'
class OpenSSHError(Exception):
pass
class InvalidAlgorithmError(OpenSSHError):
pass
class InvalidCommentError(OpenSSHError):
pass
class InvalidDataError(OpenSSHError):
pass
class InvalidPrivateKeyFileError(OpenSSHError):
pass
class InvalidPublicKeyFileError(OpenSSHError):
pass
class InvalidKeyFormatError(OpenSSHError):
pass
class InvalidKeySizeError(OpenSSHError):
pass
class InvalidKeyTypeError(OpenSSHError):
pass
class InvalidPassphraseError(OpenSSHError):
pass
class InvalidSignatureError(OpenSSHError):
pass
class AsymmetricKeypair(object):
"""Container for newly generated asymmetric key pairs or those loaded from existing files"""
@classmethod
def generate(cls, keytype='rsa', size=None, passphrase=None):
"""Returns an Asymmetric_Keypair object generated with the supplied parameters
or defaults to an unencrypted RSA-2048 key
:keytype: One of rsa, dsa, ecdsa, ed25519
:size: The key length for newly generated keys
:passphrase: Secret of type Bytes used to encrypt the private key being generated
"""
if keytype not in _ALGORITHM_PARAMETERS.keys():
raise InvalidKeyTypeError(
"%s is not a valid keytype. Valid keytypes are %s" % (
keytype, ", ".join(_ALGORITHM_PARAMETERS.keys())
)
)
if not size:
size = _ALGORITHM_PARAMETERS[keytype]['default_size']
else:
if size not in _ALGORITHM_PARAMETERS[keytype]['valid_sizes']:
raise InvalidKeySizeError(
"%s is not a valid key size for %s keys" % (size, keytype)
)
if passphrase:
encryption_algorithm = get_encryption_algorithm(passphrase)
else:
encryption_algorithm = serialization.NoEncryption()
if keytype == 'rsa':
privatekey = rsa.generate_private_key(
# Public exponent should always be 65537 to prevent issues
# if improper padding is used during signing
public_exponent=65537,
key_size=size,
backend=backend,
)
elif keytype == 'dsa':
privatekey = dsa.generate_private_key(
key_size=size,
backend=backend,
)
elif keytype == 'ed25519':
privatekey = Ed25519PrivateKey.generate()
elif keytype == 'ecdsa':
privatekey = ec.generate_private_key(
_ALGORITHM_PARAMETERS['ecdsa']['curves'][size],
backend=backend,
)
publickey = privatekey.public_key()
return cls(
keytype=keytype,
size=size,
privatekey=privatekey,
publickey=publickey,
encryption_algorithm=encryption_algorithm
)
@classmethod
def load(cls, path, passphrase=None, private_key_format='PEM', public_key_format='PEM', no_public_key=False):
"""Returns an Asymmetric_Keypair object loaded from the supplied file path
:path: A path to an existing private key to be loaded
:passphrase: Secret of type bytes used to decrypt the private key being loaded
:private_key_format: Format of private key to be loaded
:public_key_format: Format of public key to be loaded
:no_public_key: Set 'True' to only load a private key and automatically populate the matching public key
"""
if passphrase:
encryption_algorithm = get_encryption_algorithm(passphrase)
else:
encryption_algorithm = serialization.NoEncryption()
privatekey = load_privatekey(path, passphrase, private_key_format)
if no_public_key:
publickey = privatekey.public_key()
else:
publickey = load_publickey(path + '.pub', public_key_format)
# Ed25519 keys are always of size 256 and do not have a key_size attribute
if isinstance(privatekey, Ed25519PrivateKey):
size = _ALGORITHM_PARAMETERS['ed25519']['default_size']
else:
size = privatekey.key_size
if isinstance(privatekey, rsa.RSAPrivateKey):
keytype = 'rsa'
elif isinstance(privatekey, dsa.DSAPrivateKey):
keytype = 'dsa'
elif isinstance(privatekey, ec.EllipticCurvePrivateKey):
keytype = 'ecdsa'
elif isinstance(privatekey, Ed25519PrivateKey):
keytype = 'ed25519'
else:
raise InvalidKeyTypeError("Key type '%s' is not supported" % type(privatekey))
return cls(
keytype=keytype,
size=size,
privatekey=privatekey,
publickey=publickey,
encryption_algorithm=encryption_algorithm
)
def __init__(self, keytype, size, privatekey, publickey, encryption_algorithm):
"""
:keytype: One of rsa, dsa, ecdsa, ed25519
:size: The key length for the private key of this key pair
:privatekey: Private key object of this key pair
:publickey: Public key object of this key pair
:encryption_algorithm: Hashed secret used to encrypt the private key of this key pair
"""
self.__size = size
self.__keytype = keytype
self.__privatekey = privatekey
self.__publickey = publickey
self.__encryption_algorithm = encryption_algorithm
try:
self.verify(self.sign(b'message'), b'message')
except InvalidSignatureError:
raise InvalidPublicKeyFileError(
"The private key and public key of this keypair do not match"
)
def __eq__(self, other):
if not isinstance(other, AsymmetricKeypair):
return NotImplemented
return (compare_publickeys(self.public_key, other.public_key) and
compare_encryption_algorithms(self.encryption_algorithm, other.encryption_algorithm))
def __ne__(self, other):
return not self == other
@property
def private_key(self):
"""Returns the private key of this key pair"""
return self.__privatekey
@property
def public_key(self):
"""Returns the public key of this key pair"""
return self.__publickey
@property
def size(self):
"""Returns the size of the private key of this key pair"""
return self.__size
@property
def key_type(self):
"""Returns the key type of this key pair"""
return self.__keytype
@property
def encryption_algorithm(self):
"""Returns the key encryption algorithm of this key pair"""
return self.__encryption_algorithm
def sign(self, data):
"""Returns signature of data signed with the private key of this key pair
:data: byteslike data to sign
"""
try:
signature = self.__privatekey.sign(
data,
**_ALGORITHM_PARAMETERS[self.__keytype]['signer_params']
)
except TypeError as e:
raise InvalidDataError(e)
return signature
def verify(self, signature, data):
"""Verifies that the signature associated with the provided data was signed
by the private key of this key pair.
:signature: signature to verify
:data: byteslike data signed by the provided signature
"""
try:
return self.__publickey.verify(
signature,
data,
**_ALGORITHM_PARAMETERS[self.__keytype]['signer_params']
)
except InvalidSignature:
raise InvalidSignatureError
def update_passphrase(self, passphrase=None):
"""Updates the encryption algorithm of this key pair
:passphrase: Byte secret used to encrypt this key pair
"""
if passphrase:
self.__encryption_algorithm = get_encryption_algorithm(passphrase)
else:
self.__encryption_algorithm = serialization.NoEncryption()
class OpensshKeypair(object):
"""Container for OpenSSH encoded asymmetric key pairs"""
@classmethod
def generate(cls, keytype='rsa', size=None, passphrase=None, comment=None):
"""Returns an Openssh_Keypair object generated using the supplied parameters or defaults to a RSA-2048 key
:keytype: One of rsa, dsa, ecdsa, ed25519
:size: The key length for newly generated keys
:passphrase: Secret of type Bytes used to encrypt the newly generated private key
:comment: Comment for a newly generated OpenSSH public key
"""
if comment is None:
comment = "%s@%s" % (getuser(), gethostname())
asym_keypair = AsymmetricKeypair.generate(keytype, size, passphrase)
openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH')
openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment)
fingerprint = calculate_fingerprint(openssh_publickey)
return cls(
asym_keypair=asym_keypair,
openssh_privatekey=openssh_privatekey,
openssh_publickey=openssh_publickey,
fingerprint=fingerprint,
comment=comment,
)
@classmethod
def load(cls, path, passphrase=None, no_public_key=False):
"""Returns an Openssh_Keypair object loaded from the supplied file path
:path: A path to an existing private key to be loaded
:passphrase: Secret used to decrypt the private key being loaded
:no_public_key: Set 'True' to only load a private key and automatically populate the matching public key
"""
if no_public_key:
comment = ""
else:
comment = extract_comment(path + '.pub')
asym_keypair = AsymmetricKeypair.load(path, passphrase, 'SSH', 'SSH', no_public_key)
openssh_privatekey = cls.encode_openssh_privatekey(asym_keypair, 'SSH')
openssh_publickey = cls.encode_openssh_publickey(asym_keypair, comment)
fingerprint = calculate_fingerprint(openssh_publickey)
return cls(
asym_keypair=asym_keypair,
openssh_privatekey=openssh_privatekey,
openssh_publickey=openssh_publickey,
fingerprint=fingerprint,
comment=comment,
)
@staticmethod
def encode_openssh_privatekey(asym_keypair, key_format):
"""Returns an OpenSSH encoded private key for a given keypair
:asym_keypair: Asymmetric_Keypair from the private key is extracted
:key_format: Format of the encoded private key.
"""
if key_format == 'SSH':
# Default to PEM format if SSH not available
if not HAS_OPENSSH_PRIVATE_FORMAT:
privatekey_format = serialization.PrivateFormat.PKCS8
else:
privatekey_format = serialization.PrivateFormat.OpenSSH
elif key_format == 'PKCS8':
privatekey_format = serialization.PrivateFormat.PKCS8
elif key_format == 'PKCS1':
if asym_keypair.key_type == 'ed25519':
raise InvalidKeyFormatError("ed25519 keys cannot be represented in PKCS1 format")
privatekey_format = serialization.PrivateFormat.TraditionalOpenSSL
else:
raise InvalidKeyFormatError("The accepted private key formats are SSH, PKCS8, and PKCS1")
encoded_privatekey = asym_keypair.private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=privatekey_format,
encryption_algorithm=asym_keypair.encryption_algorithm
)
return encoded_privatekey
@staticmethod
def encode_openssh_publickey(asym_keypair, comment):
"""Returns an OpenSSH encoded public key for a given keypair
:asym_keypair: Asymmetric_Keypair from the public key is extracted
:comment: Comment to apply to the end of the returned OpenSSH encoded public key
"""
encoded_publickey = asym_keypair.public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
)
validate_comment(comment)
encoded_publickey += (" %s" % comment).encode(encoding=_TEXT_ENCODING) if comment else b''
return encoded_publickey
def __init__(self, asym_keypair, openssh_privatekey, openssh_publickey, fingerprint, comment):
"""
: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 public key
:fingerprint: The fingerprint of the OpenSSH encoded public key of this keypair
:comment: Comment applied to the OpenSSH public key of this keypair
"""
self.__asym_keypair = asym_keypair
self.__openssh_privatekey = openssh_privatekey
self.__openssh_publickey = openssh_publickey
self.__fingerprint = fingerprint
self.__comment = comment
def __eq__(self, other):
if not isinstance(other, OpensshKeypair):
return NotImplemented
return self.asymmetric_keypair == other.asymmetric_keypair and self.comment == other.comment
@property
def asymmetric_keypair(self):
"""Returns the underlying asymmetric key pair of this OpenSSH encoded key pair"""
return self.__asym_keypair
@property
def private_key(self):
"""Returns the OpenSSH formatted private key of this key pair"""
return self.__openssh_privatekey
@property
def public_key(self):
"""Returns the OpenSSH formatted public key of this key pair"""
return self.__openssh_publickey
@property
def size(self):
"""Returns the size of the private key of this key pair"""
return self.__asym_keypair.size
@property
def key_type(self):
"""Returns the key type of this key pair"""
return self.__asym_keypair.key_type
@property
def fingerprint(self):
"""Returns the fingerprint (SHA256 Hash) of the public key of this key pair"""
return self.__fingerprint
@property
def comment(self):
"""Returns the comment applied to the OpenSSH formatted public key of this key pair"""
return self.__comment
@comment.setter
def comment(self, comment):
"""Updates the comment applied to the OpenSSH formatted public key of this key pair
:comment: Text to update the OpenSSH public key comment
"""
validate_comment(comment)
self.__comment = comment
encoded_comment = (" %s" % self.__comment).encode(encoding=_TEXT_ENCODING) if self.__comment else b''
self.__openssh_publickey = b' '.join(self.__openssh_publickey.split(b' ', 2)[:2]) + encoded_comment
return self.__openssh_publickey
def update_passphrase(self, passphrase):
"""Updates the passphrase used to encrypt the private key of this keypair
:passphrase: Text secret used for encryption
"""
self.__asym_keypair.update_passphrase(passphrase)
self.__openssh_privatekey = OpensshKeypair.encode_openssh_privatekey(self.__asym_keypair, 'SSH')
def load_privatekey(path, passphrase, key_format):
privatekey_loaders = {
'PEM': serialization.load_pem_private_key,
'DER': serialization.load_der_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:
privatekey_loader = privatekey_loaders[key_format]
except KeyError:
raise InvalidKeyFormatError(
"%s is not a valid key format (%s)" % (
key_format,
','.join(privatekey_loaders.keys())
)
)
if not os.path.exists(path):
raise InvalidPrivateKeyFileError("No file was found at %s" % path)
try:
with open(path, 'rb') as f:
content = f.read()
privatekey = privatekey_loader(
data=content,
password=passphrase,
backend=backend,
)
except ValueError as e:
# Revert to PEM if key could not be loaded in SSH format
if key_format == 'SSH':
try:
privatekey = privatekey_loaders['PEM'](
data=content,
password=passphrase,
backend=backend,
)
except ValueError as e:
raise InvalidPrivateKeyFileError(e)
except TypeError as e:
raise InvalidPassphraseError(e)
except UnsupportedAlgorithm as e:
raise InvalidAlgorithmError(e)
else:
raise InvalidPrivateKeyFileError(e)
except TypeError as e:
raise InvalidPassphraseError(e)
except UnsupportedAlgorithm as e:
raise InvalidAlgorithmError(e)
return privatekey
def load_publickey(path, key_format):
publickey_loaders = {
'PEM': serialization.load_pem_public_key,
'DER': serialization.load_der_public_key,
'SSH': serialization.load_ssh_public_key,
}
try:
publickey_loader = publickey_loaders[key_format]
except KeyError:
raise InvalidKeyFormatError(
"%s is not a valid key format (%s)" % (
key_format,
','.join(publickey_loaders.keys())
)
)
if not os.path.exists(path):
raise InvalidPublicKeyFileError("No file was found at %s" % path)
try:
with open(path, 'rb') as f:
content = f.read()
publickey = publickey_loader(
data=content,
backend=backend,
)
except ValueError as e:
raise InvalidPublicKeyFileError(e)
except UnsupportedAlgorithm as e:
raise InvalidAlgorithmError(e)
return publickey
def compare_publickeys(pk1, pk2):
a = isinstance(pk1, Ed25519PublicKey)
b = isinstance(pk2, Ed25519PublicKey)
if a or b:
if not a or not b:
return False
a = pk1.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
b = pk2.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
return a == b
else:
return pk1.public_numbers() == pk2.public_numbers()
def compare_encryption_algorithms(ea1, ea2):
if isinstance(ea1, serialization.NoEncryption) and isinstance(ea2, serialization.NoEncryption):
return True
elif (isinstance(ea1, serialization.BestAvailableEncryption) and
isinstance(ea2, serialization.BestAvailableEncryption)):
return ea1.password == ea2.password
else:
return False
def get_encryption_algorithm(passphrase):
try:
return serialization.BestAvailableEncryption(passphrase)
except ValueError as e:
raise InvalidPassphraseError(e)
def validate_comment(comment):
if not hasattr(comment, 'encode'):
raise InvalidCommentError("%s cannot be encoded to text" % comment)
def extract_comment(path):
if not os.path.exists(path):
raise InvalidPublicKeyFileError("No file was found at %s" % path)
try:
with open(path, 'rb') as f:
fields = f.read().split(b' ', 2)
if len(fields) == 3:
comment = fields[2].decode(_TEXT_ENCODING)
else:
comment = ""
except (IOError, OSError) as e:
raise InvalidPublicKeyFileError(e)
return comment
def calculate_fingerprint(openssh_publickey):
digest = hashes.Hash(hashes.SHA256(), backend=backend)
decoded_pubkey = b64decode(openssh_publickey.split(b' ')[1])
digest.update(decoded_pubkey)
return 'SHA256:%s' % b64encode(digest.finalize()).decode(encoding=_TEXT_ENCODING).rstrip('=')

View File

@@ -0,0 +1,403 @@
# -*- coding: utf-8 -*-
#
# Copyright: (c) 2020, Doug Stanley <doug+ansible@technologixllc.com>
# Copyright: (c) 2021, Andrew Pantuso (@ajpantuso) <ajpantuso@gmail.com>
#
# Ansible is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import os
import re
from contextlib import contextmanager
from struct import Struct
from ansible.module_utils.six import PY3
# 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
#
# 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
if PY3:
long = int
# 0 (False) or 1 (True) encoded as a single byte
_BOOLEAN = Struct(b'?')
# Unsigned 8-bit integer in network-byte-order
_UBYTE = Struct(b'!B')
_UBYTE_MAX = 0xFF
# Unsigned 32-bit integer in network-byte-order
_UINT32 = Struct(b'!I')
# Unsigned 32-bit little endian integer
_UINT32_LE = Struct(b'<I')
_UINT32_MAX = 0xFFFFFFFF
# Unsigned 64-bit integer in network-byte-order
_UINT64 = Struct(b'!Q')
_UINT64_MAX = 0xFFFFFFFFFFFFFFFF
def any_in(sequence, *elements):
return any(e in sequence for e in elements)
def file_mode(path):
if not os.path.exists(path):
return 0o000
return os.stat(path).st_mode & 0o777
def parse_openssh_version(version_string):
"""Parse the version output of ssh -V and return version numbers that can be compared"""
parsed_result = re.match(
r"^.*openssh_(?P<version>[0-9.]+)(p?[0-9]+)[^0-9]*.*$", version_string.lower()
)
if parsed_result is not None:
version = parsed_result.group("version").strip()
else:
version = None
return version
@contextmanager
def secure_open(path, mode):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
try:
yield fd
finally:
os.close(fd)
def secure_write(path, mode, content):
with secure_open(path, mode) as fd:
os.write(fd, content)
# See https://datatracker.ietf.org/doc/html/rfc4251#section-5 for SSH data types
class OpensshParser(object):
"""Parser for OpenSSH encoded objects"""
BOOLEAN_OFFSET = 1
UINT32_OFFSET = 4
UINT64_OFFSET = 8
def __init__(self, data):
if not isinstance(data, (bytes, bytearray)):
raise TypeError("Data must be bytes-like not %s" % type(data))
self._data = memoryview(data) if PY3 else data
self._pos = 0
def boolean(self):
next_pos = self._check_position(self.BOOLEAN_OFFSET)
value = _BOOLEAN.unpack(self._data[self._pos:next_pos])[0]
self._pos = next_pos
return value
def uint32(self):
next_pos = self._check_position(self.UINT32_OFFSET)
value = _UINT32.unpack(self._data[self._pos:next_pos])[0]
self._pos = next_pos
return value
def uint64(self):
next_pos = self._check_position(self.UINT64_OFFSET)
value = _UINT64.unpack(self._data[self._pos:next_pos])[0]
self._pos = next_pos
return value
def string(self):
length = self.uint32()
next_pos = self._check_position(length)
value = self._data[self._pos:next_pos]
self._pos = next_pos
# Cast to bytes is required as a memoryview slice is itself a memoryview
return value if not PY3 else bytes(value)
def mpint(self):
return self._big_int(self.string(), "big", signed=True)
def name_list(self):
raw_string = self.string()
return raw_string.decode('ASCII').split(',')
# Convenience function, but not an official data type from SSH
def string_list(self):
result = []
raw_string = self.string()
if raw_string:
parser = OpensshParser(raw_string)
while parser.remaining_bytes():
result.append(parser.string())
return result
# Convenience function, but not an official data type from SSH
def option_list(self):
result = []
raw_string = self.string()
if raw_string:
parser = OpensshParser(raw_string)
while parser.remaining_bytes():
name = parser.string()
data = parser.string()
if data:
# data is doubly-encoded
data = OpensshParser(data).string()
result.append((name, data))
return result
def seek(self, offset):
self._pos = self._check_position(offset)
return self._pos
def remaining_bytes(self):
return len(self._data) - self._pos
def _check_position(self, offset):
if self._pos + offset > len(self._data):
raise ValueError("Insufficient data remaining at position: %s" % self._pos)
elif self._pos + offset < 0:
raise ValueError("Position cannot be less than zero.")
else:
return self._pos + offset
@classmethod
def signature_data(cls, signature_string):
signature_data = {}
parser = cls(signature_string)
signature_type = parser.string()
signature_blob = parser.string()
blob_parser = cls(signature_blob)
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/rfc8332#section-3
signature_data['s'] = cls._big_int(signature_blob, "big")
elif signature_type == b'ssh-dss':
# https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
signature_data['r'] = cls._big_int(signature_blob[:20], "big")
signature_data['s'] = cls._big_int(signature_blob[20:], "big")
elif signature_type in (b'ecdsa-sha2-nistp256', b'ecdsa-sha2-nistp384', b'ecdsa-sha2-nistp521'):
# https://datatracker.ietf.org/doc/html/rfc5656#section-3.1.2
signature_data['r'] = blob_parser.mpint()
signature_data['s'] = blob_parser.mpint()
elif signature_type == b'ssh-ed25519':
# https://datatracker.ietf.org/doc/html/rfc8032#section-5.1.2
signature_data['R'] = cls._big_int(signature_blob[:32], "little")
signature_data['S'] = cls._big_int(signature_blob[32:], "little")
else:
raise ValueError("%s is not a valid signature type" % signature_type)
signature_data['signature_type'] = signature_type
return signature_data
@classmethod
def _big_int(cls, raw_string, byte_order, signed=False):
if byte_order not in ("big", "little"):
raise ValueError("Byte_order must be one of (big, little) not %s" % byte_order)
if PY3:
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):
"""Writes SSH encoded values to a bytes-like buffer
.. warning::
This class is a private API and must not be exported outside of the openssh module_utils.
It is not to be used to construct Openssh objects, but rather as a utility to assist
in validating parsed material.
"""
def __init__(self, buffer=None):
if buffer is not None:
if not isinstance(buffer, (bytes, bytearray)):
raise TypeError("Buffer must be a bytes-like object not %s" % type(buffer))
else:
buffer = bytearray()
self._buff = buffer
def boolean(self, value):
if not isinstance(value, bool):
raise TypeError("Value must be of type bool not %s" % type(value))
self._buff.extend(_BOOLEAN.pack(value))
return self
def uint32(self, value):
if not isinstance(value, int):
raise TypeError("Value must be of type int not %s" % type(value))
if value < 0 or value > _UINT32_MAX:
raise ValueError("Value must be a positive integer less than %s" % _UINT32_MAX)
self._buff.extend(_UINT32.pack(value))
return self
def uint64(self, value):
if not isinstance(value, (long, int)):
raise TypeError("Value must be of type (long, int) not %s" % type(value))
if value < 0 or value > _UINT64_MAX:
raise ValueError("Value must be a positive integer less than %s" % _UINT64_MAX)
self._buff.extend(_UINT64.pack(value))
return self
def string(self, value):
if not isinstance(value, (bytes, bytearray)):
raise TypeError("Value must be bytes-like not %s" % type(value))
self.uint32(len(value))
self._buff.extend(value)
return self
def mpint(self, value):
if not isinstance(value, (int, long)):
raise TypeError("Value must be of type (long, int) not %s" % type(value))
self.string(self._int_to_mpint(value))
return self
def name_list(self, value):
if not isinstance(value, list):
raise TypeError("Value must be a list of byte strings not %s" % type(value))
try:
self.string(','.join(value).encode('ASCII'))
except UnicodeEncodeError as e:
raise ValueError("Name-list's must consist of US-ASCII characters: %s" % e)
return self
def string_list(self, value):
if not isinstance(value, list):
raise TypeError("Value must be a list of byte string not %s" % type(value))
writer = _OpensshWriter()
for s in value:
writer.string(s)
self.string(writer.bytes())
return self
def option_list(self, value):
if not isinstance(value, list) or (value and not isinstance(value[0], tuple)):
raise TypeError("Value must be a list of tuples")
writer = _OpensshWriter()
for name, data in value:
writer.string(name)
# SSH option data is encoded twice though this behavior is not documented
writer.string(_OpensshWriter().string(data).bytes() if data else bytes())
self.string(writer.bytes())
return self
@staticmethod
def _int_to_mpint(num):
if PY3:
byte_length = (num.bit_length() + 7) // 8
try:
result = num.to_bytes(byte_length, "big", signed=True)
# Handles values which require \x00 or \xFF to pad sign-bit
except OverflowError:
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):
return bytes(self._buff)

View File

@@ -88,6 +88,12 @@ options:
- "Mutually exclusive with C(new_account_key_src)." - "Mutually exclusive with C(new_account_key_src)."
- "Required if C(new_account_key_src) is not used and state is C(changed_key)." - "Required if C(new_account_key_src) is not used and state is C(changed_key)."
type: str type: str
new_account_key_passphrase:
description:
- Phassphrase to use to decode the new 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
external_account_binding: external_account_binding:
description: description:
- Allows to provide external account binding data during account creation. - Allows to provide external account binding data during account creation.
@@ -158,11 +164,19 @@ import base64
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import ( from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ModuleFailException, create_backend,
ACMEAccount,
handle_standard_module_arguments,
get_default_argspec, get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
KeyParsingError,
) )
@@ -175,6 +189,7 @@ def main():
contact=dict(type='list', elements='str', default=[]), contact=dict(type='list', elements='str', default=[]),
new_account_key_src=dict(type='path'), new_account_key_src=dict(type='path'),
new_account_key_content=dict(type='str', no_log=True), new_account_key_content=dict(type='str', no_log=True),
new_account_key_passphrase=dict(type='str', no_log=True),
external_account_binding=dict(type='dict', options=dict( external_account_binding=dict(type='dict', options=dict(
kid=dict(type='str', required=True), kid=dict(type='str', required=True),
alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']), alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']),
@@ -197,7 +212,7 @@ def main():
), ),
supports_check_mode=True, supports_check_mode=True,
) )
handle_standard_module_arguments(module, needs_acme_v2=True) backend = create_backend(module, True)
if module.params['external_account_binding']: if module.params['external_account_binding']:
# Make sure padding is there # Make sure padding is there
@@ -212,7 +227,8 @@ def main():
module.params['external_account_binding']['key'] = key module.params['external_account_binding']['key'] = key
try: try:
account = ACMEAccount(module) client = ACMEClient(module, backend)
account = ACMEAccount(client)
changed = False changed = False
state = module.params.get('state') state = module.params.get('state')
diff_before = {} diff_before = {}
@@ -221,7 +237,7 @@ def main():
created, account_data = account.setup_account(allow_creation=False) created, account_data = account.setup_account(allow_creation=False)
if account_data: if account_data:
diff_before = dict(account_data) diff_before = dict(account_data)
diff_before['public_account_key'] = account.key_data['jwk'] diff_before['public_account_key'] = client.account_key_data['jwk']
if created: if created:
raise AssertionError('Unwanted account creation') raise AssertionError('Unwanted account creation')
if account_data is not None: if account_data is not None:
@@ -231,9 +247,8 @@ def main():
payload = { payload = {
'status': 'deactivated' 'status': 'deactivated'
} }
result, info = account.send_signed_request(account.uri, payload) result, info = client.send_signed_request(
if info['status'] != 200: client.account_uri, payload, error_msg='Failed to deactivate account', expected_status_codes=[200])
raise ModuleFailException('Error deactivating account: {0} {1}'.format(info['status'], result))
changed = True changed = True
elif state == 'present': elif state == 'present':
allow_creation = module.params.get('allow_creation') allow_creation = module.params.get('allow_creation')
@@ -252,21 +267,23 @@ def main():
diff_before = {} diff_before = {}
else: else:
diff_before = dict(account_data) diff_before = dict(account_data)
diff_before['public_account_key'] = account.key_data['jwk'] diff_before['public_account_key'] = client.account_key_data['jwk']
updated = False updated = False
if not created: if not created:
updated, account_data = account.update_account(account_data, contact) updated, account_data = account.update_account(account_data, contact)
changed = created or updated changed = created or updated
diff_after = dict(account_data) diff_after = dict(account_data)
diff_after['public_account_key'] = account.key_data['jwk'] diff_after['public_account_key'] = client.account_key_data['jwk']
elif state == 'changed_key': elif state == 'changed_key':
# Parse new account key # Parse new account key
error, new_key_data = account.parse_key( try:
module.params.get('new_account_key_src'), new_key_data = client.parse_key(
module.params.get('new_account_key_content') module.params.get('new_account_key_src'),
) module.params.get('new_account_key_content'),
if error: passphrase=module.params.get('new_account_key_passphrase'),
raise ModuleFailException("error while parsing account key: %s" % error) )
except KeyParsingError as e:
raise ModuleFailException("Error while parsing new account key: {msg}".format(msg=e.msg))
# Verify that the account exists and has not been deactivated # Verify that the account exists and has not been deactivated
created, account_data = account.setup_account(allow_creation=False) created, account_data = account.setup_account(allow_creation=False)
if created: if created:
@@ -274,30 +291,29 @@ def main():
if account_data is None: if account_data is None:
raise ModuleFailException(msg='Account does not exist or is deactivated.') raise ModuleFailException(msg='Account does not exist or is deactivated.')
diff_before = dict(account_data) diff_before = dict(account_data)
diff_before['public_account_key'] = account.key_data['jwk'] diff_before['public_account_key'] = client.account_key_data['jwk']
# Now we can start the account key rollover # Now we can start the account key rollover
if not module.check_mode: if not module.check_mode:
# Compose inner signed message # Compose inner signed message
# https://tools.ietf.org/html/rfc8555#section-7.3.5 # https://tools.ietf.org/html/rfc8555#section-7.3.5
url = account.directory['keyChange'] url = client.directory['keyChange']
protected = { protected = {
"alg": new_key_data['alg'], "alg": new_key_data['alg'],
"jwk": new_key_data['jwk'], "jwk": new_key_data['jwk'],
"url": url, "url": url,
} }
payload = { payload = {
"account": account.uri, "account": client.account_uri,
"newKey": new_key_data['jwk'], # specified in draft 12 and older "newKey": new_key_data['jwk'], # specified in draft 12 and older
"oldKey": account.jwk, # specified in draft 13 and newer "oldKey": client.account_jwk, # specified in draft 13 and newer
} }
data = account.sign_request(protected, payload, new_key_data) data = client.sign_request(protected, payload, new_key_data)
# Send request and verify result # Send request and verify result
result, info = account.send_signed_request(url, data) result, info = client.send_signed_request(
if info['status'] != 200: url, data, error_msg='Failed to rollover account key', expected_status_codes=[200])
raise ModuleFailException('Error account key rollover: {0} {1}'.format(info['status'], result))
if module._diff: if module._diff:
account.key_data = new_key_data client.account_key_data = new_key_data
account.jws_header['alg'] = new_key_data['alg'] client.account_jws_header['alg'] = new_key_data['alg']
diff_after = account.get_account_data() diff_after = account.get_account_data()
elif module._diff: elif module._diff:
# Kind of fake diff_after # Kind of fake diff_after
@@ -306,7 +322,7 @@ def main():
changed = True changed = True
result = { result = {
'changed': changed, 'changed': changed,
'account_uri': account.uri, 'account_uri': client.account_uri,
} }
if module._diff: if module._diff:
result['diff'] = { result['diff'] = {

View File

@@ -213,23 +213,31 @@ order_uris:
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import ( from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ModuleFailException, create_backend,
ACMEAccount,
handle_standard_module_arguments,
process_links,
get_default_argspec, get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
process_links,
) )
def get_orders_list(module, account, orders_url): def get_orders_list(module, client, orders_url):
''' '''
Retrieves orders list (handles pagination). Retrieves orders list (handles pagination).
''' '''
orders = [] orders = []
while orders_url: while orders_url:
# Get part of orders list # Get part of orders list
res, info = account.get_request(orders_url, parse_json_result=True, fail_on_error=True) res, info = client.get_request(orders_url, parse_json_result=True, fail_on_error=True)
if not res.get('orders'): if not res.get('orders'):
if orders: if orders:
module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url)) module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url))
@@ -252,11 +260,11 @@ def get_orders_list(module, account, orders_url):
return orders return orders
def get_order(account, order_url): def get_order(client, order_url):
''' '''
Retrieve order data. Retrieve order data.
''' '''
return account.get_request(order_url, parse_json_result=True, fail_on_error=True)[0] return client.get_request(order_url, parse_json_result=True, fail_on_error=True)[0]
def main(): def main():
@@ -277,10 +285,11 @@ def main():
if module._name in ('acme_account_facts', 'community.crypto.acme_account_facts'): if module._name in ('acme_account_facts', 'community.crypto.acme_account_facts'):
module.deprecate("The 'acme_account_facts' module has been renamed to 'acme_account_info'", module.deprecate("The 'acme_account_facts' module has been renamed to 'acme_account_info'",
version='2.0.0', collection_name='community.crypto') version='2.0.0', collection_name='community.crypto')
handle_standard_module_arguments(module, needs_acme_v2=True) backend = create_backend(module, True)
try: try:
account = ACMEAccount(module) client = ACMEClient(module, backend)
account = ACMEAccount(client)
# Check whether account exists # Check whether account exists
created, account_data = account.setup_account( created, account_data = account.setup_account(
[], [],
@@ -291,18 +300,18 @@ def main():
raise AssertionError('Unwanted account creation') raise AssertionError('Unwanted account creation')
result = { result = {
'changed': False, 'changed': False,
'exists': account.uri is not None, 'exists': client.account_uri is not None,
'account_uri': account.uri, 'account_uri': client.account_uri,
} }
if account.uri is not None: if client.account_uri is not None:
# Make sure promised data is there # Make sure promised data is there
if 'contact' not in account_data: if 'contact' not in account_data:
account_data['contact'] = [] account_data['contact'] = []
account_data['public_account_key'] = account.key_data['jwk'] account_data['public_account_key'] = client.account_key_data['jwk']
result['account'] = account_data result['account'] = account_data
# Retrieve orders list # Retrieve orders list
if account_data.get('orders') and module.params['retrieve_orders'] != 'ignore': if account_data.get('orders') and module.params['retrieve_orders'] != 'ignore':
orders = get_orders_list(module, account, account_data['orders']) orders = get_orders_list(module, client, account_data['orders'])
result['order_uris'] = orders result['order_uris'] = orders
if module.params['retrieve_orders'] == 'url_list': if module.params['retrieve_orders'] == 'url_list':
module.deprecate( module.deprecate(
@@ -312,7 +321,7 @@ def main():
version='2.0.0', collection_name='community.crypto') version='2.0.0', collection_name='community.crypto')
result['orders'] = orders result['orders'] = orders
if module.params['retrieve_orders'] == 'object_list': if module.params['retrieve_orders'] == 'object_list':
result['orders'] = [get_order(account, order) for order in orders] result['orders'] = [get_order(client, order) for order in orders]
module.exit_json(**result) module.exit_json(**result)
except ModuleFailException as e: except ModuleFailException as e:
e.do_fail(module) e.do_fail(module)

View File

@@ -508,92 +508,57 @@ all_chains:
returned: always returned: always
''' '''
import base64
import binascii
import hashlib
import os import os
import re
import textwrap
import time
import traceback
from datetime import datetime from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
from ansible.module_utils._text import to_bytes, to_native create_backend,
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
parse_name_field,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_name_to_oid,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
split_pem_list,
)
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
write_file,
nopad_b64,
pem_to_der,
ACMEAccount,
HAS_CURRENT_CRYPTOGRAPHY,
cryptography_get_csr_identifiers,
openssl_get_csr_identifiers,
cryptography_get_cert_days,
handle_standard_module_arguments,
process_links,
get_default_argspec, get_default_argspec,
ACMEClient,
) )
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
try: from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
import cryptography ACMEAccount,
import cryptography.hazmat.backends )
import cryptography.x509
except ImportError: from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc() combine_identifier,
CRYPTOGRAPHY_FOUND = False split_identifier,
else: Authorization,
CRYPTOGRAPHY_FOUND = True )
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
retrieve_acme_v1_certificate,
CertificateChain,
Criterium,
)
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,
)
def get_cert_days(module, cert_file): class ACMECertificateClient(object):
'''
Return the days the certificate in cert_file remains valid and -1
if the file was not found. If cert_file contains more than one
certificate, only the first one will be considered.
'''
if HAS_CURRENT_CRYPTOGRAPHY:
return cryptography_get_cert_days(module, cert_file)
if not os.path.exists(cert_file):
return -1
openssl_bin = module.get_bin_path('openssl', True)
openssl_cert_cmd = [openssl_bin, "x509", "-in", cert_file, "-noout", "-text"]
dummy, out, dummy = module.run_command(openssl_cert_cmd, check_rc=True, encoding=None)
try:
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", out.decode('utf8')).group(1)
not_after = datetime.fromtimestamp(time.mktime(time.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')))
except AttributeError:
raise ModuleFailException("No 'Not after' date found in {0}".format(cert_file))
except ValueError:
raise ModuleFailException("Failed to parse 'Not after' date of {0}".format(cert_file))
now = datetime.utcnow()
return (not_after - now).days
class ACMEClient(object):
''' '''
ACME client class. Uses an ACME account object and a CSR to ACME client class. Uses an ACME account object and a CSR to
start and validate ACME challenges and download the respective start and validate ACME challenges and download the respective
certificates. certificates.
''' '''
def __init__(self, module): def __init__(self, module, backend):
self.module = module self.module = module
self.version = module.params['acme_version'] self.version = module.params['acme_version']
self.challenge = module.params['challenge'] self.challenge = module.params['challenge']
@@ -602,13 +567,22 @@ class ACMEClient(object):
self.dest = module.params.get('dest') self.dest = module.params.get('dest')
self.fullchain_dest = module.params.get('fullchain_dest') self.fullchain_dest = module.params.get('fullchain_dest')
self.chain_dest = module.params.get('chain_dest') self.chain_dest = module.params.get('chain_dest')
self.account = ACMEAccount(module) self.client = ACMEClient(module, backend)
self.directory = self.account.directory self.account = ACMEAccount(self.client)
self.directory = self.client.directory
self.data = module.params['data'] self.data = module.params['data']
self.authorizations = None self.authorizations = None
self.cert_days = -1 self.cert_days = -1
self.order = None
self.order_uri = self.data.get('order_uri') if self.data else None self.order_uri = self.data.get('order_uri') if self.data else None
self.finalize_uri = None self.all_chains = None
self.select_chain_matcher = []
if self.module.params['select_chain']:
for criterium_idx, criterium in enumerate(self.module.params['select_chain']):
self.select_chain_matcher.append(
self.client.backend.create_chain_matcher(
Criterium(criterium, index=criterium_idx)))
# Make sure account exists # Make sure account exists
modify_account = module.params['modify_account'] modify_account = module.params['modify_account']
@@ -639,286 +613,8 @@ class ACMEClient(object):
if self.csr is not None and not os.path.exists(self.csr): if self.csr is not None and not os.path.exists(self.csr):
raise ModuleFailException("CSR %s not found" % (self.csr)) raise ModuleFailException("CSR %s not found" % (self.csr))
self._openssl_bin = module.get_bin_path('openssl', True)
# Extract list of identifiers from CSR # Extract list of identifiers from CSR
self.identifiers = self._get_csr_identifiers() self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
def _get_csr_identifiers(self):
'''
Parse the CSR and return the list of requested identifiers
'''
if HAS_CURRENT_CRYPTOGRAPHY:
return cryptography_get_csr_identifiers(self.module, self.csr, self.csr_content)
else:
return openssl_get_csr_identifiers(self._openssl_bin, self.module, self.csr, self.csr_content)
def _add_or_update_auth(self, identifier_type, identifier, auth):
'''
Add or update the given authorization in the global authorizations list.
Return True if the auth was updated/added and False if no change was
necessary.
'''
if self.authorizations.get(identifier_type + ':' + identifier) == auth:
return False
self.authorizations[identifier_type + ':' + identifier] = auth
return True
def _new_authz_v1(self, 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 = {
"resource": "new-authz",
"identifier": {"type": identifier_type, "value": identifier},
}
result, info = self.account.send_signed_request(self.directory['new-authz'], new_authz)
if info['status'] not in [200, 201]:
raise ModuleFailException("Error requesting challenges: CODE: {0} RESULT: {1}".format(info['status'], result))
else:
result['uri'] = info['location']
return result
def _get_challenge_data(self, auth, identifier_type, identifier):
'''
Returns a dict with the data for all proposed (and supported) challenges
of the given authorization.
'''
data = {}
# no need to choose a specific challenge here as this module
# is not responsible for fulfilling the challenges. Calculate
# and return the required information for each challenge.
for challenge in auth['challenges']:
challenge_type = challenge['type']
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
keyauthorization = self.account.get_keyauthorization(token)
if challenge_type == 'http-01':
# https://tools.ietf.org/html/rfc8555#section-8.3
resource = '.well-known/acme-challenge/' + token
data[challenge_type] = {'resource': resource, 'resource_value': keyauthorization}
elif challenge_type == 'dns-01':
if identifier_type != 'dns':
continue
# https://tools.ietf.org/html/rfc8555#section-8.4
resource = '_acme-challenge'
value = nopad_b64(hashlib.sha256(to_bytes(keyauthorization)).digest())
record = (resource + identifier[1:]) if identifier.startswith('*.') else (resource + '.' + identifier)
data[challenge_type] = {'resource': resource, 'resource_value': value, 'record': record}
elif challenge_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 = compat_ipaddress.ip_address(identifier).reverse_pointer
if not resource.endswith('.'):
resource += '.'
else:
resource = identifier
value = base64.b64encode(hashlib.sha256(to_bytes(keyauthorization)).digest())
data[challenge_type] = {'resource': resource, 'resource_original': identifier_type + ':' + identifier, 'resource_value': value}
else:
continue
return data
def _fail_challenge(self, identifier_type, identifier, auth, error):
'''
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 auth['challenges']:
if challenge['status'] == 'invalid':
error_details += ' CHALLENGE: {0}'.format(challenge['type'])
if 'error' in challenge:
error_details += ' DETAILS: {0};'.format(challenge['error']['detail'])
else:
error_details += ';'
raise ModuleFailException("{0}: {1}".format(error.format(identifier_type + ':' + identifier), error_details))
def _validate_challenges(self, identifier_type, identifier, auth):
'''
Validate the authorization provided in the auth dict. Returns True
when the validation was successful and False when it was not.
'''
found_challenge = False
for challenge in auth['challenges']:
if self.challenge != challenge['type']:
continue
uri = challenge['uri'] if self.version == 1 else challenge['url']
found_challenge = True
challenge_response = {}
if self.version == 1:
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
keyauthorization = self.account.get_keyauthorization(token)
challenge_response["resource"] = "challenge"
challenge_response["keyAuthorization"] = keyauthorization
challenge_response["type"] = self.challenge
result, info = self.account.send_signed_request(uri, challenge_response)
if info['status'] not in [200, 202]:
raise ModuleFailException("Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result))
if not found_challenge:
raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}:{2}!".format(
self.challenge, identifier_type, identifier))
status = ''
while status not in ['valid', 'invalid', 'revoked']:
result, dummy = self.account.get_request(auth['uri'])
result['uri'] = auth['uri']
if self._add_or_update_auth(identifier_type, identifier, result):
self.changed = True
# 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"."
if self.version == 1 and 'status' not in result:
status = 'pending'
else:
status = result['status']
time.sleep(2)
if status == 'invalid':
self._fail_challenge(identifier_type, identifier, result, 'Authorization for {0} returned invalid')
return status == 'valid'
def _finalize_cert(self):
'''
Create a new certificate based on the csr.
Return the certificate object as dict
https://tools.ietf.org/html/rfc8555#section-7.4
'''
csr = pem_to_der(self.csr, self.csr_content)
new_cert = {
"csr": nopad_b64(csr),
}
result, info = self.account.send_signed_request(self.finalize_uri, new_cert)
if info['status'] not in [200]:
raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
status = result['status']
while status not in ['valid', 'invalid']:
time.sleep(2)
result, dummy = self.account.get_request(self.order_uri)
status = result['status']
if status != 'valid':
raise ModuleFailException("Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result))
return result['certificate']
def _der_to_pem(self, 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 _download_cert(self, url):
'''
Download and parse the certificate chain.
https://tools.ietf.org/html/rfc8555#section-7.4.2
'''
content, info = self.account.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}: {1} (headers: {2})".format(url, content, info))
cert = None
chain = []
# Parse data
certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True)
if certs:
cert = certs[0]
chain = certs[1:]
alternates = []
def f(link, relation):
if relation == 'up':
# Process link-up headers if there was no chain in reply
if not chain:
chain_result, chain_info = self.account.get_request(link, parse_json_result=False)
if chain_info['status'] in [200, 201]:
chain.append(self._der_to_pem(chain_result))
elif relation == 'alternate':
alternates.append(link)
process_links(info, f)
if cert is None:
raise ModuleFailException("Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info))
return {'cert': cert, 'chain': chain, 'alternates': alternates}
def _new_cert_v1(self):
'''
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
'''
csr = pem_to_der(self.csr, self.csr_content)
new_cert = {
"resource": "new-cert",
"csr": nopad_b64(csr),
}
result, info = self.account.send_signed_request(self.directory['new-cert'], new_cert)
chain = []
def f(link, relation):
if relation == 'up':
chain_result, chain_info = self.account.get_request(link, parse_json_result=False)
if chain_info['status'] in [200, 201]:
del chain[:]
chain.append(self._der_to_pem(chain_result))
process_links(info, f)
if info['status'] not in [200, 201]:
raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
else:
return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain}
def _new_order_v2(self):
'''
Start a new certificate order (ACME v2 protocol).
https://tools.ietf.org/html/rfc8555#section-7.4
'''
identifiers = []
for identifier_type, identifier in self.identifiers:
identifiers.append({
'type': identifier_type,
'value': identifier,
})
new_order = {
"identifiers": identifiers
}
result, info = self.account.send_signed_request(self.directory['newOrder'], new_order)
if info['status'] not in [201]:
raise ModuleFailException("Error new order: CODE: {0} RESULT: {1}".format(info['status'], result))
for auth_uri in result['authorizations']:
auth_data, dummy = self.account.get_request(auth_uri)
auth_data['uri'] = auth_uri
identifier_type = auth_data['identifier']['type']
identifier = auth_data['identifier']['value']
if auth_data.get('wildcard', False):
identifier = '*.{0}'.format(identifier)
self.authorizations[identifier_type + ':' + identifier] = auth_data
self.order_uri = info['location']
self.finalize_uri = result['finalize']
def is_first_step(self): def is_first_step(self):
''' '''
@@ -946,10 +642,13 @@ class ACMEClient(object):
if identifier_type != 'dns': if identifier_type != 'dns':
raise ModuleFailException('ACME v1 only supports DNS identifiers!') raise ModuleFailException('ACME v1 only supports DNS identifiers!')
for identifier_type, identifier in self.identifiers: for identifier_type, identifier in self.identifiers:
new_auth = self._new_authz_v1(identifier_type, identifier) authz = Authorization.create(self.client, identifier_type, identifier)
self._add_or_update_auth(identifier_type, identifier, new_auth) self.authorizations[authz.combined_identifier] = authz
else: else:
self._new_order_v2() self.order = Order.create(self.client, self.identifiers)
self.order_uri = self.order.url
self.order.load_authorizations(self.client)
self.authorizations.update(self.order.authorizations)
self.changed = True self.changed = True
def get_challenges_data(self, first_step): def get_challenges_data(self, first_step):
@@ -959,15 +658,14 @@ class ACMEClient(object):
''' '''
# Get general challenge data # Get general challenge data
data = {} data = {}
for type_identifier, auth in self.authorizations.items(): for type_identifier, authz in self.authorizations.items():
identifier_type, identifier = type_identifier.split(':', 1) identifier_type, identifier = split_identifier(type_identifier)
auth = self.authorizations[type_identifier]
# Skip valid authentications: their challenges are already valid # Skip valid authentications: their challenges are already valid
# and do not need to be returned # and do not need to be returned
if auth['status'] == 'valid': if authz.status == 'valid':
continue continue
# We drop the type from the key to preserve backwards compatibility # We drop the type from the key to preserve backwards compatibility
data[identifier] = self._get_challenge_data(auth, identifier_type, identifier) data[identifier] = authz.get_challenge_data(self.client)
if first_step and self.challenge not in data[identifier]: if first_step and self.challenge not in data[identifier]:
raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}!".format( raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}!".format(
self.challenge, type_identifier)) self.challenge, type_identifier))
@@ -994,91 +692,40 @@ class ACMEClient(object):
# For ACME v1, we attempt to create new authzs. Existing ones # For ACME v1, we attempt to create new authzs. Existing ones
# will be returned instead. # will be returned instead.
for identifier_type, identifier in self.identifiers: for identifier_type, identifier in self.identifiers:
new_auth = self._new_authz_v1(identifier_type, identifier) authz = Authorization.create(self.client, identifier_type, identifier)
self._add_or_update_auth(identifier_type, identifier, new_auth) self.authorizations[combine_identifier(identifier_type, identifier)] = authz
else: else:
# For ACME v2, we obtain the order object by fetching the # For ACME v2, we obtain the order object by fetching the
# order URI, and extract the information from there. # order URI, and extract the information from there.
result, info = self.account.get_request(self.order_uri) self.order = Order.from_url(self.client, self.order_uri)
self.order.load_authorizations(self.client)
self.authorizations.update(self.order.authorizations)
if not result: # Step 2: validate pending challenges
raise ModuleFailException("Cannot download order from {0}: {1} (headers: {2})".format(self.order_uri, result, info)) for type_identifier, authz in self.authorizations.items():
if authz.status == 'pending':
identifier_type, identifier = split_identifier(type_identifier)
authz.call_validate(self.client, self.challenge)
self.changed = True
if info['status'] not in [200]: def download_alternate_chains(self, cert):
raise ModuleFailException("Error on downloading order: CODE: {0} RESULT: {1}".format(info['status'], result)) alternate_chains = []
for alternate in cert.alternates:
for auth_uri in result['authorizations']:
auth_data, dummy = self.account.get_request(auth_uri)
auth_data['uri'] = auth_uri
identifier_type = auth_data['identifier']['type']
identifier = auth_data['identifier']['value']
if auth_data.get('wildcard', False):
identifier = '*.{0}'.format(identifier)
self.authorizations[identifier_type + ':' + identifier] = auth_data
self.finalize_uri = result['finalize']
# Step 2: validate challenges
for type_identifier, auth in self.authorizations.items():
if auth['status'] == 'pending':
identifier_type, identifier = type_identifier.split(':', 1)
self._validate_challenges(identifier_type, identifier, auth)
def _chain_matches(self, chain, criterium):
'''
Check whether an alternate chain matches the specified criterium.
'''
if criterium['test_certificates'] == 'last':
chain = chain[-1:]
elif criterium['test_certificates'] == 'first':
chain = chain[:1]
for cert in chain:
try: try:
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend()) alt_cert = CertificateChain.download(self.client, alternate)
matches = True except ModuleFailException as e:
if criterium['subject']: self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e))
for k, v in parse_name_field(criterium['subject']): continue
oid = cryptography_name_to_oid(k) alternate_chains.append(alt_cert)
value = to_native(v) return alternate_chains
found = False
for attribute in x509.subject: def find_matching_chain(self, chains):
if attribute.oid == oid and value == to_native(attribute.value): for criterium_idx, matcher in enumerate(self.select_chain_matcher):
found = True for chain in chains:
break if matcher.match(chain):
if not found: self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
matches = False return chain
break return None
if criterium['issuer']:
for k, v in parse_name_field(criterium['issuer']):
oid = cryptography_name_to_oid(k)
value = to_native(v)
found = False
for attribute in x509.issuer:
if attribute.oid == oid and value == to_native(attribute.value):
found = True
break
if not found:
matches = False
break
if criterium['subject_key_identifier']:
try:
ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier)
if criterium['subject_key_identifier'] != ext.value.digest:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if criterium['authority_key_identifier']:
try:
ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier)
if criterium['authority_key_identifier'] != ext.value.key_identifier:
matches = False
except cryptography.x509.ExtensionNotFound:
matches = False
if matches:
return True
except Exception as e:
self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e))
return False
def get_certificate(self): def get_certificate(self):
''' '''
@@ -1087,80 +734,46 @@ class ACMEClient(object):
with an error. with an error.
''' '''
for identifier_type, identifier in self.identifiers: for identifier_type, identifier in self.identifiers:
auth = self.authorizations.get(identifier_type + ':' + identifier) authz = self.authorizations.get(combine_identifier(identifier_type, identifier))
if auth is None: if authz is None:
raise ModuleFailException('Found no authorization information for "{0}"!'.format(identifier_type + ':' + identifier)) raise ModuleFailException('Found no authorization information for "{identifier}"!'.format(
if 'status' not in auth: identifier=combine_identifier(identifier_type, identifier)))
self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned no status') if authz.status != 'valid':
if auth['status'] != 'valid': authz.raise_error('Status is "{status}" and not "valid"'.format(status=authz.status), module=self.module)
self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned status ' + str(auth['status']))
if self.version == 1: if self.version == 1:
cert = self._new_cert_v1() cert = retrieve_acme_v1_certificate(self.client, pem_to_der(self.csr, self.csr_content))
else: else:
cert_uri = self._finalize_cert() self.order.finalize(self.client, pem_to_der(self.csr, self.csr_content))
cert = self._download_cert(cert_uri) cert = CertificateChain.download(self.client, self.order.certificate_uri)
if self.module.params['retrieve_all_alternates'] or self.module.params['select_chain']: if self.module.params['retrieve_all_alternates'] or self.select_chain_matcher:
# Retrieve alternate chains # Retrieve alternate chains
alternate_chains = [] alternate_chains = self.download_alternate_chains(cert)
for alternate in cert['alternates']:
try:
alt_cert = self._download_cert(alternate)
except ModuleFailException as e:
self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e))
continue
alternate_chains.append(alt_cert)
# Prepare return value for all alternate chains # Prepare return value for all alternate chains
if self.module.params['retrieve_all_alternates']: if self.module.params['retrieve_all_alternates']:
self.all_chains = [] self.all_chains = [cert.to_json()]
def _append_all_chains(cert_data):
self.all_chains.append(dict(
cert=cert_data['cert'].encode('utf8'),
chain=("\n".join(cert_data.get('chain', []))).encode('utf8'),
full_chain=(cert_data['cert'] + "\n".join(cert_data.get('chain', []))).encode('utf8'),
))
_append_all_chains(cert)
for alt_chain in alternate_chains: for alt_chain in alternate_chains:
_append_all_chains(alt_chain) self.all_chains.append(alt_chain.to_json())
# Try to select alternate chain depending on criteria # Try to select alternate chain depending on criteria
if self.module.params['select_chain']: if self.select_chain_matcher:
matching_chain = None matching_chain = self.find_matching_chain([cert] + alternate_chains)
all_chains = [cert] + alternate_chains
for criterium_idx, criterium in enumerate(self.module.params['select_chain']):
for v in ('subject_key_identifier', 'authority_key_identifier'):
if criterium[v]:
try:
criterium[v] = binascii.unhexlify(criterium[v].replace(':', ''))
except Exception:
self.module.warn('Criterium {0} in select_chain has invalid {1} value. '
'Ignoring criterium.'.format(criterium_idx, v))
continue
for alt_chain in all_chains:
if self._chain_matches(alt_chain.get('chain', []), criterium):
self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
matching_chain = alt_chain
break
if matching_chain:
break
if matching_chain: if matching_chain:
cert.update(matching_chain) cert = matching_chain
else: else:
self.module.debug('Found no matching alternative chain') self.module.debug('Found no matching alternative chain')
if cert['cert'] is not None: if cert.cert is not None:
pem_cert = cert['cert'] pem_cert = cert.cert
chain = list(cert.get('chain', [])) chain = cert.chain
if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')): if self.dest and write_file(self.module, self.dest, pem_cert.encode('utf8')):
self.cert_days = get_cert_days(self.module, self.dest) self.cert_days = self.client.backend.get_cert_days(self.dest)
self.changed = True self.changed = True
if self.fullchain_dest and write_file(self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')): if self.fullchain_dest and write_file(self.module, self.fullchain_dest, (pem_cert + "\n".join(chain)).encode('utf8')):
self.cert_days = get_cert_days(self.module, self.fullchain_dest) self.cert_days = self.client.backend.get_cert_days(self.fullchain_dest)
self.changed = True self.changed = True
if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')): if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')):
@@ -1172,25 +785,14 @@ class ACMEClient(object):
https://community.letsencrypt.org/t/authorization-deactivation/19860/2 https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2 https://tools.ietf.org/html/rfc8555#section-7.5.2
''' '''
authz_deactivate = { for authz in self.authorizations.values():
'status': 'deactivated' try:
} authz.deactivate(self.client)
if self.version == 1: except Exception:
authz_deactivate['resource'] = 'authz' # ignore errors
if self.authorizations: pass
for identifier_type, identifier in self.identifiers: if authz.status != 'deactivated':
auth = self.authorizations.get(identifier_type + ':' + identifier) self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url))
if auth is None or auth.get('status') != 'valid':
continue
try:
result, info = self.account.send_signed_request(auth['uri'], authz_deactivate)
if 200 <= info['status'] < 300 and result.get('status') == 'deactivated':
auth['status'] = 'deactivated'
except Exception as dummy:
# Ignore errors on deactivating authzs
pass
if auth.get('status') != 'deactivated':
self.module.warn(warning='Could not deactivate authz object {0}.'.format(auth['uri']))
def main(): def main():
@@ -1232,18 +834,13 @@ def main():
), ),
supports_check_mode=True, supports_check_mode=True,
) )
backend = handle_standard_module_arguments(module) backend = create_backend(module, False)
if module.params['select_chain']:
if backend != 'cryptography':
module.fail_json(msg="The 'select_chain' can only be used with the 'cryptography' backend.")
elif not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography'))
try: try:
if module.params.get('dest'): if module.params.get('dest'):
cert_days = get_cert_days(module, module.params['dest']) cert_days = backend.get_cert_days(module.params['dest'])
else: else:
cert_days = get_cert_days(module, module.params['fullchain_dest']) cert_days = backend.get_cert_days(module.params['fullchain_dest'])
if module.params['force'] or cert_days < module.params['remaining_days']: if module.params['force'] or cert_days < module.params['remaining_days']:
# If checkmode is active, base the changed state solely on the status # If checkmode is active, base the changed state solely on the status
@@ -1253,7 +850,7 @@ def main():
if module.check_mode: if module.check_mode:
module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days) module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
else: else:
client = ACMEClient(module) client = ACMECertificateClient(module, backend)
client.cert_days = cert_days client.cert_days = cert_days
other = dict() other = dict()
is_first_step = client.is_first_step() is_first_step = client.is_first_step()
@@ -1265,7 +862,7 @@ def main():
try: try:
client.finish_challenges() client.finish_challenges()
client.get_certificate() client.get_certificate()
if module.params['retrieve_all_alternates']: if client.all_chains is not None:
other['all_chains'] = client.all_chains other['all_chains'] = client.all_chains
finally: finally:
if module.params['deactivate_authzs']: if module.params['deactivate_authzs']:
@@ -1274,13 +871,13 @@ def main():
auths = dict() auths = dict()
for k, v in client.authorizations.items(): for k, v in client.authorizations.items():
# Remove "type:" from key # Remove "type:" from key
auths[k.split(':', 1)[1]] = v auths[split_identifier(k)[1]] = v.to_json()
module.exit_json( module.exit_json(
changed=client.changed, changed=client.changed,
authorizations=auths, authorizations=auths,
finalize_uri=client.finalize_uri, finalize_uri=client.order.finalize_uri if client.order else None,
order_uri=client.order_uri, order_uri=client.order_uri,
account_uri=client.account.uri, account_uri=client.client.account_uri,
challenge_data=data, challenge_data=data,
challenge_data_dns=data_dns, challenge_data_dns=data_dns,
cert_days=client.cert_days, cert_days=client.cert_days,

View File

@@ -54,7 +54,6 @@ options:
private keys in PEM format can be used as well." private keys in PEM format can be used as well."
- "Mutually exclusive with C(account_key_content)." - "Mutually exclusive with C(account_key_content)."
- "Required if C(account_key_content) is not used." - "Required if C(account_key_content) is not used."
type: path
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."
@@ -69,7 +68,6 @@ options:
temporary file. It can still happen that it is written to disk by 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 Ansible in the process of moving the module with its argument to
the node where it is executed." the node where it is executed."
type: str
private_key_src: private_key_src:
description: description:
- "Path to the certificate's private key." - "Path to the certificate's private key."
@@ -91,6 +89,12 @@ options:
Ansible in the process of moving the module with its argument to Ansible in the process of moving the module with its argument to
the node where it is executed." the node where it is executed."
type: str type: str
private_key_passphrase:
description:
- Phassphrase to use to decode the certificate's private 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
revoke_reason: revoke_reason:
description: description:
- "One of the revocation reasonCodes defined in - "One of the revocation reasonCodes defined in
@@ -119,13 +123,25 @@ RETURN = '''#'''
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import ( from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ModuleFailException, create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount, ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
ModuleFailException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64, nopad_b64,
pem_to_der, pem_to_der,
handle_standard_module_arguments,
get_default_argspec,
) )
@@ -134,6 +150,7 @@ def main():
argument_spec.update(dict( argument_spec.update(dict(
private_key_src=dict(type='path'), private_key_src=dict(type='path'),
private_key_content=dict(type='str', no_log=True), private_key_content=dict(type='str', no_log=True),
private_key_passphrase=dict(type='str', no_log=True),
certificate=dict(type='path', required=True), certificate=dict(type='path', required=True),
revoke_reason=dict(type='int'), revoke_reason=dict(type='int'),
)) ))
@@ -147,10 +164,11 @@ def main():
), ),
supports_check_mode=False, supports_check_mode=False,
) )
handle_standard_module_arguments(module) backend = create_backend(module, False)
try: try:
account = ACMEAccount(module) client = ACMEClient(module, backend)
account = ACMEAccount(client)
# Load certificate # Load certificate
certificate = pem_to_der(module.params.get('certificate')) certificate = pem_to_der(module.params.get('certificate'))
certificate = nopad_b64(certificate) certificate = nopad_b64(certificate)
@@ -162,25 +180,28 @@ def main():
payload['reason'] = module.params.get('revoke_reason') payload['reason'] = module.params.get('revoke_reason')
# Determine endpoint # Determine endpoint
if module.params.get('acme_version') == 1: if module.params.get('acme_version') == 1:
endpoint = account.directory['revoke-cert'] endpoint = client.directory['revoke-cert']
payload['resource'] = 'revoke-cert' payload['resource'] = 'revoke-cert'
else: else:
endpoint = account.directory['revokeCert'] endpoint = client.directory['revokeCert']
# Get hold of private key (if available) and make sure it comes from disk # Get hold of private key (if available) and make sure it comes from disk
private_key = module.params.get('private_key_src') private_key = module.params.get('private_key_src')
private_key_content = module.params.get('private_key_content') private_key_content = module.params.get('private_key_content')
# Revoke certificate # Revoke certificate
if private_key or private_key_content: if private_key or private_key_content:
passphrase = module.params['private_key_passphrase']
# Step 1: load and parse private key # Step 1: load and parse private key
error, private_key_data = account.parse_key(private_key, private_key_content) try:
if error: private_key_data = client.parse_key(private_key, private_key_content, passphrase=passphrase)
raise ModuleFailException("error while parsing private key: %s" % error) except KeyParsingError as e:
raise ModuleFailException("Error while parsing private key: {msg}".format(msg=e.msg))
# Step 2: sign revokation request with private key # Step 2: sign revokation request with private key
jws_header = { jws_header = {
"alg": private_key_data['alg'], "alg": private_key_data['alg'],
"jwk": private_key_data['jwk'], "jwk": private_key_data['jwk'],
} }
result, info = account.send_signed_request(endpoint, payload, key_data=private_key_data, jws_header=jws_header) result, info = client.send_signed_request(
endpoint, payload, key_data=private_key_data, jws_header=jws_header, fail_on_error=False)
else: else:
# Step 1: get hold of account URI # Step 1: get hold of account URI
created, account_data = account.setup_account(allow_creation=False) created, account_data = account.setup_account(allow_creation=False)
@@ -189,7 +210,7 @@ def main():
if account_data is None: if account_data is None:
raise ModuleFailException(msg='Account does not exist or is deactivated.') raise ModuleFailException(msg='Account does not exist or is deactivated.')
# Step 2: sign revokation request with account key # Step 2: sign revokation request with account key
result, info = account.send_signed_request(endpoint, payload) result, info = client.send_signed_request(endpoint, payload, fail_on_error=False)
if info['status'] != 200: if info['status'] != 200:
already_revoked = False already_revoked = False
# Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6) # Standardized error from draft 14 on (https://tools.ietf.org/html/rfc8555#section-7.6)
@@ -208,7 +229,7 @@ def main():
# but successfully terminate while indicating no change # but successfully terminate while indicating no change
if already_revoked: if already_revoked:
module.exit_json(changed=False) module.exit_json(changed=False)
raise ModuleFailException('Error revoking certificate: {0} {1}'.format(info['status'], result)) raise ACMEProtocolException(module, 'Failed to revoke certificate', info=info, content_json=result)
module.exit_json(changed=True) module.exit_json(changed=True)
except ModuleFailException as e: except ModuleFailException as e:
e.do_fail(module) e.do_fail(module)

View File

@@ -52,6 +52,11 @@ options:
- "Content of the private key to use for this challenge certificate." - "Content of the private key to use for this challenge certificate."
- "Mutually exclusive with C(private_key_src)." - "Mutually exclusive with C(private_key_src)."
type: str type: str
private_key_passphrase:
description:
- Phassphrase to use to decode the private key.
type: str
version_added: 1.6.0
notes: notes:
- Does not support C(check_mode). - Does not support C(check_mode).
''' '''
@@ -138,10 +143,11 @@ import sys
import traceback import traceback
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_bytes, to_text from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.acme import ( from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
ModuleFailException,
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
read_file, read_file,
) )
@@ -186,6 +192,7 @@ def main():
challenge_data=dict(type='dict', required=True), challenge_data=dict(type='dict', required=True),
private_key_src=dict(type='path'), private_key_src=dict(type='path'),
private_key_content=dict(type='str', no_log=True), private_key_content=dict(type='str', no_log=True),
private_key_passphrase=dict(type='str', no_log=True),
), ),
required_one_of=( required_one_of=(
['private_key_src', 'private_key_content'], ['private_key_src', 'private_key_content'],
@@ -195,7 +202,10 @@ def main():
), ),
) )
if not HAS_CRYPTOGRAPHY: if not HAS_CRYPTOGRAPHY:
module.fail_json(msg=missing_required_lib('cryptography >= 1.3'), exception=CRYPTOGRAPHY_IMP_ERR) # Some callbacks die when exception is provided with value None
if CRYPTOGRAPHY_IMP_ERR:
module.fail_json(msg=missing_required_lib('cryptography >= 1.3'), exception=CRYPTOGRAPHY_IMP_ERR)
module.fail_json(msg=missing_required_lib('cryptography >= 1.3'))
try: try:
# Get parameters # Get parameters
@@ -204,12 +214,16 @@ def main():
# Get hold of private key # Get hold of private key
private_key_content = module.params.get('private_key_content') private_key_content = module.params.get('private_key_content')
private_key_passphrase = module.params.get('private_key_passphrase')
if private_key_content is None: if private_key_content is None:
private_key_content = read_file(module.params['private_key_src']) private_key_content = read_file(module.params['private_key_src'])
else: else:
private_key_content = to_bytes(private_key_content) private_key_content = to_bytes(private_key_content)
try: try:
private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key(private_key_content, password=None, backend=_cryptography_backend) private_key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
private_key_content,
password=to_bytes(private_key_passphrase) if private_key_passphrase is not None else None,
backend=_cryptography_backend)
except Exception as e: except Exception as e:
raise ModuleFailException('Error while loading private key: {0}'.format(e)) raise ModuleFailException('Error while loading private key: {0}'.format(e))

View File

@@ -240,16 +240,18 @@ output_json:
- ... - ...
''' '''
import json
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native, to_bytes, to_text from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.acme import ( from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ModuleFailException, create_backend,
ACMEAccount,
handle_standard_module_arguments,
get_default_argspec, get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ACMEProtocolException,
ModuleFailException,
) )
@@ -273,25 +275,26 @@ def main():
['method', 'post', ['account_key_src', 'account_key_content'], True], ['method', 'post', ['account_key_src', 'account_key_content'], True],
), ),
) )
handle_standard_module_arguments(module) backend = create_backend(module, False)
result = dict() result = dict()
changed = False changed = False
try: try:
# Get hold of ACMEAccount object (includes directory) # Get hold of ACMEClient and ACMEAccount objects (includes directory)
account = ACMEAccount(module) client = ACMEClient(module, backend)
method = module.params['method'] method = module.params['method']
result['directory'] = account.directory.directory result['directory'] = client.directory.directory
# Do we have to do more requests? # Do we have to do more requests?
if method != 'directory-only': if method != 'directory-only':
url = module.params['url'] url = module.params['url']
fail_on_acme_error = module.params['fail_on_acme_error'] fail_on_acme_error = module.params['fail_on_acme_error']
# Do request # Do request
if method == 'get': if method == 'get':
data, info = account.get_request(url, parse_json_result=False, fail_on_error=False) data, info = client.get_request(url, parse_json_result=False, fail_on_error=False)
elif method == 'post': elif method == 'post':
changed = True # only POSTs can change changed = True # only POSTs can change
data, info = account.send_signed_request(url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False) data, info = client.send_signed_request(
url, to_bytes(module.params['content']), parse_json_result=False, encode_payload=False, fail_on_error=False)
# Update results # Update results
result.update(dict( result.update(dict(
headers=info, headers=info,
@@ -299,13 +302,12 @@ def main():
)) ))
# See if we can parse the result as JSON # See if we can parse the result as JSON
try: try:
# to_text() is needed only for Python 3.5 (and potentially 3.0 to 3.4 as well) result['output_json'] = module.from_json(to_text(data))
result['output_json'] = json.loads(to_text(data))
except Exception as dummy: except Exception as dummy:
pass pass
# Fail if error was returned # Fail if error was returned
if fail_on_acme_error and info['status'] >= 400: if fail_on_acme_error and info['status'] >= 400:
raise ModuleFailException("ACME request failed: CODE: {0} RESULT: {1}".format(info['status'], data)) raise ACMEProtocolException(module, info=info, content=data)
# Done! # Done!
module.exit_json(changed=changed, **result) module.exit_json(changed=changed, **result)
except ModuleFailException as e: except ModuleFailException as e:

View File

@@ -122,7 +122,7 @@ import os
import traceback import traceback
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_bytes from ansible.module_utils.common.text.converters import to_bytes
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,

View File

@@ -522,7 +522,7 @@ import traceback
from distutils.version import LooseVersion from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.io import ( from ansible_collections.community.crypto.plugins.module_utils.io import (
write_file, write_file,

View File

@@ -202,7 +202,7 @@ import datetime
import time import time
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.ecs.api import ( from ansible_collections.community.crypto.plugins.module_utils.ecs.api import (
ecs_client_argument_spec, ecs_client_argument_spec,

View File

@@ -50,6 +50,14 @@ options:
- Proxy port used when get a certificate. - Proxy port used when get a certificate.
type: int type: int
default: 8080 default: 8080
starttls:
description:
- Requests a secure connection for protocols which require clients to initiate encryption.
- Only available for C(mysql) currently.
type: str
choices:
- mysql
version_added: 1.9.0
timeout: timeout:
description: description:
- The timeout in seconds - The timeout in seconds
@@ -165,7 +173,7 @@ from socket import create_connection, setdefaulttimeout, socket
from ssl import get_server_certificate, DER_cert_to_PEM_cert, CERT_NONE, CERT_REQUIRED from ssl import get_server_certificate, DER_cert_to_PEM_cert, CERT_NONE, CERT_REQUIRED
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_bytes from ansible.module_utils.common.text.converters import to_bytes
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_oid_to_name, cryptography_oid_to_name,
@@ -209,6 +217,20 @@ else:
CRYPTOGRAPHY_FOUND = True CRYPTOGRAPHY_FOUND = True
def send_starttls_packet(sock, server_type):
if server_type == 'mysql':
ssl_request_packet = (
b'\x20\x00\x00\x01\x85\xae\x7f\x00' +
b'\x00\x00\x00\x01\x21\x00\x00\x00' +
b'\x00\x00\x00\x00\x00\x00\x00\x00' +
b'\x00\x00\x00\x00\x00\x00\x00\x00' +
b'\x00\x00\x00\x00'
)
sock.recv(8192) # discard initial handshake from server for this naive implementation
sock.send(ssl_request_packet)
def main(): def main():
module = AnsibleModule( module = AnsibleModule(
argument_spec=dict( argument_spec=dict(
@@ -220,6 +242,7 @@ def main():
server_name=dict(type='str'), server_name=dict(type='str'),
timeout=dict(type='int', default=10), timeout=dict(type='int', default=10),
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'), select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
starttls=dict(type='str', choices=['mysql']),
), ),
) )
@@ -230,6 +253,7 @@ def main():
proxy_port = module.params.get('proxy_port') proxy_port = module.params.get('proxy_port')
timeout = module.params.get('timeout') timeout = module.params.get('timeout')
server_name = module.params.get('server_name') server_name = module.params.get('server_name')
start_tls_server_type = module.params.get('starttls')
backend = module.params.get('select_crypto_backend') backend = module.params.get('select_crypto_backend')
if backend == 'auto': if backend == 'auto':
@@ -305,6 +329,9 @@ def main():
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = CERT_NONE ctx.verify_mode = CERT_NONE
if start_tls_server_type is not None:
send_starttls_packet(sock, start_tls_server_type)
cert = ctx.wrap_socket(sock, server_hostname=server_name or host).getpeercert(True) cert = ctx.wrap_socket(sock, server_hostname=server_name or host).getpeercert(True)
cert = DER_cert_to_PEM_cert(cert) cert = DER_cert_to_PEM_cert(cert)
except Exception as e: except Exception as e:

View File

@@ -20,7 +20,8 @@ requirements:
options: options:
state: state:
description: description:
- Whether the host or user certificate should exist or not, taking action if the state is different from what is stated. - Whether the host or user certificate should exist or not, taking action if the state is different
from what is stated.
type: str type: str
default: "present" default: "present"
choices: [ 'present', 'absent' ] choices: [ 'present', 'absent' ]
@@ -33,6 +34,7 @@ options:
force: force:
description: description:
- Should the certificate be regenerated even if it already exists and is valid. - Should the certificate be regenerated even if it already exists and is valid.
- Equivalent to I(regenerate=always).
type: bool type: bool
default: false default: false
path: path:
@@ -40,6 +42,46 @@ options:
- Path of the file containing the certificate. - Path of the file containing the certificate.
type: path type: path
required: true required: true
regenerate:
description:
- When C(never) the task will fail if a certificate already exists at I(path) and is unreadable
otherwise a new certificate will only be generated if there is no existing certificate.
- When C(fail) the task will fail if a certificate already exists at I(path) and does not
match the module's options.
- When C(partial_idempotence) an existing certificate will be regenerated based on
I(serial), I(signature_algorithm), I(type), I(valid_from), I(valid_to), I(valid_at), and I(principals).
- When C(full_idempotence) I(identifier), I(options), I(public_key), and I(signing_key)
are also considered when compared against an existing certificate.
- C(always) is equivalent to I(force=true).
type: str
choices:
- never
- fail
- partial_idempotence
- full_idempotence
- always
default: partial_idempotence
version_added: 1.8.0
signature_algorithm:
description:
- As of OpenSSH 8.2 the SHA-1 signature algorithm for RSA keys has been disabled and C(ssh) will refuse
host certificates signed with the SHA-1 algorithm. OpenSSH 8.1 made C(rsa-sha2-512) the default algorithm
when acting as a CA and signing certificates with a RSA key. However, for OpenSSH versions less than 8.1
the SHA-2 signature algorithms, C(rsa-sha2-256) or C(rsa-sha2-512), must be specified using this option
if compatibility with newer C(ssh) clients is required. Conversely if hosts using OpenSSH version 8.2
or greater must remain compatible with C(ssh) clients using OpenSSH less than 7.2, then C(ssh-rsa)
can be used when generating host certificates (a corresponding change to the sshd_config to add C(ssh-rsa)
to the C(CASignatureAlgorithms) keyword is also required).
- Using any value for this option with a non-RSA I(signing_key) will cause this module to fail.
- "Note: OpenSSH versions prior to 7.2 do not support SHA-2 signature algorithms for RSA keys and OpenSSH
versions prior to 7.3 do not support SHA-2 signature algorithms for certificates."
- See U(https://www.openssh.com/txt/release-8.2) for more information.
type: str
choices:
- ssh-rsa
- rsa-sha2-256
- rsa-sha2-512
version_added: 1.10.0
signing_key: signing_key:
description: description:
- The path to the private openssh key that is used for signing the public key in order to generate the certificate. - The path to the private openssh key that is used for signing the public key in order to generate the certificate.
@@ -215,421 +257,292 @@ info:
''' '''
import errno
import os import os
import re
import tempfile
from datetime import datetime
from datetime import MINYEAR, MAXYEAR
from distutils.version import LooseVersion from distutils.version import LooseVersion
from shutil import copy2, rmtree
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native, to_text
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import convert_relative_to_datetime from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.common import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.openssh import parse_openssh_version KeygenCommand,
OpensshModule,
PrivateKey,
)
from ansible_collections.community.crypto.plugins.module_utils.openssh.certificate import (
OpensshCertificate,
OpensshCertificateTimeParameters,
parse_option_list,
)
class CertificateError(Exception): class Certificate(OpensshModule):
pass
class Certificate(object):
def __init__(self, module): def __init__(self, module):
self.state = module.params['state'] super(Certificate, self).__init__(module)
self.force = module.params['force'] self.ssh_keygen = KeygenCommand(self.module)
self.type = module.params['type']
self.signing_key = module.params['signing_key'] self.identifier = self.module.params['identifier'] or ""
self.use_agent = module.params['use_agent'] self.options = self.module.params['options'] or []
self.pkcs11_provider = module.params['pkcs11_provider'] self.path = self.module.params['path']
self.public_key = module.params['public_key'] self.pkcs11_provider = self.module.params['pkcs11_provider']
self.path = module.params['path'] self.principals = self.module.params['principals'] or []
self.identifier = module.params['identifier'] self.public_key = self.module.params['public_key']
self.serial_number = module.params['serial_number'] self.regenerate = self.module.params['regenerate'] if not self.module.params['force'] else 'always'
self.valid_from = module.params['valid_from'] self.serial_number = self.module.params['serial_number']
self.valid_to = module.params['valid_to'] self.signature_algorithm = self.module.params['signature_algorithm']
self.valid_at = module.params['valid_at'] self.signing_key = self.module.params['signing_key']
self.principals = module.params['principals'] self.state = self.module.params['state']
self.options = module.params['options'] self.type = self.module.params['type']
self.changed = False self.use_agent = self.module.params['use_agent']
self.check_mode = module.check_mode self.valid_at = self.module.params['valid_at']
self.cert_info = {}
self._check_if_base_dir(self.path)
if self.state == 'present': if self.state == 'present':
self._validate_parameters()
if self.options and self.type == "host": self.data = None
module.fail_json(msg="Options can only be used with user certificates.") self.original_data = None
if self._exists():
self._load_certificate()
if self.valid_at: self.time_parameters = None
self.valid_at = self.valid_at.lstrip() if self.state == 'present':
self._set_time_parameters()
self.valid_from = self.valid_from.lstrip() def _validate_parameters(self):
self.valid_to = self.valid_to.lstrip() for path in (self.public_key, self.signing_key):
self._check_if_base_dir(path)
self.ssh_keygen = module.get_bin_path('ssh-keygen', True) if self.options and self.type == "host":
self.module.fail_json(msg="Options can only be used with user certificates.")
def generate(self, module): if self.use_agent:
self._use_agent_available()
if not self.is_valid(module, perms_required=False) or self.force: def _use_agent_available(self):
args = [ ssh_version = self._get_ssh_version()
self.ssh_keygen, if not ssh_version:
'-s', self.signing_key self.module.fail_json(msg="Failed to determine ssh version")
] elif LooseVersion(ssh_version) < LooseVersion("7.6"):
self.module.fail_json(
msg="Signing with CA key in ssh agent requires ssh 7.6 or newer." +
" Your version is: %s" % ssh_version
)
if self.pkcs11_provider: def _exists(self):
args.extend(['-D', self.pkcs11_provider]) return os.path.exists(self.path)
if self.use_agent: def _load_certificate(self):
args.extend(['-U'])
validity = ""
if not (self.valid_from == "always" and self.valid_to == "forever"):
if not self.valid_from == "always":
timeobj = self.convert_to_datetime(module, self.valid_from)
validity += (
str(timeobj.year).zfill(4) +
str(timeobj.month).zfill(2) +
str(timeobj.day).zfill(2) +
str(timeobj.hour).zfill(2) +
str(timeobj.minute).zfill(2) +
str(timeobj.second).zfill(2)
)
else:
validity += "19700101010101"
validity += ":"
if self.valid_to == "forever":
# on ssh-keygen versions that have the year 2038 bug this will cause the datetime to be 2038-01-19T04:14:07
timeobj = datetime(MAXYEAR, 12, 31)
else:
timeobj = self.convert_to_datetime(module, self.valid_to)
validity += (
str(timeobj.year).zfill(4) +
str(timeobj.month).zfill(2) +
str(timeobj.day).zfill(2) +
str(timeobj.hour).zfill(2) +
str(timeobj.minute).zfill(2) +
str(timeobj.second).zfill(2)
)
args.extend(["-V", validity])
if self.type == 'host':
args.extend(['-h'])
if self.identifier:
args.extend(['-I', self.identifier])
else:
args.extend(['-I', ""])
if self.serial_number is not None:
args.extend(['-z', str(self.serial_number)])
if self.principals:
args.extend(['-n', ','.join(self.principals)])
if self.options:
for option in self.options:
args.extend(['-O'])
args.extend([option])
args.extend(['-P', ''])
try:
temp_directory = tempfile.mkdtemp()
copy2(self.public_key, temp_directory)
args.extend([temp_directory + "/" + os.path.basename(self.public_key)])
module.run_command(args, environ_update=dict(TZ="UTC"), check_rc=True)
copy2(temp_directory + "/" + os.path.splitext(os.path.basename(self.public_key))[0] + "-cert.pub", self.path)
rmtree(temp_directory, ignore_errors=True)
proc = module.run_command([self.ssh_keygen, '-L', '-f', self.path])
self.cert_info = proc[1].split()
self.changed = True
except Exception as e:
try:
self.remove()
rmtree(temp_directory, ignore_errors=True)
except OSError as exc:
if exc.errno != errno.ENOENT:
raise CertificateError(exc)
else:
pass
module.fail_json(msg="%s" % to_native(e))
file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, False):
self.changed = True
def convert_to_datetime(self, module, timestring):
if self.is_relative(timestring):
result = convert_relative_to_datetime(timestring)
if result is None:
module.fail_json(
msg="'%s' is not a valid time format." % timestring)
else:
return result
else:
formats = ["%Y-%m-%d",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
]
for fmt in formats:
try:
return datetime.strptime(timestring, fmt)
except ValueError:
pass
module.fail_json(msg="'%s' is not a valid time format" % timestring)
def is_relative(self, timestr):
if timestr.startswith("+") or timestr.startswith("-"):
return True
return False
def is_same_datetime(self, datetime_one, datetime_two):
# This function is for backwards compatibility only because .total_seconds() is new in python2.7
def timedelta_total_seconds(time_delta):
return (time_delta.microseconds + 0.0 + (time_delta.seconds + time_delta.days * 24 * 3600) * 10 ** 6) / 10 ** 6
# try to use .total_ seconds() from python2.7
try: try:
return (datetime_one - datetime_two).total_seconds() == 0.0 self.original_data = OpensshCertificate.load(self.path)
except AttributeError: except (TypeError, ValueError) as e:
return timedelta_total_seconds(datetime_one - datetime_two) == 0.0 if self.regenerate in ('never', 'fail'):
self.module.fail_json(msg="Unable to read existing certificate: %s" % to_native(e))
self.module.warn("Unable to read existing certificate: %s" % to_native(e))
def is_valid(self, module, perms_required=True): def _set_time_parameters(self):
try:
def _check_state(): self.time_parameters = OpensshCertificateTimeParameters(
return os.path.exists(self.path) valid_from=self.module.params['valid_from'],
valid_to=self.module.params['valid_to'],
if _check_state(): )
proc = module.run_command([self.ssh_keygen, '-L', '-f', self.path], environ_update=dict(TZ="UTC"), check_rc=False) except ValueError as e:
if proc[0] != 0: self.module.fail_json(msg=to_native(e))
return False
self.cert_info = proc[1].split()
principals = re.findall("(?<=Principals:)(.*)(?=Critical)", proc[1], re.S)[0].split()
principals = list(map(str.strip, principals))
if principals == ["(none)"]:
principals = None
cert_type = re.findall("( user | host )", proc[1])[0].strip()
serial_number = re.search(r"Serial: (\d+)", proc[1]).group(1)
validity = re.findall("(from (\\d{4}-\\d{2}-\\d{2}T\\d{2}(:\\d{2}){2}) to (\\d{4}-\\d{2}-\\d{2}T\\d{2}(:\\d{2}){2}))", proc[1])
if validity:
if validity[0][1]:
cert_valid_from = self.convert_to_datetime(module, validity[0][1])
if self.is_same_datetime(cert_valid_from, self.convert_to_datetime(module, "1970-01-01 01:01:01")):
cert_valid_from = datetime(MINYEAR, 1, 1)
else:
cert_valid_from = datetime(MINYEAR, 1, 1)
if validity[0][3]:
cert_valid_to = self.convert_to_datetime(module, validity[0][3])
if self.is_same_datetime(cert_valid_to, self.convert_to_datetime(module, "2038-01-19 03:14:07")):
cert_valid_to = datetime(MAXYEAR, 12, 31)
else:
cert_valid_to = datetime(MAXYEAR, 12, 31)
else:
cert_valid_from = datetime(MINYEAR, 1, 1)
cert_valid_to = datetime(MAXYEAR, 12, 31)
else:
return False
def _check_perms(module):
file_args = module.load_file_common_arguments(module.params)
return not module.set_fs_attributes_if_different(file_args, False)
def _check_serial_number():
if self.serial_number is None:
return True
return self.serial_number == int(serial_number)
def _check_type():
return self.type == cert_type
def _check_principals():
if not principals or not self.principals:
return self.principals == principals
return set(self.principals) == set(principals)
def _check_validity(module):
if self.valid_from == "always":
earliest_time = datetime(MINYEAR, 1, 1)
elif self.is_relative(self.valid_from):
earliest_time = None
else:
earliest_time = self.convert_to_datetime(module, self.valid_from)
if self.valid_to == "forever":
last_time = datetime(MAXYEAR, 12, 31)
elif self.is_relative(self.valid_to):
last_time = None
else:
last_time = self.convert_to_datetime(module, self.valid_to)
if earliest_time:
if not self.is_same_datetime(earliest_time, cert_valid_from):
return False
if last_time:
if not self.is_same_datetime(last_time, cert_valid_to):
return False
if self.valid_at:
if cert_valid_from <= self.convert_to_datetime(module, self.valid_at) <= cert_valid_to:
return True
if earliest_time and last_time:
return True
return False
if perms_required and not _check_perms(module):
return False
return _check_type() and _check_principals() and _check_validity(module) and _check_serial_number()
def dump(self):
"""Serialize the object into a dictionary."""
def filter_keywords(arr, keywords):
concated = []
string = ""
for word in arr:
if word in keywords:
concated.append(string)
string = word
else:
string += " " + word
concated.append(string)
# drop the certificate path
concated.pop(0)
return concated
def format_cert_info():
return filter_keywords(self.cert_info, [
"Type:",
"Public",
"Signing",
"Key",
"Serial:",
"Valid:",
"Principals:",
"Critical",
"Extensions:"])
def _execute(self):
if self.state == 'present': if self.state == 'present':
result = { if self._should_generate():
'changed': self.changed, self._generate()
'type': self.type, self._update_permissions(self.path)
'filename': self.path,
'info': format_cert_info(),
}
else: else:
result = { if self._exists():
'changed': self.changed, self._remove()
}
return result def _should_generate(self):
if self.regenerate == 'never':
return self.original_data is None
elif self.regenerate == 'fail':
if self.original_data and not self._is_fully_valid():
self.module.fail_json(
msg="Certificate does not match the provided options.",
cert=get_cert_dict(self.original_data)
)
return self.original_data is None
elif self.regenerate == 'partial_idempotence':
return self.original_data is None or not self._is_partially_valid()
elif self.regenerate == 'full_idempotence':
return self.original_data is None or not self._is_fully_valid()
else:
return True
def remove(self): def _is_fully_valid(self):
"""Remove the resource from the filesystem.""" return self._is_partially_valid() and all([
self._compare_options(),
self.original_data.key_id == self.identifier,
self.original_data.public_key == self._get_key_fingerprint(self.public_key),
self.original_data.signing_key == self._get_key_fingerprint(self.signing_key),
])
def _is_partially_valid(self):
return all([
set(self.original_data.principals) == set(self.principals),
self.original_data.signature_type == self.signature_algorithm if self.signature_algorithm else True,
self.original_data.serial == self.serial_number if self.serial_number is not None else True,
self.original_data.type == self.type,
self._compare_time_parameters(),
])
def _compare_time_parameters(self):
try:
original_time_parameters = OpensshCertificateTimeParameters(
valid_from=self.original_data.valid_after,
valid_to=self.original_data.valid_before
)
except ValueError as e:
return self.module.fail_json(msg=to_native(e))
return all([
original_time_parameters == self.time_parameters,
original_time_parameters.within_range(self.valid_at)
])
def _compare_options(self):
try:
critical_options, extensions = parse_option_list(self.options)
except ValueError as e:
return self.module.fail_json(msg=to_native(e))
return all([
set(self.original_data.critical_options) == set(critical_options),
set(self.original_data.extensions) == set(extensions)
])
def _get_key_fingerprint(self, path):
private_key_content = self.ssh_keygen.get_private_key(path, check_rc=True)[1]
return PrivateKey.from_string(private_key_content).fingerprint
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _generate(self):
try:
temp_certificate = self._generate_temp_certificate()
self._safe_secure_move([(temp_certificate, self.path)])
except OSError as e:
self.module.fail_json(msg="Unable to write certificate to %s: %s" % (self.path, to_native(e)))
try:
self.data = OpensshCertificate.load(self.path)
except (TypeError, ValueError) as e:
self.module.fail_json(msg="Unable to read new certificate: %s" % to_native(e))
def _generate_temp_certificate(self):
key_copy = os.path.join(self.module.tmpdir, os.path.basename(self.public_key))
try:
self.module.preserved_copy(self.public_key, key_copy)
except OSError as e:
self.module.fail_json(msg="Unable to stage temporary key: %s" % to_native(e))
self.module.add_cleanup_file(key_copy)
self.ssh_keygen.generate_certificate(
key_copy, self.identifier, self.options, self.pkcs11_provider, self.principals, self.serial_number,
self.signature_algorithm, self.signing_key, self.type, self.time_parameters, self.use_agent,
environ_update=dict(TZ="UTC"), check_rc=True
)
temp_cert = os.path.splitext(key_copy)[0] + '-cert.pub'
self.module.add_cleanup_file(temp_cert)
return temp_cert
@OpensshModule.trigger_change
@OpensshModule.skip_if_check_mode
def _remove(self):
try: try:
os.remove(self.path) os.remove(self.path)
self.changed = True except OSError as e:
except OSError as exc: self.module.fail_json(msg="Unable to remove existing certificate: %s" % to_native(e))
if exc.errno != errno.ENOENT:
raise CertificateError(exc) @property
else: def _result(self):
pass if self.state != 'present':
return {}
certificate_info = self.ssh_keygen.get_certificate_info(self.path)[1]
return {
'type': self.type,
'filename': self.path,
'info': format_cert_info(certificate_info),
}
@property
def diff(self):
return {
'before': get_cert_dict(self.original_data),
'after': get_cert_dict(self.data)
}
def format_cert_info(cert_info):
result = []
string = ""
for word in cert_info.split():
if word in ("Type:", "Public", "Signing", "Key", "Serial:", "Valid:", "Principals:", "Critical", "Extensions:"):
result.append(string)
string = word
else:
string += " " + word
result.append(string)
# Drop the certificate path
result.pop(0)
return result
def get_cert_dict(data):
if data is None:
return {}
result = data.to_dict()
result.pop('nonce')
result['signature_algorithm'] = data.signature_type
return result
def main(): def main():
module = AnsibleModule( module = AnsibleModule(
argument_spec=dict( argument_spec=dict(
state=dict(type='str', default='present', choices=['absent', 'present']),
force=dict(type='bool', default=False), force=dict(type='bool', default=False),
type=dict(type='str', choices=['host', 'user']),
signing_key=dict(type='path'),
use_agent=dict(type='bool', default=False),
pkcs11_provider=dict(type='str'),
public_key=dict(type='path'),
path=dict(type='path', required=True),
identifier=dict(type='str'), identifier=dict(type='str'),
options=dict(type='list', elements='str'),
path=dict(type='path', required=True),
pkcs11_provider=dict(type='str'),
principals=dict(type='list', elements='str'),
public_key=dict(type='path'),
regenerate=dict(
type='str',
default='partial_idempotence',
choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always']
),
signature_algorithm=dict(type='str', choices=['ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512']),
signing_key=dict(type='path'),
serial_number=dict(type='int'), serial_number=dict(type='int'),
state=dict(type='str', default='present', choices=['absent', 'present']),
type=dict(type='str', choices=['host', 'user']),
use_agent=dict(type='bool', default=False),
valid_at=dict(type='str'),
valid_from=dict(type='str'), valid_from=dict(type='str'),
valid_to=dict(type='str'), valid_to=dict(type='str'),
valid_at=dict(type='str'),
principals=dict(type='list', elements='str'),
options=dict(type='list', elements='str'),
), ),
supports_check_mode=True, supports_check_mode=True,
add_file_common_args=True, add_file_common_args=True,
required_if=[('state', 'present', ['type', 'signing_key', 'public_key', 'valid_from', 'valid_to'])], required_if=[('state', 'present', ['type', 'signing_key', 'public_key', 'valid_from', 'valid_to'])],
) )
if module.params['use_agent']: Certificate(module).execute()
ssh = module.get_bin_path('ssh', True)
proc = module.run_command([ssh, '-Vq'])
ssh_version_string = proc[2].strip()
ssh_version = parse_openssh_version(ssh_version_string)
if ssh_version is None:
module.fail_json(msg="Failed to parse ssh version")
elif LooseVersion(ssh_version) < LooseVersion("7.6"):
module.fail_json(
msg=(
"Signing with CA key in ssh agent requires ssh 7.6 or newer."
" Your version is: %s"
) % ssh_version_string
)
def isBaseDir(path):
base_dir = os.path.dirname(path) or '.'
if not os.path.isdir(base_dir):
module.fail_json(
name=base_dir,
msg='The directory %s does not exist or the file is not a directory' % base_dir
)
if module.params['state'] == "present":
isBaseDir(module.params['signing_key'])
isBaseDir(module.params['public_key'])
isBaseDir(module.params['path'])
certificate = Certificate(module)
if certificate.state == 'present':
if module.check_mode:
certificate.changed = module.params['force'] or not certificate.is_valid(module)
else:
try:
certificate.generate(module)
except Exception as exc:
module.fail_json(msg=to_native(exc))
else:
if module.check_mode:
certificate.changed = os.path.exists(module.params['path'])
if certificate.changed:
certificate.cert_info = {}
else:
try:
certificate.remove()
except Exception as exc:
module.fail_json(msg=to_native(exc))
result = certificate.dump()
module.exit_json(**result)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -7,7 +7,6 @@
from __future__ import absolute_import, division, print_function from __future__ import absolute_import, division, print_function
__metaclass__ = type __metaclass__ = type
DOCUMENTATION = ''' DOCUMENTATION = '''
--- ---
module: openssh_keypair module: openssh_keypair
@@ -18,7 +17,9 @@ description:
ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519) ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519)
or C(ecdsa) private keys." or C(ecdsa) private keys."
requirements: requirements:
- "ssh-keygen" - ssh-keygen (if I(backend=openssh))
- cryptography >= 2.6 (if I(backend=cryptography) and OpenSSH < 7.8 is installed)
- cryptography >= 3.0 (if I(backend=cryptography) and OpenSSH >= 7.8 is installed)
options: options:
state: state:
description: description:
@@ -55,6 +56,35 @@ options:
description: description:
- Provides a new comment to the public key. - Provides a new comment to the public key.
type: str type: str
passphrase:
description:
- Passphrase used to decrypt an existing private key or encrypt a newly generated private key.
- Passphrases are not supported for I(type=rsa1).
- Can only be used when I(backend=cryptography), or when I(backend=auto) and a required C(cryptography) version is installed.
type: str
version_added: 1.7.0
private_key_format:
description:
- Used when a I(backend=cryptography) to select a format for the private key at the provided I(path).
- The only valid option currently is C(auto) which will match the key format of the installed OpenSSH version.
- For OpenSSH < 7.8 private keys will be in PKCS1 format except ed25519 keys which will be in OpenSSH format.
- For OpenSSH >= 7.8 all private key types will be in the OpenSSH format.
type: str
default: auto
choices:
- auto
version_added: 1.7.0
backend:
description:
- Selects between the C(cryptography) library or the OpenSSH binary C(opensshbin).
- C(auto) will default to C(opensshbin) unless the OpenSSH binary is not installed or when using I(passphrase).
type: str
default: auto
choices:
- auto
- cryptography
- opensshbin
version_added: 1.7.0
regenerate: regenerate:
description: description:
- Allows to configure in which situations the module is allowed to regenerate private keys. - Allows to configure in which situations the module is allowed to regenerate private keys.
@@ -92,6 +122,7 @@ notes:
- In case the ssh key is broken or password protected, the module will fail. - In case the ssh key is broken or password protected, the module will fail.
Set the I(force) option to C(yes) if you want to regenerate the keypair. Set the I(force) option to C(yes) if you want to regenerate the keypair.
- Supports C(check_mode). - Supports C(check_mode).
- In the case a custom C(mode), C(group), C(owner), or other file attribute is provided it will be applied to both key files.
extends_documentation_fragment: files extends_documentation_fragment: files
''' '''
@@ -101,6 +132,11 @@ EXAMPLES = '''
community.crypto.openssh_keypair: community.crypto.openssh_keypair:
path: /tmp/id_ssh_rsa path: /tmp/id_ssh_rsa
- name: Generate an OpenSSH keypair with the default values (4096 bits, rsa) and encrypted private key
community.crypto.openssh_keypair:
path: /tmp/id_ssh_rsa
passphrase: super_secret_password
- name: Generate an OpenSSH rsa keypair with a different size (2048 bits) - name: Generate an OpenSSH rsa keypair with a different size (2048 bits)
community.crypto.openssh_keypair: community.crypto.openssh_keypair:
path: /tmp/id_ssh_rsa path: /tmp/id_ssh_rsa
@@ -150,281 +186,15 @@ comment:
sample: test@comment sample: test@comment
''' '''
import errno
import os
import stat
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native
from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.keypair_backend import (
class KeypairError(Exception): select_backend
pass )
class Keypair(object):
def __init__(self, module):
self.path = module.params['path']
self.state = module.params['state']
self.force = module.params['force']
self.size = module.params['size']
self.type = module.params['type']
self.comment = module.params['comment']
self.changed = False
self.check_mode = module.check_mode
self.privatekey = None
self.fingerprint = {}
self.public_key = {}
self.regenerate = module.params['regenerate']
if self.regenerate == 'always':
self.force = True
if self.type in ('rsa', 'rsa1'):
self.size = 4096 if self.size is None else self.size
if self.size < 1024:
module.fail_json(msg=('For RSA keys, the minimum size is 1024 bits and the default is 4096 bits. '
'Attempting to use bit lengths under 1024 will cause the module to fail.'))
if self.type == 'dsa':
self.size = 1024 if self.size is None else self.size
if self.size != 1024:
module.fail_json(msg=('DSA keys must be exactly 1024 bits as specified by FIPS 186-2.'))
if self.type == 'ecdsa':
self.size = 256 if self.size is None else self.size
if self.size not in (256, 384, 521):
module.fail_json(msg=('For ECDSA keys, size determines the key length by selecting from '
'one of three elliptic curve sizes: 256, 384 or 521 bits. '
'Attempting to use bit lengths other than these three values for '
'ECDSA keys will cause this module to fail. '))
if self.type == 'ed25519':
self.size = 256
def generate(self, module):
# generate a keypair
if self.force or not self.isPrivateKeyValid(module, perms_required=False):
args = [
module.get_bin_path('ssh-keygen', True),
'-q',
'-N', '',
'-b', str(self.size),
'-t', self.type,
'-f', self.path,
]
if self.comment:
args.extend(['-C', self.comment])
else:
args.extend(['-C', ""])
try:
if os.path.exists(self.path) and not os.access(self.path, os.W_OK):
os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR)
self.changed = True
stdin_data = None
if os.path.exists(self.path):
stdin_data = 'y'
module.run_command(args, data=stdin_data)
proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path])
self.fingerprint = proc[1].split()
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
self.public_key = pubkey[1].strip('\n')
except Exception as e:
self.remove()
module.fail_json(msg="%s" % to_native(e))
elif not self.isPublicKeyValid(module, perms_required=False):
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
pubkey = pubkey[1].strip('\n')
try:
self.changed = True
with open(self.path + ".pub", "w") as pubkey_f:
pubkey_f.write(pubkey + '\n')
os.chmod(self.path + ".pub", stat.S_IWUSR + stat.S_IRUSR + stat.S_IRGRP + stat.S_IROTH)
except IOError:
module.fail_json(
msg='The public key is missing or does not match the private key. '
'Unable to regenerate the public key.')
self.public_key = pubkey
if self.comment:
try:
if os.path.exists(self.path) and not os.access(self.path, os.W_OK):
os.chmod(self.path, stat.S_IWUSR + stat.S_IRUSR)
args = [module.get_bin_path('ssh-keygen', True),
'-q', '-o', '-c', '-C', self.comment, '-f', self.path]
module.run_command(args)
except IOError:
module.fail_json(
msg='Unable to update the comment for the public key.')
file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, False):
self.changed = True
file_args['path'] = file_args['path'] + '.pub'
if module.set_fs_attributes_if_different(file_args, False):
self.changed = True
def _check_pass_protected_or_broken_key(self, module):
key_state = module.run_command([module.get_bin_path('ssh-keygen', True),
'-P', '', '-yf', self.path], check_rc=False)
if key_state[0] == 255 or 'is not a public key file' in key_state[2]:
return True
if 'incorrect passphrase' in key_state[2] or 'load failed' in key_state[2]:
return True
return False
def isPrivateKeyValid(self, module, perms_required=True):
# check if the key is correct
def _check_state():
return os.path.exists(self.path)
if not _check_state():
return False
if self._check_pass_protected_or_broken_key(module):
if self.regenerate in ('full_idempotence', 'always'):
return False
module.fail_json(msg='Unable to read the key. The key is protected with a passphrase or broken.'
' Will not proceed. To force regeneration, call the module with `generate`'
' set to `full_idempotence` or `always`, or with `force=yes`.')
proc = module.run_command([module.get_bin_path('ssh-keygen', True), '-lf', self.path], check_rc=False)
if not proc[0] == 0:
if os.path.isdir(self.path):
module.fail_json(msg='%s is a directory. Please specify a path to a file.' % (self.path))
if self.regenerate in ('full_idempotence', 'always'):
return False
module.fail_json(msg='Unable to read the key. The key is protected with a passphrase or broken.'
' Will not proceed. To force regeneration, call the module with `generate`'
' set to `full_idempotence` or `always`, or with `force=yes`.')
fingerprint = proc[1].split()
keysize = int(fingerprint[0])
keytype = fingerprint[-1][1:-1].lower()
self.fingerprint = fingerprint
if self.regenerate == 'never':
return True
def _check_type():
return self.type == keytype
def _check_size():
return self.size == keysize
if not (_check_type() and _check_size()):
if self.regenerate in ('partial_idempotence', 'full_idempotence', 'always'):
return False
module.fail_json(msg='Key has wrong type and/or size.'
' Will not proceed. To force regeneration, call the module with `generate`'
' set to `partial_idempotence`, `full_idempotence` or `always`, or with `force=yes`.')
def _check_perms(module):
file_args = module.load_file_common_arguments(module.params)
return not module.set_fs_attributes_if_different(file_args, False)
return not perms_required or _check_perms(module)
def isPublicKeyValid(self, module, perms_required=True):
def _get_pubkey_content():
if os.path.exists(self.path + ".pub"):
with open(self.path + ".pub", "r") as pubkey_f:
present_pubkey = pubkey_f.read().strip(' \n')
return present_pubkey
else:
return False
def _parse_pubkey(pubkey_content):
if pubkey_content:
parts = pubkey_content.split(' ', 2)
if len(parts) < 2:
return False
return parts[0], parts[1], '' if len(parts) <= 2 else parts[2]
return False
def _pubkey_valid(pubkey):
if pubkey_parts and _parse_pubkey(pubkey):
return pubkey_parts[:2] == _parse_pubkey(pubkey)[:2]
return False
def _comment_valid():
if pubkey_parts:
return pubkey_parts[2] == self.comment
return False
def _check_perms(module):
file_args = module.load_file_common_arguments(module.params)
file_args['path'] = file_args['path'] + '.pub'
return not module.set_fs_attributes_if_different(file_args, False)
pubkey_parts = _parse_pubkey(_get_pubkey_content())
pubkey = module.run_command([module.get_bin_path('ssh-keygen', True), '-yf', self.path])
pubkey = pubkey[1].strip('\n')
if _pubkey_valid(pubkey):
self.public_key = pubkey
else:
return False
if self.comment:
if not _comment_valid():
return False
if perms_required:
if not _check_perms(module):
return False
return True
def dump(self):
# return result as a dict
"""Serialize the object into a dictionary."""
result = {
'changed': self.changed,
'size': self.size,
'type': self.type,
'filename': self.path,
# On removal this has no value
'fingerprint': self.fingerprint[1] if self.fingerprint else '',
'public_key': self.public_key,
'comment': self.comment if self.comment else '',
}
return result
def remove(self):
"""Remove the resource from the filesystem."""
try:
os.remove(self.path)
self.changed = True
except OSError as exc:
if exc.errno != errno.ENOENT:
raise KeypairError(exc)
else:
pass
if os.path.exists(self.path + ".pub"):
try:
os.remove(self.path + ".pub")
self.changed = True
except OSError as exc:
if exc.errno != errno.ENOENT:
raise KeypairError(exc)
else:
pass
def main(): def main():
# Define Ansible Module
module = AnsibleModule( module = AnsibleModule(
argument_spec=dict( argument_spec=dict(
state=dict(type='str', default='present', choices=['present', 'absent']), state=dict(type='str', default='present', choices=['present', 'absent']),
@@ -438,49 +208,17 @@ def main():
default='partial_idempotence', default='partial_idempotence',
choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always'] choices=['never', 'fail', 'partial_idempotence', 'full_idempotence', 'always']
), ),
passphrase=dict(type='str', no_log=True),
private_key_format=dict(type='str', default='auto', no_log=False, choices=['auto']),
backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'opensshbin'])
), ),
supports_check_mode=True, supports_check_mode=True,
add_file_common_args=True, add_file_common_args=True,
) )
# Check if Path exists keypair = select_backend(module, module.params['backend'])[1]
base_dir = os.path.dirname(module.params['path']) or '.'
if not os.path.isdir(base_dir):
module.fail_json(
name=base_dir,
msg='The directory %s does not exist or the file is not a directory' % base_dir
)
keypair = Keypair(module) keypair.execute()
if keypair.state == 'present':
if module.check_mode:
result = keypair.dump()
result['changed'] = keypair.force or not keypair.isPrivateKeyValid(module) or not keypair.isPublicKeyValid(module)
module.exit_json(**result)
try:
keypair.generate(module)
except Exception as exc:
module.fail_json(msg=to_native(exc))
else:
if module.check_mode:
keypair.changed = os.path.exists(module.params['path'])
if keypair.changed:
keypair.fingerprint = {}
result = keypair.dump()
module.exit_json(**result)
try:
keypair.remove()
except Exception as exc:
module.fail_json(msg=to_native(exc))
result = keypair.dump()
module.exit_json(**result)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -236,7 +236,7 @@ csr:
import os import os
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr import (
select_backend, select_backend,
@@ -286,7 +286,10 @@ class CertificateSigningRequestModule(OpenSSLObject):
self.changed = True self.changed = True
file_args = module.load_file_common_arguments(module.params) file_args = module.load_file_common_arguments(module.params)
self.changed = module.set_fs_attributes_if_different(file_args, self.changed) if module.check_file_absent_if_check_mode(file_args['path']):
self.changed = True
else:
self.changed = module.set_fs_attributes_if_different(file_args, self.changed)
def remove(self, module): def remove(self, module):
self.module_backend.set_existing(None) self.module_backend.set_existing(None)

View File

@@ -183,6 +183,77 @@ public_key:
returned: success returned: success
type: str type: str
sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..."
public_key_type:
description:
- The CSR's public key's type.
- One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448).
- Will start with C(unknown) if the key type cannot be determined.
returned: success
type: str
version_added: 1.7.0
sample: RSA
public_key_data:
description:
- Public key data. Depends on the public key's type.
returned: success
type: dict
version_added: 1.7.0
contains:
size:
description:
- Bit size of modulus (RSA) or prime number (DSA).
type: int
returned: When C(public_key_type=RSA) or C(public_key_type=DSA)
modulus:
description:
- The RSA key's modulus.
type: int
returned: When C(public_key_type=RSA)
exponent:
description:
- The RSA key's public exponent.
type: int
returned: When C(public_key_type=RSA)
p:
description:
- The C(p) value for DSA.
- This is the prime modulus upon which arithmetic takes place.
type: int
returned: When C(public_key_type=DSA)
q:
description:
- The C(q) value for DSA.
- This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the
multiplicative group of the prime field used.
type: int
returned: When C(public_key_type=DSA)
g:
description:
- The C(g) value for DSA.
- This is the element spanning the subgroup of the multiplicative group of the prime field used.
type: int
returned: When C(public_key_type=DSA)
curve:
description:
- The curve's name for ECC.
type: str
returned: When C(public_key_type=ECC)
exponent_size:
description:
- The maximum number of bits of a private key. This is basically the bit size of the subgroup used.
type: int
returned: When C(public_key_type=ECC)
x:
description:
- The C(x) coordinate for the public point on the elliptic curve.
type: int
returned: When C(public_key_type=ECC)
y:
description:
- For C(public_key_type=ECC), this is the C(y) coordinate for the public point on the elliptic curve.
- For C(public_key_type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key.
type: int
returned: When C(public_key_type=DSA) or C(public_key_type=ECC)
public_key_fingerprints: public_key_fingerprints:
description: description:
- Fingerprints of CSR's public key. - Fingerprints of CSR's public key.
@@ -225,428 +296,17 @@ authority_cert_serial_number:
''' '''
import abc from ansible.module_utils.basic import AnsibleModule
import binascii from ansible.module_utils.common.text.converters import to_native
import os
import traceback
from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native, to_text, 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.support import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import (
OpenSSLObject, select_backend,
load_certificate_request,
get_fingerprint_of_bytes,
) )
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.pyopenssl_support import (
pyopenssl_get_extensions_from_csr,
pyopenssl_normalize_name,
pyopenssl_normalize_name_attribute,
pyopenssl_parse_name_constraints,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.3'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# OpenSSL 1.1.0 or newer
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
else:
# OpenSSL 1.0.x or older
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
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"
class CertificateSigningRequestInfo(OpenSSLObject):
def __init__(self, module, backend):
super(CertificateSigningRequestInfo, self).__init__(
module.params['path'] or '',
'present',
False,
module.check_mode,
)
self.backend = backend
self.module = module
self.content = module.params['content']
if self.content is not None:
self.content = self.content.encode('utf-8')
def generate(self):
# Empty method because OpenSSLObject wants this
pass
def dump(self):
# Empty method because OpenSSLObject wants this
pass
@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(self, binary):
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):
result = dict()
self.csr = load_certificate_request(self.path, 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'] = self._get_public_key(binary=False)
pk = self._get_public_key(binary=True)
result['public_key_fingerprints'] = get_fingerprint_of_bytes(pk) if pk is not None else dict()
if self.backend != 'pyopenssl':
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 not result['signature_valid']:
self.module.fail_json(
msg='CSR signature is invalid!',
**result
)
return result
class CertificateSigningRequestInfoCryptography(CertificateSigningRequestInfo):
"""Validate the supplied CSR, using the cryptography backend"""
def __init__(self, module):
super(CertificateSigningRequestInfoCryptography, self).__init__(module, 'cryptography')
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 = []
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.csr.extensions.get_extension_for_class(x509.TLSFeature)
value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
except AttributeError as dummy:
# 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) 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) for san in nc_ext.value.permitted_subtrees or []]
excluded = [cryptography_decode_name(san) 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(self, binary):
return self.csr.public_key().public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
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) 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
class CertificateSigningRequestInfoPyOpenSSL(CertificateSigningRequestInfo):
"""validate the supplied CSR."""
def __init__(self, module):
super(CertificateSigningRequestInfoPyOpenSSL, self).__init__(module, 'pyopenssl')
def __get_name(self, name):
result = []
for sub in name.get_components():
result.append([pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
return result
def _get_subject_ordered(self):
return self.__get_name(self.csr.get_subject())
def _get_extension(self, short_name):
for extension in self.csr.get_extensions():
if extension.get_short_name() == short_name:
result = [
pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
]
return sorted(result), bool(extension.get_critical())
return None, False
def _get_key_usage(self):
return self._get_extension(b'keyUsage')
def _get_extended_key_usage(self):
return self._get_extension(b'extendedKeyUsage')
def _get_basic_constraints(self):
return self._get_extension(b'basicConstraints')
def _get_ocsp_must_staple(self):
extensions = self.csr.get_extensions()
oms_ext = [
ext for ext in extensions
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
]
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
# Older versions of libssl don't know about OCSP Must Staple
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
if oms_ext:
return True, bool(oms_ext[0].get_critical())
else:
return None, False
def _get_subject_alt_name(self):
for extension in self.csr.get_extensions():
if extension.get_short_name() == b'subjectAltName':
result = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
to_text(extension, errors='surrogate_or_strict').split(', ')]
return result, bool(extension.get_critical())
return None, False
def _get_name_constraints(self):
for extension in self.csr.get_extensions():
if extension.get_short_name() == b'nameConstraints':
permitted, excluded = pyopenssl_parse_name_constraints(extension)
return permitted, excluded, bool(extension.get_critical())
return None, None, False
def _get_public_key(self, binary):
try:
return crypto.dump_publickey(
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
self.csr.get_pubkey()
)
except AttributeError:
try:
bio = crypto._new_mem_buf()
if binary:
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.csr.get_pubkey()._pkey)
else:
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.csr.get_pubkey()._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def _get_subject_key_identifier(self):
# Won't be implemented
return None
def _get_authority_key_identifier(self):
# Won't be implemented
return None, None, None
def _get_all_extensions(self):
return pyopenssl_get_extensions_from_csr(self.csr)
def _is_signature_valid(self):
try:
return bool(self.csr.verify(self.csr.get_pubkey()))
except crypto.Error:
# OpenSSL error means that key is not consistent
return False
def main(): def main():
module = AnsibleModule( module = AnsibleModule(
@@ -664,53 +324,19 @@ def main():
supports_check_mode=True, supports_check_mode=True,
) )
if module.params['content'] is not None:
data = module.params['content'].encode('utf-8')
else:
try:
with open(module.params['path'], 'rb') as f:
data = f.read()
except (IOError, OSError) as e:
module.fail_json(msg='Error while reading CSR file from disk: {0}'.format(e))
backend, module_backend = select_backend(module, module.params['select_crypto_backend'], data, validate_signature=True)
try: try:
if module.params['path'] is not None: result = module_backend.get_info()
base_dir = os.path.dirname(module.params['path']) or '.'
if not os.path.isdir(base_dir):
module.fail_json(
name=base_dir,
msg='The directory %s does not exist or the file is not a directory' % base_dir
)
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)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# If cryptography is available we'll use it
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Fail if no backend has been found
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
try:
getattr(crypto.X509Req, 'get_extensions')
except AttributeError:
module.fail_json(msg='You need to have PyOpenSSL>=0.15')
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
certificate = CertificateSigningRequestInfoPyOpenSSL(module)
elif backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
certificate = CertificateSigningRequestInfoCryptography(module)
result = certificate.get_info()
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=to_native(exc))

View File

@@ -115,7 +115,7 @@ csr:
type: str type: str
''' '''
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr import (
select_backend, select_backend,

View File

@@ -131,7 +131,7 @@ import traceback
from distutils.version import LooseVersion from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.io import ( from ansible_collections.community.crypto.plugins.module_utils.io import (
load_file_if_exists, load_file_if_exists,
@@ -221,9 +221,9 @@ class DHParameterBase(object):
def _check_fs_attributes(self, module): def _check_fs_attributes(self, module):
"""Checks (and changes if not in check mode!) fs attributes""" """Checks (and changes if not in check mode!) fs attributes"""
file_args = module.load_file_common_arguments(module.params) file_args = module.load_file_common_arguments(module.params)
attrs_changed = module.set_fs_attributes_if_different(file_args, False) if module.check_file_absent_if_check_mode(file_args['path']):
return False
return not attrs_changed return not module.set_fs_attributes_if_different(file_args, False)
def dump(self): def dump(self):
"""Serialize the object into a dictionary.""" """Serialize the object into a dictionary."""

View File

@@ -16,8 +16,14 @@ author:
short_description: Generate OpenSSL PKCS#12 archive short_description: Generate OpenSSL PKCS#12 archive
description: description:
- This module allows one to (re-)generate PKCS#12. - This module allows one to (re-)generate PKCS#12.
- The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available, assuming none of the
I(iter_size) and I(maciter_size) options are used. This can be overridden with the
I(select_crypto_backend) option.
# Please note that the C(pyopenssl) backend has been deprecated in community.crypto x.y.0,
# and will be removed in community.crypto (x+1).0.0.
requirements: requirements:
- python-pyOpenSSL - PyOpenSSL >= 0.15 or cryptography >= 3.0
options: options:
action: action:
description: description:
@@ -58,16 +64,21 @@ options:
iter_size: iter_size:
description: description:
- Number of times to repeat the encryption step. - Number of times to repeat the encryption step.
- This is not considered during idempotency checks.
- This is only used by the C(pyopenssl) backend. When using it, the default is C(2048).
type: int type: int
default: 2048
maciter_size: maciter_size:
description: description:
- Number of times to repeat the MAC step. - Number of times to repeat the MAC step.
- This is not considered during idempotency checks.
- This is only used by the C(pyopenssl) backend. When using it, the default is C(1).
type: int type: int
default: 1
passphrase: passphrase:
description: description:
- The PKCS#12 password. - The PKCS#12 password.
- "B(Note:) PKCS12 encryption is not secure and should not be used as a security mechanism.
If you need to store or send a PKCS12 file safely, you should additionally encrypt it
with something else."
type: str type: str
path: path:
description: description:
@@ -105,6 +116,21 @@ options:
type: bool type: bool
default: no default: no
version_added: "1.0.0" version_added: "1.0.0"
select_crypto_backend:
description:
- Determines which crypto backend to use.
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
If one of I(iter_size) or I(maciter_size) is used, C(auto) will always result in C(pyopenssl) to be chosen
for backwards compatibility.
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
# - Please note that the C(pyopenssl) backend has been deprecated in community.crypto x.y.0, and will be
# removed in community.crypto (x+1).0.0.
# From that point on, only the C(cryptography) backend will be available.
type: str
default: auto
choices: [ auto, cryptography, pyopenssl ]
version_added: 1.7.0
extends_documentation_fragment: extends_documentation_fragment:
- files - files
seealso: seealso:
@@ -207,13 +233,16 @@ pkcs12:
version_added: "1.0.0" version_added: "1.0.0"
''' '''
import abc
import base64 import base64
import os import os
import stat import stat
import traceback import traceback
from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_bytes, to_native from ansible.module_utils.common.text.converters import to_bytes, to_native
from ansible_collections.community.crypto.plugins.module_utils.io import ( from ansible_collections.community.crypto.plugins.module_utils.io import (
load_file_if_exists, load_file_if_exists,
@@ -225,6 +254,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.basic impo
OpenSSLBadPassphraseError, OpenSSLBadPassphraseError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
parse_pkcs12,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
OpenSSLObject, OpenSSLObject,
load_privatekey, load_privatekey,
@@ -235,23 +268,40 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import
split_pem_list, split_pem_list,
) )
MINIMAL_CRYPTOGRAPHY_VERSION = '3.0'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None PYOPENSSL_IMP_ERR = None
try: try:
import OpenSSL
from OpenSSL import crypto from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
except ImportError: except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc() PYOPENSSL_IMP_ERR = traceback.format_exc()
pyopenssl_found = False PYOPENSSL_FOUND = False
else: else:
pyopenssl_found = True PYOPENSSL_FOUND = True
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
def load_certificate_set(filename): def load_certificate_set(filename, backend):
''' '''
Load list of concatenated PEM files, and return a list of parsed certificates. Load list of concatenated PEM files, and return a list of parsed certificates.
''' '''
with open(filename, 'rb') as f: with open(filename, 'rb') as f:
data = f.read().decode('utf-8') data = f.read().decode('utf-8')
return [load_certificate(None, content=cert) for cert in split_pem_list(data)] return [load_certificate(None, content=cert.encode('utf-8'), backend=backend) for cert in split_pem_list(data)]
class PkcsError(OpenSSLObjectError): class PkcsError(OpenSSLObjectError):
@@ -259,21 +309,21 @@ class PkcsError(OpenSSLObjectError):
class Pkcs(OpenSSLObject): class Pkcs(OpenSSLObject):
def __init__(self, module, backend):
def __init__(self, module):
super(Pkcs, self).__init__( super(Pkcs, self).__init__(
module.params['path'], module.params['path'],
module.params['state'], module.params['state'],
module.params['force'], module.params['force'],
module.check_mode module.check_mode
) )
self.backend = backend
self.action = module.params['action'] self.action = module.params['action']
self.other_certificates = module.params['other_certificates'] self.other_certificates = module.params['other_certificates']
self.other_certificates_parse_all = module.params['other_certificates_parse_all'] self.other_certificates_parse_all = module.params['other_certificates_parse_all']
self.certificate_path = module.params['certificate_path'] self.certificate_path = module.params['certificate_path']
self.friendly_name = module.params['friendly_name'] self.friendly_name = module.params['friendly_name']
self.iter_size = module.params['iter_size'] self.iter_size = module.params['iter_size'] or 2048
self.maciter_size = module.params['maciter_size'] self.maciter_size = module.params['maciter_size'] or 1
self.passphrase = module.params['passphrase'] self.passphrase = module.params['passphrase']
self.pkcs12 = None self.pkcs12 = None
self.privatekey_passphrase = module.params['privatekey_passphrase'] self.privatekey_passphrase = module.params['privatekey_passphrase']
@@ -293,12 +343,37 @@ class Pkcs(OpenSSLObject):
filenames = list(self.other_certificates) filenames = list(self.other_certificates)
self.other_certificates = [] self.other_certificates = []
for other_cert_bundle in filenames: for other_cert_bundle in filenames:
self.other_certificates.extend(load_certificate_set(other_cert_bundle)) self.other_certificates.extend(load_certificate_set(other_cert_bundle, self.backend))
else: else:
self.other_certificates = [ self.other_certificates = [
load_certificate(other_cert) for other_cert in self.other_certificates load_certificate(other_cert, backend=self.backend) for other_cert in self.other_certificates
] ]
@abc.abstractmethod
def generate_bytes(self, module):
"""Generate PKCS#12 file archive."""
pass
@abc.abstractmethod
def parse_bytes(self, pkcs12_content):
pass
@abc.abstractmethod
def _dump_privatekey(self, pkcs12):
pass
@abc.abstractmethod
def _dump_certificate(self, pkcs12):
pass
@abc.abstractmethod
def _dump_other_certificates(self, pkcs12):
pass
@abc.abstractmethod
def _get_friendly_name(self, pkcs12):
pass
def check(self, module, perms_required=True): def check(self, module, perms_required=True):
"""Ensure the resource is in its desired state.""" """Ensure the resource is in its desired state."""
@@ -307,10 +382,8 @@ class Pkcs(OpenSSLObject):
def _check_pkey_passphrase(): def _check_pkey_passphrase():
if self.privatekey_passphrase: if self.privatekey_passphrase:
try: try:
load_privatekey(self.privatekey_path, self.privatekey_passphrase) load_privatekey(self.privatekey_path, self.privatekey_passphrase, backend=self.backend)
except crypto.Error: except OpenSSLObjectError:
return False
except OpenSSLBadPassphraseError:
return False return False
return True return True
@@ -318,32 +391,28 @@ class Pkcs(OpenSSLObject):
return state_and_perms return state_and_perms
if os.path.exists(self.path) and module.params['action'] == 'export': if os.path.exists(self.path) and module.params['action'] == 'export':
dummy = self.generate(module) dummy = self.generate_bytes(module)
self.src = self.path self.src = self.path
try: try:
pkcs12_privatekey, pkcs12_certificate, pkcs12_other_certificates, pkcs12_friendly_name = self.parse() pkcs12_privatekey, pkcs12_certificate, pkcs12_other_certificates, pkcs12_friendly_name = self.parse()
except crypto.Error: except OpenSSLObjectError:
return False return False
if (pkcs12_privatekey is not None) and (self.privatekey_path is not None): if (pkcs12_privatekey is not None) and (self.privatekey_path is not None):
expected_pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, expected_pkey = self._dump_privatekey(self.pkcs12)
self.pkcs12.get_privatekey())
if pkcs12_privatekey != expected_pkey: if pkcs12_privatekey != expected_pkey:
return False return False
elif bool(pkcs12_privatekey) != bool(self.privatekey_path): elif bool(pkcs12_privatekey) != bool(self.privatekey_path):
return False return False
if (pkcs12_certificate is not None) and (self.certificate_path is not None): if (pkcs12_certificate is not None) and (self.certificate_path is not None):
expected_cert = self._dump_certificate(self.pkcs12)
expected_cert = crypto.dump_certificate(crypto.FILETYPE_PEM,
self.pkcs12.get_certificate())
if pkcs12_certificate != expected_cert: if pkcs12_certificate != expected_cert:
return False return False
elif bool(pkcs12_certificate) != bool(self.certificate_path): elif bool(pkcs12_certificate) != bool(self.certificate_path):
return False return False
if (pkcs12_other_certificates is not None) and (self.other_certificates is not None): if (pkcs12_other_certificates is not None) and (self.other_certificates is not None):
expected_other_certs = [crypto.dump_certificate(crypto.FILETYPE_PEM, expected_other_certs = self._dump_other_certificates(self.pkcs12)
other_cert) for other_cert in self.pkcs12.get_ca_certificates()]
if set(pkcs12_other_certificates) != set(expected_other_certs): if set(pkcs12_other_certificates) != set(expected_other_certs):
return False return False
elif bool(pkcs12_other_certificates) != bool(self.other_certificates): elif bool(pkcs12_other_certificates) != bool(self.other_certificates):
@@ -352,15 +421,16 @@ class Pkcs(OpenSSLObject):
if pkcs12_privatekey: if pkcs12_privatekey:
# This check is required because pyOpenSSL will not return a friendly name # This check is required because pyOpenSSL will not return a friendly name
# if the private key is not set in the file # if the private key is not set in the file
if ((self.pkcs12.get_friendlyname() is not None) and (pkcs12_friendly_name is not None)): friendly_name = self._get_friendly_name(self.pkcs12)
if self.pkcs12.get_friendlyname() != pkcs12_friendly_name: if ((friendly_name is not None) and (pkcs12_friendly_name is not None)):
if friendly_name != pkcs12_friendly_name:
return False return False
elif bool(self.pkcs12.get_friendlyname()) != bool(pkcs12_friendly_name): elif bool(friendly_name) != bool(pkcs12_friendly_name):
return False return False
elif module.params['action'] == 'parse' and os.path.exists(self.src) and os.path.exists(self.path): elif module.params['action'] == 'parse' and os.path.exists(self.src) and os.path.exists(self.path):
try: try:
pkey, cert, other_certs, friendly_name = self.parse() pkey, cert, other_certs, friendly_name = self.parse()
except crypto.Error: except OpenSSLObjectError:
return False return False
expected_content = to_bytes( expected_content = to_bytes(
''.join([to_native(pem) for pem in [pkey, cert] + other_certs if pem is not None]) ''.join([to_native(pem) for pem in [pkey, cert] + other_certs if pem is not None])
@@ -390,27 +460,6 @@ class Pkcs(OpenSSLObject):
return result return result
def generate(self, module):
"""Generate PKCS#12 file archive."""
self.pkcs12 = crypto.PKCS12()
if self.other_certificates:
self.pkcs12.set_ca_certificates(self.other_certificates)
if self.certificate_path:
self.pkcs12.set_certificate(load_certificate(self.certificate_path))
if self.friendly_name:
self.pkcs12.set_friendlyname(to_bytes(self.friendly_name))
if self.privatekey_path:
try:
self.pkcs12.set_privatekey(load_privatekey(self.privatekey_path, self.privatekey_passphrase))
except OpenSSLBadPassphraseError as exc:
raise PkcsError(exc)
return self.pkcs12.export(self.passphrase, self.iter_size, self.maciter_size)
def remove(self, module): def remove(self, module):
if self.backup: if self.backup:
self.backup_file = module.backup_local(self.path) self.backup_file = module.backup_local(self.path)
@@ -422,8 +471,51 @@ class Pkcs(OpenSSLObject):
try: try:
with open(self.src, 'rb') as pkcs12_fh: with open(self.src, 'rb') as pkcs12_fh:
pkcs12_content = pkcs12_fh.read() pkcs12_content = pkcs12_fh.read()
p12 = crypto.load_pkcs12(pkcs12_content, return self.parse_bytes(pkcs12_content)
self.passphrase) except IOError as exc:
raise PkcsError(exc)
def generate(self):
pass
def write(self, module, content, mode=None):
"""Write the PKCS#12 file."""
if self.backup:
self.backup_file = module.backup_local(self.path)
write_file(module, content, mode)
if self.return_content:
self.pkcs12_bytes = content
class PkcsPyOpenSSL(Pkcs):
def __init__(self, module):
super(PkcsPyOpenSSL, self).__init__(module, 'pyopenssl')
def generate_bytes(self, module):
"""Generate PKCS#12 file archive."""
self.pkcs12 = crypto.PKCS12()
if self.other_certificates:
self.pkcs12.set_ca_certificates(self.other_certificates)
if self.certificate_path:
self.pkcs12.set_certificate(load_certificate(self.certificate_path, backend=self.backend))
if self.friendly_name:
self.pkcs12.set_friendlyname(to_bytes(self.friendly_name))
if self.privatekey_path:
try:
self.pkcs12.set_privatekey(
load_privatekey(self.privatekey_path, self.privatekey_passphrase, backend=self.backend))
except OpenSSLBadPassphraseError as exc:
raise PkcsError(exc)
return self.pkcs12.export(self.passphrase, self.iter_size, self.maciter_size)
def parse_bytes(self, pkcs12_content):
try:
p12 = crypto.load_pkcs12(pkcs12_content, self.passphrase)
pkey = p12.get_privatekey() pkey = p12.get_privatekey()
if pkey is not None: if pkey is not None:
pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)
@@ -438,17 +530,143 @@ class Pkcs(OpenSSLObject):
friendly_name = p12.get_friendlyname() friendly_name = p12.get_friendlyname()
return (pkey, crt, other_certs, friendly_name) return (pkey, crt, other_certs, friendly_name)
except crypto.Error as exc:
except IOError as exc:
raise PkcsError(exc) raise PkcsError(exc)
def write(self, module, content, mode=None): def _dump_privatekey(self, pkcs12):
"""Write the PKCS#12 file.""" pk = pkcs12.get_privatekey()
if self.backup: return crypto.dump_privatekey(crypto.FILETYPE_PEM, pk) if pk else None
self.backup_file = module.backup_local(self.path)
write_file(module, content, mode) def _dump_certificate(self, pkcs12):
if self.return_content: cert = pkcs12.get_certificate()
self.pkcs12_bytes = content return crypto.dump_certificate(crypto.FILETYPE_PEM, cert) if cert else None
def _dump_other_certificates(self, pkcs12):
return [
crypto.dump_certificate(crypto.FILETYPE_PEM, other_cert)
for other_cert in pkcs12.get_ca_certificates()
]
def _get_friendly_name(self, pkcs12):
return pkcs12.get_friendlyname()
class PkcsCryptography(Pkcs):
def __init__(self, module):
super(PkcsCryptography, self).__init__(module, 'cryptography')
def generate_bytes(self, module):
"""Generate PKCS#12 file archive."""
pkey = None
if self.privatekey_path:
try:
pkey = load_privatekey(self.privatekey_path, self.privatekey_passphrase, backend=self.backend)
except OpenSSLBadPassphraseError as exc:
raise PkcsError(exc)
cert = None
if self.certificate_path:
cert = load_certificate(self.certificate_path, backend=self.backend)
friendly_name = to_bytes(self.friendly_name) if self.friendly_name is not None else None
# Store fake object which can be used to retrieve the components back
self.pkcs12 = (pkey, cert, self.other_certificates, friendly_name)
return serialize_key_and_certificates(
friendly_name,
pkey,
cert,
self.other_certificates,
serialization.BestAvailableEncryption(to_bytes(self.passphrase))
if self.passphrase else serialization.NoEncryption(),
)
def parse_bytes(self, pkcs12_content):
try:
private_key, certificate, additional_certificates, friendly_name = parse_pkcs12(
pkcs12_content, self.passphrase)
pkey = None
if private_key is not None:
pkey = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
crt = None
if certificate is not None:
crt = certificate.public_bytes(serialization.Encoding.PEM)
other_certs = []
if additional_certificates is not None:
other_certs = [
other_cert.public_bytes(serialization.Encoding.PEM)
for other_cert in additional_certificates
]
return (pkey, crt, other_certs, friendly_name)
except ValueError as exc:
raise PkcsError(exc)
# The following methods will get self.pkcs12 passed, which is computed as:
#
# self.pkcs12 = (pkey, cert, self.other_certificates, self.friendly_name)
def _dump_privatekey(self, pkcs12):
return pkcs12[0].private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
) if pkcs12[0] else None
def _dump_certificate(self, pkcs12):
return pkcs12[1].public_bytes(serialization.Encoding.PEM) if pkcs12[1] else None
def _dump_other_certificates(self, pkcs12):
return [other_cert.public_bytes(serialization.Encoding.PEM) for other_cert in pkcs12[2]]
def _get_friendly_name(self, pkcs12):
return pkcs12[3]
def select_backend(module, backend):
if backend == 'auto':
# Detection what is possible
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# If no restrictions are provided, first try cryptography, then pyOpenSSL
if module.params['iter_size'] is not None or module.params['maciter_size'] is not None:
# If iter_size or maciter_size is specified, use pyOpenSSL backend
backend = 'pyopenssl'
elif can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
# module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
# version='x.0.0', collection_name='community.crypto')
return backend, PkcsPyOpenSSL(module)
elif 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, PkcsCryptography(module)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))
def main(): def main():
@@ -459,8 +677,8 @@ def main():
certificate_path=dict(type='path'), certificate_path=dict(type='path'),
force=dict(type='bool', default=False), force=dict(type='bool', default=False),
friendly_name=dict(type='str', aliases=['name']), friendly_name=dict(type='str', aliases=['name']),
iter_size=dict(type='int', default=2048), iter_size=dict(type='int'),
maciter_size=dict(type='int', default=1), maciter_size=dict(type='int'),
passphrase=dict(type='str', no_log=True), passphrase=dict(type='str', no_log=True),
path=dict(type='path', required=True), path=dict(type='path', required=True),
privatekey_passphrase=dict(type='str', no_log=True), privatekey_passphrase=dict(type='str', no_log=True),
@@ -469,6 +687,7 @@ def main():
src=dict(type='path'), src=dict(type='path'),
backup=dict(type='bool', default=False), backup=dict(type='bool', default=False),
return_content=dict(type='bool', default=False), return_content=dict(type='bool', default=False),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
) )
required_if = [ required_if = [
@@ -482,8 +701,7 @@ def main():
supports_check_mode=True, supports_check_mode=True,
) )
if not pyopenssl_found: backend, pkcs12 = select_backend(module, module.params['select_crypto_backend'])
module.fail_json(msg=missing_required_lib('pyOpenSSL'), exception=PYOPENSSL_IMP_ERR)
base_dir = os.path.dirname(module.params['path']) or '.' base_dir = os.path.dirname(module.params['path']) or '.'
if not os.path.isdir(base_dir): if not os.path.isdir(base_dir):
@@ -493,7 +711,6 @@ def main():
) )
try: try:
pkcs12 = Pkcs(module)
changed = False changed = False
if module.params['state'] == 'present': if module.params['state'] == 'present':
@@ -506,7 +723,7 @@ def main():
if module.params['action'] == 'export': if module.params['action'] == 'export':
if not module.params['friendly_name']: if not module.params['friendly_name']:
module.fail_json(msg='Friendly_name is required') module.fail_json(msg='Friendly_name is required')
pkcs12_content = pkcs12.generate(module) pkcs12_content = pkcs12.generate_bytes(module)
pkcs12.write(module, pkcs12_content, 0o600) pkcs12.write(module, pkcs12_content, 0o600)
changed = True changed = True
else: else:
@@ -516,7 +733,9 @@ def main():
changed = True changed = True
file_args = module.load_file_common_arguments(module.params) file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, changed): if module.check_file_absent_if_check_mode(file_args['path']):
changed = True
elif module.set_fs_attributes_if_different(file_args, changed):
changed = True changed = True
else: else:
if module.check_mode: if module.check_mode:

View File

@@ -14,6 +14,7 @@ module: openssl_privatekey
short_description: Generate OpenSSL private keys short_description: Generate OpenSSL private keys
description: description:
- This module allows one to (re)generate OpenSSL private keys. - This module allows one to (re)generate OpenSSL private keys.
- The default mode for the private key file will be C(0600) if I(mode) is not explicitly set.
author: author:
- Yanis Guenane (@Spredzy) - Yanis Guenane (@Spredzy)
- Felix Fontein (@felixfontein) - Felix Fontein (@felixfontein)
@@ -143,7 +144,7 @@ privatekey:
import os import os
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.io import ( from ansible_collections.community.crypto.plugins.module_utils.io import (
load_file_if_exists, load_file_if_exists,
@@ -213,7 +214,10 @@ class PrivateKeyModule(OpenSSLObject):
self.changed = True self.changed = True
file_args = module.load_file_common_arguments(module.params) file_args = module.load_file_common_arguments(module.params)
self.changed = module.set_fs_attributes_if_different(file_args, self.changed) if module.check_file_absent_if_check_mode(file_args['path']):
self.changed = True
else:
self.changed = module.set_fs_attributes_if_different(file_args, self.changed)
def remove(self, module): def remove(self, module):
self.module_backend.set_existing(None) self.module_backend.set_existing(None)

View File

@@ -130,6 +130,62 @@ public_data:
- Public key data. Depends on key type. - Public key data. Depends on key type.
returned: success returned: success
type: dict type: dict
contains:
size:
description:
- Bit size of modulus (RSA) or prime number (DSA).
type: int
returned: When C(type=RSA) or C(type=DSA)
modulus:
description:
- The RSA key's modulus.
type: int
returned: When C(type=RSA)
exponent:
description:
- The RSA key's public exponent.
type: int
returned: When C(type=RSA)
p:
description:
- The C(p) value for DSA.
- This is the prime modulus upon which arithmetic takes place.
type: int
returned: When C(type=DSA)
q:
description:
- The C(q) value for DSA.
- This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the
multiplicative group of the prime field used.
type: int
returned: When C(type=DSA)
g:
description:
- The C(g) value for DSA.
- This is the element spanning the subgroup of the multiplicative group of the prime field used.
type: int
returned: When C(type=DSA)
curve:
description:
- The curve's name for ECC.
type: str
returned: When C(type=ECC)
exponent_size:
description:
- The maximum number of bits of a private key. This is basically the bit size of the subgroup used.
type: int
returned: When C(type=ECC)
x:
description:
- The C(x) coordinate for the public point on the elliptic curve.
type: int
returned: When C(type=ECC)
y:
description:
- For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve.
- For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key.
type: int
returned: When C(type=DSA) or C(type=ECC)
private_data: private_data:
description: description:
- Private key data. Depends on key type. - Private key data. Depends on key type.
@@ -138,450 +194,19 @@ private_data:
''' '''
import abc from ansible.module_utils.basic import AnsibleModule
import os from ansible.module_utils.common.text.converters import to_native
import traceback
from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
CRYPTOGRAPHY_HAS_X25519,
CRYPTOGRAPHY_HAS_X448,
CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448,
OpenSSLObjectError, OpenSSLObjectError,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_info import (
OpenSSLObject, PrivateKeyConsistencyError,
load_privatekey, PrivateKeyParseError,
get_fingerprint_of_bytes, select_backend,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.math import (
binary_exp_mod,
quick_is_not_prime,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
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
SIGNATURE_TEST_DATA = b'1234'
def _get_cryptography_key_info(key):
key_public_data = dict()
key_private_data = dict()
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
key_type = 'RSA'
key_public_data['size'] = key.key_size
key_public_data['modulus'] = key.public_key().public_numbers().n
key_public_data['exponent'] = key.public_key().public_numbers().e
key_private_data['p'] = key.private_numbers().p
key_private_data['q'] = key.private_numbers().q
key_private_data['exponent'] = key.private_numbers().d
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
key_type = 'DSA'
key_public_data['size'] = key.key_size
key_public_data['p'] = key.parameters().parameter_numbers().p
key_public_data['q'] = key.parameters().parameter_numbers().q
key_public_data['g'] = key.parameters().parameter_numbers().g
key_public_data['y'] = key.public_key().public_numbers().y
key_private_data['x'] = key.private_numbers().x
elif CRYPTOGRAPHY_HAS_X25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey):
key_type = 'X25519'
elif CRYPTOGRAPHY_HAS_X448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey):
key_type = 'X448'
elif CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
key_type = 'Ed25519'
elif CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
key_type = 'Ed448'
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
key_type = 'ECC'
key_public_data['curve'] = key.public_key().curve.name
key_public_data['x'] = key.public_key().public_numbers().x
key_public_data['y'] = key.public_key().public_numbers().y
key_public_data['exponent_size'] = key.public_key().curve.key_size
key_private_data['multiplier'] = key.private_numbers().private_value
else:
key_type = 'unknown ({0})'.format(type(key))
return key_type, key_public_data, key_private_data
def _check_dsa_consistency(key_public_data, key_private_data):
# Get parameters
p = key_public_data.get('p')
q = key_public_data.get('q')
g = key_public_data.get('g')
y = key_public_data.get('y')
x = key_private_data.get('x')
for v in (p, q, g, y, x):
if v 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, p) != 1:
return False
# Check whether g**x mod p == y
if binary_exp_mod(g, x, 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, key_public_data, key_private_data):
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
return bool(key._backend._lib.RSA_check_key(key._rsa_cdata))
if isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
result = _check_dsa_consistency(key_public_data, key_private_data)
if result is not None:
return result
try:
signature = key.sign(SIGNATURE_TEST_DATA, cryptography.hazmat.primitives.hashes.SHA256())
except AttributeError:
# sign() was added in cryptography 1.5, but we support older versions
return None
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):
try:
signature = key.sign(
SIGNATURE_TEST_DATA,
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256())
)
except AttributeError:
# sign() was added in cryptography 1.5, but we support older versions
return None
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 CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
has_simple_sign_function = True
if CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
has_simple_sign_function = True
if has_simple_sign_function:
signature = key.sign(SIGNATURE_TEST_DATA)
try:
key.public_key().verify(signature, SIGNATURE_TEST_DATA)
return True
except cryptography.exceptions.InvalidSignature:
return False
# For X25519 and X448, there's no test yet.
return None
class PrivateKeyInfo(OpenSSLObject):
def __init__(self, module, backend):
super(PrivateKeyInfo, self).__init__(
module.params['path'] or '',
'present',
False,
module.check_mode,
)
self.backend = backend
self.module = module
self.content = module.params['content']
self.passphrase = module.params['passphrase']
self.return_private_key_data = module.params['return_private_key_data']
def generate(self):
# Empty method because OpenSSLObject wants this
pass
def dump(self):
# Empty method because OpenSSLObject wants this
pass
@abc.abstractmethod
def _get_public_key(self, binary):
pass
@abc.abstractmethod
def _get_key_info(self):
pass
@abc.abstractmethod
def _is_key_consistent(self, key_public_data, key_private_data):
pass
def get_info(self):
result = dict(
can_load_key=False,
can_parse_key=False,
key_is_consistent=None,
)
if self.content is not None:
priv_key_detail = self.content.encode('utf-8')
result['can_load_key'] = True
else:
try:
with open(self.path, 'rb') as b_priv_key_fh:
priv_key_detail = b_priv_key_fh.read()
result['can_load_key'] = True
except (IOError, OSError) as exc:
self.module.fail_json(msg=to_native(exc), **result)
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,
backend=self.backend
)
result['can_parse_key'] = True
except OpenSSLObjectError as exc:
self.module.fail_json(msg=to_native(exc), **result)
result['public_key'] = self._get_public_key(binary=False)
pk = self._get_public_key(binary=True)
result['public_key_fingerprints'] = get_fingerprint_of_bytes(pk) if pk is not None else dict()
key_type, key_public_data, key_private_data = self._get_key_info()
result['type'] = key_type
result['public_data'] = key_public_data
if self.return_private_key_data:
result['private_data'] = key_private_data
result['key_is_consistent'] = self._is_key_consistent(key_public_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 don't know")
result['key_is_consistent'] = False
self.module.fail_json(
msg="Private key is not consistent! (See "
"https://blog.hboeck.de/archives/888-How-I-tricked-Symantec-with-a-Fake-Private-Key.html)",
**result
)
return result
class PrivateKeyInfoCryptography(PrivateKeyInfo):
"""Validate the supplied private key, using the cryptography backend"""
def __init__(self, module):
super(PrivateKeyInfoCryptography, self).__init__(module, 'cryptography')
def _get_public_key(self, binary):
return self.key.public_key().public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
def _get_key_info(self):
return _get_cryptography_key_info(self.key)
def _is_key_consistent(self, key_public_data, key_private_data):
return _is_cryptography_key_consistent(self.key, key_public_data, key_private_data)
class PrivateKeyInfoPyOpenSSL(PrivateKeyInfo):
"""validate the supplied private key."""
def __init__(self, module):
super(PrivateKeyInfoPyOpenSSL, self).__init__(module, 'pyopenssl')
def _get_public_key(self, binary):
try:
return crypto.dump_publickey(
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
self.key
)
except AttributeError:
try:
# pyOpenSSL < 16.0:
bio = crypto._new_mem_buf()
if binary:
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.key._pkey)
else:
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.key._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def bigint_to_int(self, bn):
'''Convert OpenSSL BIGINT to Python integer'''
if bn == OpenSSL._util.ffi.NULL:
return None
hexstr = OpenSSL._util.lib.BN_bn2hex(bn)
try:
return int(OpenSSL._util.ffi.string(hexstr), 16)
finally:
OpenSSL._util.lib.OPENSSL_free(hexstr)
def _get_key_info(self):
key_public_data = dict()
key_private_data = dict()
openssl_key_type = self.key.type()
try_fallback = True
if crypto.TYPE_RSA == openssl_key_type:
key_type = 'RSA'
key_public_data['size'] = self.key.bits()
try:
# Use OpenSSL directly to extract key data
key = OpenSSL._util.lib.EVP_PKEY_get1_RSA(self.key._pkey)
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.RSA_free)
# OpenSSL 1.1 and newer have functions to extract the parameters
# from the EVP PKEY data structures. Older versions didn't have
# these getters, and it was common use to simply access the values
# directly. Since there's no guarantee that these data structures
# will still be accessible in the future, we use the getters for
# 1.1 and later, and directly access the values for 1.0.x and
# earlier.
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# Get modulus and exponents
n = OpenSSL._util.ffi.new("BIGNUM **")
e = OpenSSL._util.ffi.new("BIGNUM **")
d = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.RSA_get0_key(key, n, e, d)
key_public_data['modulus'] = self.bigint_to_int(n[0])
key_public_data['exponent'] = self.bigint_to_int(e[0])
key_private_data['exponent'] = self.bigint_to_int(d[0])
# Get factors
p = OpenSSL._util.ffi.new("BIGNUM **")
q = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.RSA_get0_factors(key, p, q)
key_private_data['p'] = self.bigint_to_int(p[0])
key_private_data['q'] = self.bigint_to_int(q[0])
else:
# Get modulus and exponents
key_public_data['modulus'] = self.bigint_to_int(key.n)
key_public_data['exponent'] = self.bigint_to_int(key.e)
key_private_data['exponent'] = self.bigint_to_int(key.d)
# Get factors
key_private_data['p'] = self.bigint_to_int(key.p)
key_private_data['q'] = self.bigint_to_int(key.q)
try_fallback = False
except AttributeError:
# Use fallback if available
pass
elif crypto.TYPE_DSA == openssl_key_type:
key_type = 'DSA'
key_public_data['size'] = self.key.bits()
try:
# Use OpenSSL directly to extract key data
key = OpenSSL._util.lib.EVP_PKEY_get1_DSA(self.key._pkey)
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.DSA_free)
# OpenSSL 1.1 and newer have functions to extract the parameters
# from the EVP PKEY data structures. Older versions didn't have
# these getters, and it was common use to simply access the values
# directly. Since there's no guarantee that these data structures
# will still be accessible in the future, we use the getters for
# 1.1 and later, and directly access the values for 1.0.x and
# earlier.
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# Get public parameters (primes and group element)
p = OpenSSL._util.ffi.new("BIGNUM **")
q = OpenSSL._util.ffi.new("BIGNUM **")
g = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.DSA_get0_pqg(key, p, q, g)
key_public_data['p'] = self.bigint_to_int(p[0])
key_public_data['q'] = self.bigint_to_int(q[0])
key_public_data['g'] = self.bigint_to_int(g[0])
# Get public and private key exponents
y = OpenSSL._util.ffi.new("BIGNUM **")
x = OpenSSL._util.ffi.new("BIGNUM **")
OpenSSL._util.lib.DSA_get0_key(key, y, x)
key_public_data['y'] = self.bigint_to_int(y[0])
key_private_data['x'] = self.bigint_to_int(x[0])
else:
# Get public parameters (primes and group element)
key_public_data['p'] = self.bigint_to_int(key.p)
key_public_data['q'] = self.bigint_to_int(key.q)
key_public_data['g'] = self.bigint_to_int(key.g)
# Get public and private key exponents
key_public_data['y'] = self.bigint_to_int(key.pub_key)
key_private_data['x'] = self.bigint_to_int(key.priv_key)
try_fallback = False
except AttributeError:
# Use fallback if available
pass
else:
# Return 'unknown'
key_type = 'unknown ({0})'.format(self.key.type())
# If needed and if possible, fall back to cryptography
if try_fallback and PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
return _get_cryptography_key_info(self.key.to_cryptography_key())
return key_type, key_public_data, key_private_data
def _is_key_consistent(self, key_public_data, key_private_data):
openssl_key_type = self.key.type()
if crypto.TYPE_RSA == openssl_key_type:
try:
return self.key.check()
except crypto.Error:
# OpenSSL error means that key is not consistent
return False
if crypto.TYPE_DSA == openssl_key_type:
result = _check_dsa_consistency(key_public_data, key_private_data)
if result is not None:
return result
signature = crypto.sign(self.key, SIGNATURE_TEST_DATA, 'sha256')
# Verify wants a cert (where it can get the public key from)
cert = crypto.X509()
cert.set_pubkey(self.key)
try:
crypto.verify(cert, signature, SIGNATURE_TEST_DATA, 'sha256')
return True
except crypto.Error:
return False
# If needed and if possible, fall back to cryptography
if PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
return _is_cryptography_key_consistent(self.key.to_cryptography_key(), key_public_data, key_private_data)
return None
def main(): def main():
module = AnsibleModule( module = AnsibleModule(
@@ -601,49 +226,39 @@ def main():
supports_check_mode=True, supports_check_mode=True,
) )
result = dict(
can_load_key=False,
can_parse_key=False,
key_is_consistent=None,
)
if module.params['content'] is not None:
data = module.params['content'].encode('utf-8')
else:
try:
with open(module.params['path'], 'rb') as f:
data = f.read()
except (IOError, OSError) as e:
module.fail_json(msg='Error while reading private key file from disk: {0}'.format(e), **result)
result['can_load_key'] = True
backend, module_backend = select_backend(
module,
module.params['select_crypto_backend'],
data,
passphrase=module.params['passphrase'],
return_private_key_data=module.params['return_private_key_data'])
try: try:
if module.params['path'] is not None: result.update(module_backend.get_info())
base_dir = os.path.dirname(module.params['path']) or '.'
if not os.path.isdir(base_dir):
module.fail_json(
name=base_dir,
msg='The directory %s does not exist or the file is not a directory' % base_dir
)
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)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# If cryptography is available we'll use it
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Fail if no backend has been found
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
privatekey = PrivateKeyInfoPyOpenSSL(module)
elif backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
privatekey = PrivateKeyInfoCryptography(module)
result = privatekey.get_info()
module.exit_json(**result) module.exit_json(**result)
except PrivateKeyParseError as exc:
result.update(exc.result)
module.fail_json(msg=exc.error_message, **result)
except PrivateKeyConsistencyError as exc:
result.update(exc.result)
module.fail_json(msg=exc.error_message, **result)
except OpenSSLObjectError as exc: except OpenSSLObjectError as exc:
module.fail_json(msg=to_native(exc)) module.fail_json(msg=to_native(exc))

View File

@@ -68,7 +68,7 @@ EXAMPLES = r'''
no_log: true # make sure that private key data is not accidentally revealed in logs! no_log: true # make sure that private key data is not accidentally revealed in logs!
- name: Update encrypted key when openssl_privatekey_pipe reported a change - name: Update encrypted key when openssl_privatekey_pipe reported a change
community.sops.encrypt_sops: community.sops.sops_encrypt:
path: private_key.pem.sops path: private_key.pem.sops
content_text: "{{ output.privatekey }}" content_text: "{{ output.privatekey }}"
when: output is changed when: output is changed

View File

@@ -185,7 +185,7 @@ import traceback
from distutils.version import LooseVersion from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.io import ( from ansible_collections.community.crypto.plugins.module_utils.io import (
load_file_if_exists, load_file_if_exists,
@@ -203,6 +203,11 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
get_fingerprint, get_fingerprint,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
PublicKeyParseError,
get_publickey_info,
)
MINIMAL_PYOPENSSL_VERSION = '16.0.0' MINIMAL_PYOPENSSL_VERSION = '16.0.0'
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3' MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH = '1.4' MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH = '1.4'
@@ -244,6 +249,7 @@ class PublicKey(OpenSSLObject):
module.params['force'], module.params['force'],
module.check_mode module.check_mode
) )
self.module = module
self.format = module.params['format'] self.format = module.params['format']
self.privatekey_path = module.params['privatekey_path'] self.privatekey_path = module.params['privatekey_path']
self.privatekey_content = module.params['privatekey_content'] self.privatekey_content = module.params['privatekey_content']
@@ -259,6 +265,23 @@ class PublicKey(OpenSSLObject):
self.backup = module.params['backup'] self.backup = module.params['backup']
self.backup_file = None self.backup_file = None
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()
result = dict(can_parse_key=False)
try:
result.update(get_publickey_info(
self.module, self.backend, content=data, prefer_one_fingerprint=True))
result['can_parse_key'] = True
except PublicKeyParseError as exc:
result.update(exc.result)
except Exception as exc:
pass
return result
def _create_publickey(self, module): def _create_publickey(self, module):
self.privatekey = load_privatekey( self.privatekey = load_privatekey(
path=self.privatekey_path, path=self.privatekey_path,
@@ -294,6 +317,7 @@ class PublicKey(OpenSSLObject):
if not self.check(module, perms_required=False) or self.force: if not self.check(module, perms_required=False) or self.force:
try: try:
publickey_content = self._create_publickey(module) publickey_content = self._create_publickey(module)
self.diff_after = self._get_info(publickey_content)
if self.return_content: if self.return_content:
self.publickey_bytes = publickey_content self.publickey_bytes = publickey_content
@@ -314,7 +338,9 @@ class PublicKey(OpenSSLObject):
backend=self.backend, backend=self.backend,
) )
file_args = module.load_file_common_arguments(module.params) file_args = module.load_file_common_arguments(module.params)
if module.set_fs_attributes_if_different(file_args, False): if module.check_file_absent_if_check_mode(file_args['path']):
self.changed = True
elif module.set_fs_attributes_if_different(file_args, False):
self.changed = True self.changed = True
def check(self, module, perms_required=True): def check(self, module, perms_required=True):
@@ -329,6 +355,7 @@ class PublicKey(OpenSSLObject):
try: try:
with open(self.path, 'rb') as public_key_fh: with open(self.path, 'rb') as public_key_fh:
publickey_content = public_key_fh.read() publickey_content = public_key_fh.read()
self.diff_before = self.diff_after = self._get_info(publickey_content)
if self.return_content: if self.return_content:
self.publickey_bytes = publickey_content self.publickey_bytes = publickey_content
if self.backend == 'cryptography': if self.backend == 'cryptography':
@@ -387,6 +414,11 @@ class PublicKey(OpenSSLObject):
self.publickey_bytes = load_file_if_exists(self.path, ignore_errors=True) self.publickey_bytes = load_file_if_exists(self.path, ignore_errors=True)
result['publickey'] = self.publickey_bytes.decode('utf-8') if self.publickey_bytes else None result['publickey'] = self.publickey_bytes.decode('utf-8') if self.publickey_bytes else None
result['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result return result

View File

@@ -0,0 +1,219 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2021, Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = r'''
---
module: openssl_publickey_info
short_description: Provide information for OpenSSL public keys
description:
- This module allows one to query information on OpenSSL public keys.
- It uses the pyOpenSSL or cryptography python library to interact with OpenSSL. If both the
cryptography and PyOpenSSL libraries are available (and meet the minimum version requirements)
cryptography will be preferred as a backend over PyOpenSSL (unless the backend is forced with
C(select_crypto_backend)). Please note that the PyOpenSSL backend was deprecated in Ansible 2.9
and will be removed in community.crypto 2.0.0.
version_added: 1.7.0
requirements:
- PyOpenSSL >= 0.15 or cryptography >= 1.2.3
author:
- Felix Fontein (@felixfontein)
options:
path:
description:
- Remote absolute path where the public key file is loaded from.
type: path
content:
description:
- Content of the public key file.
- Either I(path) or I(content) must be specified, but not both.
type: str
select_crypto_backend:
description:
- Determines which crypto backend to use.
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
- Please note that the C(pyopenssl) backend has been deprecated in Ansible 2.9, and will be removed in community.crypto 2.0.0.
From that point on, only the C(cryptography) backend will be available.
type: str
default: auto
choices: [ auto, cryptography, pyopenssl ]
notes:
- Supports C(check_mode).
seealso:
- module: community.crypto.openssl_publickey
- module: community.crypto.openssl_privatekey_info
'''
EXAMPLES = r'''
- name: Generate an OpenSSL private key with the default values (4096 bits, RSA)
community.crypto.openssl_privatekey:
path: /etc/ssl/private/ansible.com.pem
- name: Create public key from private key
community.crypto.openssl_privatekey:
privatekey_path: /etc/ssl/private/ansible.com.pem
path: /etc/ssl/ansible.com.pub
- name: Get information on public key
community.crypto.openssl_publickey_info:
path: /etc/ssl/ansible.com.pub
register: result
- name: Dump information
ansible.builtin.debug:
var: result
'''
RETURN = r'''
fingerprints:
description:
- Fingerprints of public key.
- For every hash algorithm available, the fingerprint is computed.
returned: success
type: dict
sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63',
'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..."
type:
description:
- The key's type.
- One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448).
- Will start with C(unknown) if the key type cannot be determined.
returned: success
type: str
sample: RSA
public_data:
description:
- Public key data. Depends on key type.
returned: success
type: dict
contains:
size:
description:
- Bit size of modulus (RSA) or prime number (DSA).
type: int
returned: When C(type=RSA) or C(type=DSA)
modulus:
description:
- The RSA key's modulus.
type: int
returned: When C(type=RSA)
exponent:
description:
- The RSA key's public exponent.
type: int
returned: When C(type=RSA)
p:
description:
- The C(p) value for DSA.
- This is the prime modulus upon which arithmetic takes place.
type: int
returned: When C(type=DSA)
q:
description:
- The C(q) value for DSA.
- This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the
multiplicative group of the prime field used.
type: int
returned: When C(type=DSA)
g:
description:
- The C(g) value for DSA.
- This is the element spanning the subgroup of the multiplicative group of the prime field used.
type: int
returned: When C(type=DSA)
curve:
description:
- The curve's name for ECC.
type: str
returned: When C(type=ECC)
exponent_size:
description:
- The maximum number of bits of a private key. This is basically the bit size of the subgroup used.
type: int
returned: When C(type=ECC)
x:
description:
- The C(x) coordinate for the public point on the elliptic curve.
type: int
returned: When C(type=ECC)
y:
description:
- For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve.
- For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key.
type: int
returned: When C(type=DSA) or C(type=ECC)
'''
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
PublicKeyParseError,
select_backend,
)
def main():
module = AnsibleModule(
argument_spec=dict(
path=dict(type='path'),
content=dict(type='str', no_log=True),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
),
required_one_of=(
['path', 'content'],
),
mutually_exclusive=(
['path', 'content'],
),
supports_check_mode=True,
)
result = dict(
can_load_key=False,
can_parse_key=False,
key_is_consistent=None,
)
if module.params['content'] is not None:
data = module.params['content'].encode('utf-8')
else:
try:
with open(module.params['path'], 'rb') as f:
data = f.read()
except (IOError, OSError) as e:
module.fail_json(msg='Error while reading public key file from disk: {0}'.format(e), **result)
backend, module_backend = select_backend(
module,
module.params['select_crypto_backend'],
data)
try:
result.update(module_backend.get_info())
module.exit_json(**result)
except PublicKeyParseError as exc:
result.update(exc.result)
module.fail_json(msg=exc.error_message, **result)
except OpenSSLObjectError as exc:
module.fail_json(msg=to_native(exc))
if __name__ == "__main__":
main()

View File

@@ -139,7 +139,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
load_privatekey, load_privatekey,
) )
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib

View File

@@ -139,7 +139,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
load_certificate, load_certificate,
) )
from ansible.module_utils._text import to_native, to_bytes from ansible.module_utils.common.text.converters import to_native, to_bytes
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib

View File

@@ -362,7 +362,7 @@ certificate:
import os import os
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
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 (
select_backend, select_backend,
@@ -472,7 +472,10 @@ class GenericCertificate(OpenSSLObject):
self.changed = True self.changed = True
file_args = module.load_file_common_arguments(module.params) file_args = module.load_file_common_arguments(module.params)
self.changed = module.set_fs_attributes_if_different(file_args, self.changed) if module.check_file_absent_if_check_mode(file_args['path']):
self.changed = True
else:
self.changed = module.set_fs_attributes_if_different(file_args, self.changed)
def check(self, module, perms_required=True): def check(self, module, perms_required=True):
"""Ensure the resource is in its desired state.""" """Ensure the resource is in its desired state."""

View File

@@ -227,6 +227,77 @@ public_key:
returned: success returned: success
type: str type: str
sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..." sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..."
public_key_type:
description:
- The certificate's public key's type.
- One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448).
- Will start with C(unknown) if the key type cannot be determined.
returned: success
type: str
version_added: 1.7.0
sample: RSA
public_key_data:
description:
- Public key data. Depends on the public key's type.
returned: success
type: dict
version_added: 1.7.0
contains:
size:
description:
- Bit size of modulus (RSA) or prime number (DSA).
type: int
returned: When C(public_key_type=RSA) or C(public_key_type=DSA)
modulus:
description:
- The RSA key's modulus.
type: int
returned: When C(public_key_type=RSA)
exponent:
description:
- The RSA key's public exponent.
type: int
returned: When C(public_key_type=RSA)
p:
description:
- The C(p) value for DSA.
- This is the prime modulus upon which arithmetic takes place.
type: int
returned: When C(public_key_type=DSA)
q:
description:
- The C(q) value for DSA.
- This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the
multiplicative group of the prime field used.
type: int
returned: When C(public_key_type=DSA)
g:
description:
- The C(g) value for DSA.
- This is the element spanning the subgroup of the multiplicative group of the prime field used.
type: int
returned: When C(public_key_type=DSA)
curve:
description:
- The curve's name for ECC.
type: str
returned: When C(public_key_type=ECC)
exponent_size:
description:
- The maximum number of bits of a private key. This is basically the bit size of the subgroup used.
type: int
returned: When C(public_key_type=ECC)
x:
description:
- The C(x) coordinate for the public point on the elliptic curve.
type: int
returned: When C(public_key_type=ECC)
y:
description:
- For C(public_key_type=ECC), this is the C(y) coordinate for the public point on the elliptic curve.
- For C(public_key_type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key.
type: int
returned: When C(public_key_type=DSA) or C(public_key_type=ECC)
public_key_fingerprints: public_key_fingerprints:
description: description:
- Fingerprints of certificate's public key. - Fingerprints of certificate's public key.
@@ -304,529 +375,22 @@ ocsp_uri:
''' '''
import abc from ansible.module_utils.basic import AnsibleModule
import binascii
import datetime
import os
import re
import traceback
from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils.six import string_types from ansible.module_utils.six import string_types
from ansible.module_utils._text import to_native, to_text, to_bytes from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
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.support import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
OpenSSLObject,
get_relative_time_option, get_relative_time_option,
load_certificate,
get_fingerprint_of_bytes,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import (
cryptography_decode_name, select_backend,
cryptography_get_extensions_from_cert,
cryptography_oid_to_name,
cryptography_serial_number_of_cert,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import (
pyopenssl_get_extensions_from_cert,
pyopenssl_normalize_name,
pyopenssl_normalize_name_attribute,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
MINIMAL_PYOPENSSL_VERSION = '0.15'
PYOPENSSL_IMP_ERR = None
try:
import OpenSSL
from OpenSSL import crypto
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
# OpenSSL 1.1.0 or newer
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
else:
# OpenSSL 1.0.x or older
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
except ImportError:
PYOPENSSL_IMP_ERR = traceback.format_exc()
PYOPENSSL_FOUND = False
else:
PYOPENSSL_FOUND = True
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"
class CertificateInfo(OpenSSLObject):
def __init__(self, module, backend):
super(CertificateInfo, self).__init__(
module.params['path'] or '',
'present',
False,
module.check_mode,
)
self.backend = backend
self.module = module
self.content = module.params['content']
if self.content is not None:
self.content = self.content.encode('utf-8')
self.valid_at = module.params['valid_at']
if self.valid_at:
for k, v in self.valid_at.items():
if not isinstance(v, string_types):
self.module.fail_json(
msg='The value for valid_at.{0} must be of type string (got {1})'.format(k, type(v))
)
self.valid_at[k] = get_relative_time_option(v, 'valid_at.{0}'.format(k))
def generate(self):
# Empty method because OpenSSLObject wants this
pass
def dump(self):
# Empty method because OpenSSLObject wants this
pass
@abc.abstractmethod
def _get_der_bytes(self):
pass
@abc.abstractmethod
def _get_signature_algorithm(self):
pass
@abc.abstractmethod
def _get_subject_ordered(self):
pass
@abc.abstractmethod
def _get_issuer_ordered(self):
pass
@abc.abstractmethod
def _get_version(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_not_before(self):
pass
@abc.abstractmethod
def _get_not_after(self):
pass
@abc.abstractmethod
def _get_public_key(self, binary):
pass
@abc.abstractmethod
def _get_subject_key_identifier(self):
pass
@abc.abstractmethod
def _get_authority_key_identifier(self):
pass
@abc.abstractmethod
def _get_serial_number(self):
pass
@abc.abstractmethod
def _get_all_extensions(self):
pass
@abc.abstractmethod
def _get_ocsp_uri(self):
pass
def get_info(self):
result = dict()
self.cert = load_certificate(self.path, content=self.content, backend=self.backend)
result['signature_algorithm'] = self._get_signature_algorithm()
subject = self._get_subject_ordered()
issuer = self._get_issuer_ordered()
result['subject'] = dict()
for k, v in subject:
result['subject'][k] = v
result['subject_ordered'] = subject
result['issuer'] = dict()
for k, v in issuer:
result['issuer'][k] = v
result['issuer_ordered'] = issuer
result['version'] = self._get_version()
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()
not_before = self._get_not_before()
not_after = self._get_not_after()
result['not_before'] = not_before.strftime(TIMESTAMP_FORMAT)
result['not_after'] = not_after.strftime(TIMESTAMP_FORMAT)
result['expired'] = not_after < datetime.datetime.utcnow()
result['valid_at'] = dict()
if self.valid_at:
for k, v in self.valid_at.items():
result['valid_at'][k] = not_before <= v <= not_after
result['public_key'] = self._get_public_key(binary=False)
pk = self._get_public_key(binary=True)
result['public_key_fingerprints'] = get_fingerprint_of_bytes(pk) if pk is not None else dict()
result['fingerprints'] = get_fingerprint_of_bytes(self._get_der_bytes())
if self.backend != 'pyopenssl':
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['serial_number'] = self._get_serial_number()
result['extensions_by_oid'] = self._get_all_extensions()
result['ocsp_uri'] = self._get_ocsp_uri()
return result
class CertificateInfoCryptography(CertificateInfo):
"""Validate the supplied cert, using the cryptography backend"""
def __init__(self, module):
super(CertificateInfoCryptography, self).__init__(module, 'cryptography')
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 as dummy:
# 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) for san in san_ext.value]
return result, san_ext.critical
except cryptography.x509.ExtensionNotFound:
return None, False
def _get_not_before(self):
return self.cert.not_valid_before
def _get_not_after(self):
return self.cert.not_valid_after
def _get_public_key(self, binary):
return self.cert.public_key().public_bytes(
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo
)
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) 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 as dummy:
pass
return None
class CertificateInfoPyOpenSSL(CertificateInfo):
"""validate the supplied certificate."""
def __init__(self, module):
super(CertificateInfoPyOpenSSL, self).__init__(module, 'pyopenssl')
def _get_der_bytes(self):
return crypto.dump_certificate(crypto.FILETYPE_ASN1, self.cert)
def _get_signature_algorithm(self):
return to_text(self.cert.get_signature_algorithm())
def __get_name(self, name):
result = []
for sub in name.get_components():
result.append([pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
return result
def _get_subject_ordered(self):
return self.__get_name(self.cert.get_subject())
def _get_issuer_ordered(self):
return self.__get_name(self.cert.get_issuer())
def _get_version(self):
# Version numbers in certs are off by one:
# v1: 0, v2: 1, v3: 2 ...
return self.cert.get_version() + 1
def _get_extension(self, short_name):
for extension_idx in range(0, self.cert.get_extension_count()):
extension = self.cert.get_extension(extension_idx)
if extension.get_short_name() == short_name:
result = [
pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
]
return sorted(result), bool(extension.get_critical())
return None, False
def _get_key_usage(self):
return self._get_extension(b'keyUsage')
def _get_extended_key_usage(self):
return self._get_extension(b'extendedKeyUsage')
def _get_basic_constraints(self):
return self._get_extension(b'basicConstraints')
def _get_ocsp_must_staple(self):
extensions = [self.cert.get_extension(i) for i in range(0, self.cert.get_extension_count())]
oms_ext = [
ext for ext in extensions
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
]
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
# Older versions of libssl don't know about OCSP Must Staple
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
if oms_ext:
return True, bool(oms_ext[0].get_critical())
else:
return None, False
def _get_subject_alt_name(self):
for extension_idx in range(0, self.cert.get_extension_count()):
extension = self.cert.get_extension(extension_idx)
if extension.get_short_name() == b'subjectAltName':
result = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
to_text(extension, errors='surrogate_or_strict').split(', ')]
return result, bool(extension.get_critical())
return None, False
def _get_not_before(self):
time_string = to_native(self.cert.get_notBefore())
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
def _get_not_after(self):
time_string = to_native(self.cert.get_notAfter())
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
def _get_public_key(self, binary):
try:
return crypto.dump_publickey(
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
self.cert.get_pubkey()
)
except AttributeError:
try:
# pyOpenSSL < 16.0:
bio = crypto._new_mem_buf()
if binary:
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.cert.get_pubkey()._pkey)
else:
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.cert.get_pubkey()._pkey)
if rc != 1:
crypto._raise_current_error()
return crypto._bio_to_string(bio)
except AttributeError:
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
def _get_subject_key_identifier(self):
# Won't be implemented
return None
def _get_authority_key_identifier(self):
# Won't be implemented
return None, None, None
def _get_serial_number(self):
return self.cert.get_serial_number()
def _get_all_extensions(self):
return pyopenssl_get_extensions_from_cert(self.cert)
def _get_ocsp_uri(self):
for i in range(self.cert.get_extension_count()):
ext = self.cert.get_extension(i)
if ext.get_short_name() == b'authorityInfoAccess':
v = str(ext)
m = re.search('^OCSP - URI:(.*)$', v, flags=re.MULTILINE)
if m:
return m.group(1)
return None
def main(): def main():
module = AnsibleModule( module = AnsibleModule(
@@ -848,53 +412,37 @@ def main():
module.deprecate("The 'community.crypto.openssl_certificate_info' module has been renamed to 'community.crypto.x509_certificate_info'", module.deprecate("The 'community.crypto.openssl_certificate_info' module has been renamed to 'community.crypto.x509_certificate_info'",
version='2.0.0', collection_name='community.crypto') version='2.0.0', collection_name='community.crypto')
try: if module.params['content'] is not None:
if module.params['path'] is not None: data = module.params['content'].encode('utf-8')
base_dir = os.path.dirname(module.params['path']) or '.' else:
if not os.path.isdir(base_dir): try:
with open(module.params['path'], 'rb') as f:
data = f.read()
except (IOError, OSError) as e:
module.fail_json(msg='Error while reading certificate file from disk: {0}'.format(e))
backend, module_backend = select_backend(module, module.params['select_crypto_backend'], data)
valid_at = module.params['valid_at']
if valid_at:
for k, v in valid_at.items():
if not isinstance(v, string_types):
module.fail_json( module.fail_json(
name=base_dir, msg='The value for valid_at.{0} must be of type string (got {1})'.format(k, type(v))
msg='The directory %s does not exist or the file is not a directory' % base_dir
) )
valid_at[k] = get_relative_time_option(v, 'valid_at.{0}'.format(k))
backend = module.params['select_crypto_backend'] try:
if backend == 'auto': result = module_backend.get_info()
# Detect what backend we can use
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
# If cryptography is available we'll use it not_before = module_backend.get_not_before()
if can_use_cryptography: not_after = module_backend.get_not_after()
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
# Fail if no backend has been found result['valid_at'] = dict()
if backend == 'auto': if valid_at:
module.fail_json(msg=("Can't detect any of the required Python libraries " for k, v in valid_at.items():
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format( result['valid_at'][k] = not_before <= v <= not_after
MINIMAL_CRYPTOGRAPHY_VERSION,
MINIMAL_PYOPENSSL_VERSION))
if backend == 'pyopenssl':
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
try:
getattr(crypto.X509Req, 'get_extensions')
except AttributeError:
module.fail_json(msg='You need to have PyOpenSSL>=0.15')
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
certificate = CertificateInfoPyOpenSSL(module)
elif backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
certificate = CertificateInfoCryptography(module)
result = certificate.get_info()
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=to_native(exc))

View File

@@ -125,7 +125,7 @@ certificate:
import os import os
from ansible.module_utils._text import to_native from ansible.module_utils.common.text.converters import to_native
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 (
select_backend, select_backend,

View File

@@ -370,7 +370,7 @@ import traceback
from distutils.version import LooseVersion from distutils.version import LooseVersion
from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native, to_text from ansible.module_utils.common.text.converters import to_native, to_text
from ansible_collections.community.crypto.plugins.module_utils.io import ( from ansible_collections.community.crypto.plugins.module_utils.io import (
write_file, write_file,
@@ -409,6 +409,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import
identify_pem_format, identify_pem_format,
) )
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import (
get_crl_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2' MINIMAL_CRYPTOGRAPHY_VERSION = '1.2'
CRYPTOGRAPHY_IMP_ERR = None CRYPTOGRAPHY_IMP_ERR = None
@@ -550,6 +554,19 @@ class CRL(OpenSSLObject):
except Exception as dummy: except Exception as dummy:
self.crl_content = None self.crl_content = None
self.actual_format = self.format self.actual_format = self.format
data = None
self.diff_after = self.diff_before = self._get_info(data)
def _get_info(self, data):
if data is None:
return dict()
try:
result = get_crl_info(self.module, data)
result['can_parse_crl'] = True
return result
except Exception as exc:
return dict(can_parse_crl=False)
def remove(self): def remove(self):
if self.backup: if self.backup:
@@ -580,7 +597,7 @@ class CRL(OpenSSLObject):
entry['invalidity_date_critical'], entry['invalidity_date_critical'],
) )
def check(self, perms_required=True, ignore_conversion=True): def check(self, module, perms_required=True, ignore_conversion=True):
"""Ensure the resource is in its desired state.""" """Ensure the resource is in its desired state."""
state_and_perms = super(CRL, self).check(self.module, perms_required) state_and_perms = super(CRL, self).check(self.module, perms_required)
@@ -672,15 +689,16 @@ class CRL(OpenSSLObject):
def generate(self): def generate(self):
result = None result = None
if not self.check(perms_required=False, ignore_conversion=True) or self.force: if not self.check(self.module, perms_required=False, ignore_conversion=True) or self.force:
result = self._generate_crl() result = self._generate_crl()
elif not self.check(perms_required=False, ignore_conversion=False) and self.crl: elif not self.check(self.module, perms_required=False, ignore_conversion=False) and self.crl:
if self.format == 'pem': if self.format == 'pem':
result = self.crl.public_bytes(Encoding.PEM) result = self.crl.public_bytes(Encoding.PEM)
else: else:
result = self.crl.public_bytes(Encoding.DER) result = self.crl.public_bytes(Encoding.DER)
if result is not None: if result is not None:
self.diff_after = self._get_info(result)
if self.return_content: if self.return_content:
if self.format == 'pem': if self.format == 'pem':
self.crl_content = result self.crl_content = result
@@ -692,7 +710,9 @@ class CRL(OpenSSLObject):
self.changed = True self.changed = True
file_args = self.module.load_file_common_arguments(self.module.params) file_args = self.module.load_file_common_arguments(self.module.params)
if self.module.set_fs_attributes_if_different(file_args, False): if self.module.check_file_absent_if_check_mode(file_args['path']):
self.changed = True
elif self.module.set_fs_attributes_if_different(file_args, False):
self.changed = True self.changed = True
def dump(self, check_mode=False): def dump(self, check_mode=False):
@@ -742,6 +762,10 @@ class CRL(OpenSSLObject):
if self.return_content: if self.return_content:
result['crl'] = self.crl_content result['crl'] = self.crl_content
result['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result return result
@@ -810,7 +834,7 @@ def main():
if module.params['state'] == 'present': if module.params['state'] == 'present':
if module.check_mode: if module.check_mode:
result = crl.dump(check_mode=True) result = crl.dump(check_mode=True)
result['changed'] = module.params['force'] or not crl.check() or not crl.check(ignore_conversion=False) result['changed'] = module.params['force'] or not crl.check(module) or not crl.check(module, ignore_conversion=False)
module.exit_json(**result) module.exit_json(**result)
crl.generate() crl.generate()

View File

@@ -30,6 +30,15 @@ options:
- Content of the X.509 CRL in PEM format, or Base64-encoded X.509 CRL. - Content of the X.509 CRL in PEM format, or Base64-encoded X.509 CRL.
- Either I(path) or I(content) must be specified, but not both. - Either I(path) or I(content) must be specified, but not both.
type: str type: str
list_revoked_certificates:
description:
- If set to C(false), the list of revoked certificates is not included in the result.
- This is useful when retrieving information on large CRL files. Enumerating all revoked
certificates can take some time, including serializing the result as JSON, sending it to
the Ansible controller, and decoding it again.
type: bool
default: true
version_added: 1.7.0
notes: notes:
- All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern. - All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern.
@@ -48,6 +57,12 @@ EXAMPLES = r'''
- name: Print the information - name: Print the information
ansible.builtin.debug: ansible.builtin.debug:
msg: "{{ result }}" msg: "{{ result }}"
- name: Get information on CRL without list of revoked certificates
community.crypto.x509_crl_info:
path: /etc/ssl/very-large.crl
list_revoked_certificates: false
register: result
''' '''
RETURN = r''' RETURN = r'''
@@ -87,7 +102,7 @@ digest:
sample: sha256WithRSAEncryption sample: sha256WithRSAEncryption
revoked_certificates: revoked_certificates:
description: List of certificates to be revoked. description: List of certificates to be revoked.
returned: success returned: success if I(list_revoked_certificates=true)
type: list type: list
elements: dict elements: dict
contains: contains:
@@ -134,129 +149,22 @@ revoked_certificates:
import base64 import base64
import traceback import binascii
from distutils.version import LooseVersion from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils._text import to_native
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.support import (
OpenSSLObject,
)
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.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.pem import ( from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
identify_pem_format, identify_pem_format,
) )
# crypto_utils from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import (
get_crl_info,
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 CRLError(OpenSSLObjectError):
pass
class CRLInfo(OpenSSLObject):
"""The main module implementation."""
def __init__(self, module):
super(CRLInfo, self).__init__(
module.params['path'] or '',
'present',
False,
module.check_mode
)
self.content = module.params['content']
self.module = module
self.crl = None
if self.content is None:
try:
with open(self.path, 'rb') as f:
data = f.read()
except Exception as e:
self.module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e))
else:
data = self.content.encode('utf-8')
if not identify_pem_format(data):
data = base64.b64decode(self.content)
self.crl_pem = identify_pem_format(data)
try:
if self.crl_pem:
self.crl = x509.load_pem_x509_crl(data, default_backend())
else:
self.crl = x509.load_der_x509_crl(data, default_backend())
except Exception as e:
self.module.fail_json(msg='Error while decoding CRL: {0}'.format(e))
def get_info(self):
result = {
'changed': False,
'format': 'pem' if self.crl_pem else 'der',
'last_update': None,
'next_update': None,
'digest': None,
'issuer_ordered': None,
'issuer': None,
'revoked_certificates': [],
}
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
result['revoked_certificates'] = []
for cert in self.crl:
entry = cryptography_decode_revoked_certificate(cert)
result['revoked_certificates'].append(cryptography_dump_revoked(entry))
return result
def generate(self):
# Empty method because OpenSSLObject wants this
pass
def dump(self):
# Empty method because OpenSSLObject wants this
pass
def main(): def main():
@@ -264,6 +172,7 @@ def main():
argument_spec=dict( argument_spec=dict(
path=dict(type='path'), path=dict(type='path'),
content=dict(type='str'), content=dict(type='str'),
list_revoked_certificates=dict(type='bool', default=True),
), ),
required_one_of=( required_one_of=(
['path', 'content'], ['path', 'content'],
@@ -274,13 +183,22 @@ def main():
supports_check_mode=True, supports_check_mode=True,
) )
if not CRYPTOGRAPHY_FOUND: if module.params['content'] is None:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)), try:
exception=CRYPTOGRAPHY_IMP_ERR) with open(module.params['path'], 'rb') as f:
data = f.read()
except (IOError, OSError) as e:
module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e))
else:
data = module.params['content'].encode('utf-8')
if not identify_pem_format(data):
try:
data = base64.b64decode(module.params['content'])
except (binascii.Error, TypeError) as e:
module.fail_json(msg='Error while Base64 decoding content: {0}'.format(e))
try: try:
crl = CRLInfo(module) result = get_crl_info(module, data, list_revoked_certificates=module.params['list_revoked_certificates'])
result = crl.get_info()
module.exit_json(**result) module.exit_json(**result)
except OpenSSLObjectError as e: except OpenSSLObjectError as e:
module.fail_json(msg=to_native(e)) module.fail_json(msg=to_native(e))

View File

@@ -4,11 +4,12 @@
# Copyright (c) 2016 Toshio Kuratomi <tkuratomi@ansible.com> # Copyright (c) 2016 Toshio Kuratomi <tkuratomi@ansible.com>
# Copyright (c) 2019 Ansible Project # Copyright (c) 2019 Ansible Project
# Copyright (c) 2020 Felix Fontein <felix@fontein.de> # Copyright (c) 2020 Felix Fontein <felix@fontein.de>
# Copyright (c) 2021 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)
# Parts taken from ansible.module_utils.basic and ansible.module_utils.common.warnings. # Parts taken from ansible.module_utils.basic and ansible.module_utils.common.warnings.
# NOTE: THIS MUST NOT BE USED BY A MODULE! THIS IS ONLY FOR ACTION PLUGINS! # NOTE: THIS IS ONLY FOR ACTION PLUGINS!
from __future__ import absolute_import, division, print_function from __future__ import absolute_import, division, print_function
__metaclass__ = type __metaclass__ = type
@@ -26,9 +27,6 @@ from ansible.module_utils.common._collections_compat import (
Mapping Mapping
) )
from ansible.module_utils.common.parameters import ( from ansible.module_utils.common.parameters import (
handle_aliases,
list_deprecations,
list_no_log_values,
PASS_VARS, PASS_VARS,
PASS_BOOLS, PASS_BOOLS,
) )
@@ -62,10 +60,30 @@ from ansible.module_utils.six import (
string_types, string_types,
text_type, text_type,
) )
from ansible.module_utils._text import to_native, to_text from ansible.module_utils.common.text.converters import to_native, to_text
from ansible.plugins.action import ActionBase from ansible.plugins.action import ActionBase
try:
# For ansible-core 2.11, we can use the ArgumentSpecValidator. We also import
# ModuleArgumentSpecValidator since that indicates that the 'classical' approach
# will no longer work.
from ansible.module_utils.common.arg_spec import (
ArgumentSpecValidator,
ModuleArgumentSpecValidator, # noqa
)
from ansible.module_utils.errors import UnsupportedError
HAS_ARGSPEC_VALIDATOR = True
except ImportError:
# For ansible-base 2.10 and Ansible 2.9, we need to use the 'classical' approach
from ansible.module_utils.common.parameters import (
handle_aliases,
list_deprecations,
list_no_log_values,
)
HAS_ARGSPEC_VALIDATOR = False
class _ModuleExitException(Exception): class _ModuleExitException(Exception):
def __init__(self, result): def __init__(self, result):
super(_ModuleExitException, self).__init__() super(_ModuleExitException, self).__init__()
@@ -104,54 +122,91 @@ class AnsibleActionModule(object):
self._options_context = list() self._options_context = list()
self.params = copy.deepcopy(action_plugin._task.args) self.params = copy.deepcopy(action_plugin._task.args)
self._set_fallbacks()
# append to legal_inputs and then possibly check against them
try:
self.aliases = self._handle_aliases()
except (ValueError, TypeError) as e:
# Use exceptions here because it isn't safe to call fail_json until no_log is processed
raise _ModuleExitException(dict(failed=True, msg="Module alias error: %s" % to_native(e)))
# Save parameter values that should never be logged
self.no_log_values = set() self.no_log_values = set()
self._handle_no_log_values() if HAS_ARGSPEC_VALIDATOR:
self._validator = ArgumentSpecValidator(
self.argument_spec,
self.mutually_exclusive,
self.required_together,
self.required_one_of,
self.required_if,
self.required_by,
)
self._validation_result = self._validator.validate(self.params)
self.params.update(self._validation_result.validated_parameters)
self.no_log_values.update(self._validation_result._no_log_values)
self._check_arguments() try:
error = self._validation_result.errors[0]
except IndexError:
error = None
# check exclusive early # We cannot use ModuleArgumentSpecValidator directly since it uses mechanisms for reporting
if not bypass_checks: # warnings and deprecations that do not work in plugins. This is a copy of that code adjusted
self._check_mutually_exclusive(mutually_exclusive) # for our use-case:
for d in self._validation_result._deprecations:
self.deprecate(
"Alias '{name}' is deprecated. See the module docs for more information".format(name=d['name']),
version=d.get('version'), date=d.get('date'), collection_name=d.get('collection_name'))
self._set_defaults(pre=True) for w in self._validation_result._warnings:
self.warn('Both option {option} and its alias {alias} are set.'.format(option=w['option'], alias=w['alias']))
self._CHECK_ARGUMENT_TYPES_DISPATCHER = { # Fail for validation errors, even in check mode
'str': self._check_type_str, if error:
'list': self._check_type_list, msg = self._validation_result.errors.msg
'dict': self._check_type_dict, if isinstance(error, UnsupportedError):
'bool': self._check_type_bool, msg = "Unsupported parameters for ({name}) {kind}: {msg}".format(name=self._name, kind='module', msg=msg)
'int': self._check_type_int,
'float': self._check_type_float,
'path': self._check_type_path,
'raw': self._check_type_raw,
'jsonarg': self._check_type_jsonarg,
'json': self._check_type_jsonarg,
'bytes': self._check_type_bytes,
'bits': self._check_type_bits,
}
if not bypass_checks:
self._check_required_arguments()
self._check_argument_types()
self._check_argument_values()
self._check_required_together(required_together)
self._check_required_one_of(required_one_of)
self._check_required_if(required_if)
self._check_required_by(required_by)
self._set_defaults(pre=False) self.fail_json(msg=msg)
else:
self._set_fallbacks()
# deal with options sub-spec # append to legal_inputs and then possibly check against them
self._handle_options() try:
self.aliases = self._handle_aliases()
except (ValueError, TypeError) as e:
# Use exceptions here because it isn't safe to call fail_json until no_log is processed
raise _ModuleExitException(dict(failed=True, msg="Module alias error: %s" % to_native(e)))
# Save parameter values that should never be logged
self._handle_no_log_values()
self._check_arguments()
# check exclusive early
if not bypass_checks:
self._check_mutually_exclusive(mutually_exclusive)
self._set_defaults(pre=True)
self._CHECK_ARGUMENT_TYPES_DISPATCHER = {
'str': self._check_type_str,
'list': check_type_list,
'dict': check_type_dict,
'bool': check_type_bool,
'int': check_type_int,
'float': check_type_float,
'path': check_type_path,
'raw': check_type_raw,
'jsonarg': check_type_jsonarg,
'json': check_type_jsonarg,
'bytes': check_type_bytes,
'bits': check_type_bits,
}
if not bypass_checks:
self._check_required_arguments()
self._check_argument_types()
self._check_argument_values()
self._check_required_together(required_together)
self._check_required_one_of(required_one_of)
self._check_required_if(required_if)
self._check_required_by(required_by)
self._set_defaults(pre=False)
# deal with options sub-spec
self._handle_options()
def _handle_aliases(self, spec=None, param=None, option_prefix=''): def _handle_aliases(self, spec=None, param=None, option_prefix=''):
if spec is None: if spec is None:
@@ -413,36 +468,6 @@ class AnsibleActionModule(object):
self.warn(to_native(msg)) self.warn(to_native(msg))
return to_native(value, errors='surrogate_or_strict') return to_native(value, errors='surrogate_or_strict')
def _check_type_list(self, value):
return check_type_list(value)
def _check_type_dict(self, value):
return check_type_dict(value)
def _check_type_bool(self, value):
return check_type_bool(value)
def _check_type_int(self, value):
return check_type_int(value)
def _check_type_float(self, value):
return check_type_float(value)
def _check_type_path(self, value):
return check_type_path(value)
def _check_type_jsonarg(self, value):
return check_type_jsonarg(value)
def _check_type_raw(self, value):
return check_type_raw(value)
def _check_type_bytes(self, value):
return check_type_bytes(value)
def _check_type_bits(self, value):
return check_type_bits(value)
def _handle_options(self, argument_spec=None, params=None, prefix=''): def _handle_options(self, argument_spec=None, params=None, prefix=''):
''' deal with options to create sub spec ''' ''' deal with options to create sub spec '''
if argument_spec is None: if argument_spec is None:

5
tests/config.yml Normal file
View File

@@ -0,0 +1,5 @@
---
# See template for more information:
# https://github.com/ansible/ansible/blob/devel/test/lib/ansible_test/config/config.yml
modules:
python_requires: default

View File

@@ -1,2 +1,14 @@
shippable/cloud/group1 shippable/cloud/group1
cloud/acme cloud/acme
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
# (https://github.com/ansible/ansible/issues/75711)
# shippable/posix/group1
# Skip all VMs, since we cannot talk to the ACME simulator from these:
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
# skip/aix
# skip/freebsd
# skip/macos
# skip/osx
# skip/rhel

View File

@@ -1,2 +1,3 @@
dependencies: dependencies:
- setup_acme - setup_acme
- setup_remote_tmp_dir

View File

@@ -1,25 +1,34 @@
- name: Generate account keys - block:
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/{{ item }}.pem" - name: Generate account keys
loop: openssl_privatekey:
- accountkey path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
- accountkey2 passphrase: "{{ item.pass | default(omit) | default(omit, true) }}"
- accountkey3 cipher: "{{ 'auto' if (item.pass | default(false)) else omit }}"
- accountkey4 type: ECC
- accountkey5 curve: secp256r1
force: true
loop: "{{ account_keys }}"
- name: Parse account keys (to ease debugging some test failures) - name: Parse account keys (to ease debugging some test failures)
command: "{{ openssl_binary }} ec -in {{ output_dir }}/{{ item }}.pem -noout -text" openssl_privatekey_info:
loop: path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
- accountkey passphrase: "{{ item.pass | default(omit) | default(omit, true) }}"
- accountkey2 return_private_key_data: true
- accountkey3 loop: "{{ account_keys }}"
- accountkey4
- accountkey5 vars:
account_keys:
- name: accountkey
- name: accountkey2
pass: "{{ 'hunter2' if select_crypto_backend != 'openssl' else '' }}"
- name: accountkey3
- name: accountkey4
- name: accountkey5
- name: Do not try to create account - name: Do not try to create account
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -31,7 +40,7 @@
- name: Create it now (check mode, diff) - name: Create it now (check mode, diff)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -47,7 +56,7 @@
- name: Create it now - name: Create it now
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -61,7 +70,7 @@
- name: Create it now (idempotent) - name: Create it now (idempotent)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -72,10 +81,15 @@
- mailto:example@example.org - mailto:example@example.org
register: account_created_idempotent register: account_created_idempotent
- name: Read account key
slurp:
src: '{{ remote_tmp_dir }}/accountkey.pem'
register: slurp
- name: Change email address (check mode, diff) - name: Change email address (check mode, diff)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}" account_key_content: "{{ slurp.content | b64decode }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -90,7 +104,7 @@
- name: Change email address - name: Change email address
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}" account_key_content: "{{ slurp.content | b64decode }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -103,7 +117,7 @@
- name: Change email address (idempotent) - name: Change email address (idempotent)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
account_uri: "{{ account_created.account_uri }}" account_uri: "{{ account_created.account_uri }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
@@ -117,7 +131,7 @@
- name: Cannot access account with wrong URI - name: Cannot access account with wrong URI
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
account_uri: "{{ account_created.account_uri ~ '12345thisdoesnotexist' }}" account_uri: "{{ account_created.account_uri ~ '12345thisdoesnotexist' }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
@@ -130,7 +144,7 @@
- name: Clear contact email addresses (check mode, diff) - name: Clear contact email addresses (check mode, diff)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -144,7 +158,7 @@
- name: Clear contact email addresses - name: Clear contact email addresses
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -156,7 +170,7 @@
- name: Clear contact email addresses (idempotent) - name: Clear contact email addresses (idempotent)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -168,11 +182,12 @@
- name: Change account key (check mode, diff) - name: Change account key (check mode, diff)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
new_account_key_src: "{{ output_dir }}/accountkey2.pem" new_account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
new_account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
state: changed_key state: changed_key
contact: contact:
- mailto:example@example.com - mailto:example@example.com
@@ -183,11 +198,12 @@
- name: Change account key - name: Change account key
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
new_account_key_src: "{{ output_dir }}/accountkey2.pem" new_account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
new_account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
state: changed_key state: changed_key
contact: contact:
- mailto:example@example.com - mailto:example@example.com
@@ -196,7 +212,8 @@
- name: Deactivate account (check mode, diff) - name: Deactivate account (check mode, diff)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey2.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -208,7 +225,8 @@
- name: Deactivate account - name: Deactivate account
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey2.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -218,7 +236,8 @@
- name: Deactivate account (idempotent) - name: Deactivate account (idempotent)
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey2.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -228,7 +247,8 @@
- name: Do not try to create account II - name: Do not try to create account II
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey2.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -240,7 +260,7 @@
- name: Do not try to create account III - name: Do not try to create account III
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -252,7 +272,7 @@
- name: Create account with External Account Binding - name: Create account with External Account Binding
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/{{ item.account }}.pem" account_key_src: "{{ remote_tmp_dir }}/{{ item.account }}.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no

View File

@@ -17,12 +17,12 @@
- name: Remove output directory - name: Remove output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: absent state: absent
- name: Re-create output directory - name: Re-create output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: directory state: directory
- block: - block:

View File

@@ -1,2 +1,14 @@
shippable/cloud/group1 shippable/cloud/group1
cloud/acme cloud/acme
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
# (https://github.com/ansible/ansible/issues/75711)
# shippable/posix/group1
# Skip all VMs, since we cannot talk to the ACME simulator from these:
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
# skip/aix
# skip/freebsd
# skip/macos
# skip/osx
# skip/rhel

View File

@@ -1,2 +1,3 @@
dependencies: dependencies:
- setup_acme - setup_acme
- setup_remote_tmp_dir

View File

@@ -1,17 +1,28 @@
--- ---
- name: Generate account key - block:
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem" - name: Generate account keys
openssl_privatekey:
path: "{{ remote_tmp_dir }}/{{ item }}.pem"
type: ECC
curve: secp256r1
force: true
loop: "{{ account_keys }}"
- name: Generate second account key - name: Parse account keys (to ease debugging some test failures)
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey2.pem" openssl_privatekey_info:
path: "{{ remote_tmp_dir }}/{{ item }}.pem"
return_private_key_data: true
loop: "{{ account_keys }}"
- name: Parse account key (to ease debugging some test failures) vars:
command: "{{ openssl_binary }} ec -in {{ output_dir }}/accountkey.pem -noout -text" account_keys:
- accountkey
- accountkey2
- name: Check that account does not exist - name: Check that account does not exist
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -20,7 +31,7 @@
- name: Create it now - name: Create it now
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -33,16 +44,21 @@
- name: Check that account exists - name: Check that account exists
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
register: account_created register: account_created
- name: Read account key
slurp:
src: '{{ remote_tmp_dir }}/accountkey.pem'
register: slurp
- name: Clear email address - name: Clear email address
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}" account_key_content: "{{ slurp.content | b64decode }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -53,7 +69,7 @@
- name: Check that account was modified - name: Check that account was modified
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -63,7 +79,7 @@
- name: Check with wrong account URI - name: Check with wrong account URI
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -73,7 +89,7 @@
- name: Check with wrong account key - name: Check with wrong account key
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/accountkey2.pem" account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no

View File

@@ -17,12 +17,12 @@
- name: Remove output directory - name: Remove output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: absent state: absent
- name: Re-create output directory - name: Re-create output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: directory state: directory
- block: - block:

View File

@@ -1,2 +1,14 @@
shippable/cloud/group1 shippable/cloud/group1
cloud/acme cloud/acme
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
# (https://github.com/ansible/ansible/issues/75711)
# shippable/posix/group1
# Skip all VMs, since we cannot talk to the ACME simulator from these:
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
# skip/aix
# skip/freebsd
# skip/macos
# skip/osx
# skip/rhel

View File

@@ -1,2 +1,5 @@
dependencies: dependencies:
- setup_acme - setup_acme
- setup_pyopenssl # needed for Ubuntu 16.04
- setup_remote_tmp_dir
- prepare_jinja2_compat

View File

@@ -1,11 +1,26 @@
--- ---
## SET UP ACCOUNT KEYS ######################################################################## ## SET UP ACCOUNT KEYS ########################################################################
- name: Create ECC256 account key - block:
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem" - name: Generate account keys
- name: Create ECC384 account key openssl_privatekey:
command: "{{ openssl_binary }} ecparam -name secp384r1 -genkey -out {{ output_dir }}/account-ec384.pem" path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
- name: Create RSA account key type: "{{ item.type }}"
command: "{{ openssl_binary }} genrsa -out {{ output_dir }}/account-rsa.pem {{ default_rsa_key_size }}" size: "{{ item.size | default(omit) }}"
curve: "{{ item.curve | default(omit) }}"
force: true
loop: "{{ account_keys }}"
vars:
account_keys:
- name: account-ec256
type: ECC
curve: secp256r1
- name: account-ec384
type: ECC
curve: secp384r1
- name: account-rsa
type: RSA
size: "{{ default_rsa_key_size }}"
## SET UP ACCOUNTS ############################################################################ ## SET UP ACCOUNTS ############################################################################
- name: Make sure ECC256 account hasn't been created yet - name: Make sure ECC256 account hasn't been created yet
acme_account: acme_account:
@@ -13,15 +28,19 @@
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
account_key_src: "{{ output_dir }}/account-ec256.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
state: absent state: absent
- name: Read account key (EC384)
slurp:
src: '{{ remote_tmp_dir }}/account-ec384.pem'
register: slurp
- name: Create ECC384 account - name: Create ECC384 account
acme_account: acme_account:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
account_key_content: "{{ lookup('file', output_dir ~ '/account-ec384.pem') }}" account_key_content: "{{ slurp.content | b64decode }}"
state: present state: present
allow_creation: yes allow_creation: yes
terms_agreed: yes terms_agreed: yes
@@ -34,7 +53,7 @@
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
account_key_src: "{{ output_dir }}/account-rsa.pem" account_key_src: "{{ remote_tmp_dir }}/account-rsa.pem"
state: present state: present
allow_creation: yes allow_creation: yes
terms_agreed: yes terms_agreed: yes
@@ -72,6 +91,7 @@
vars: vars:
certgen_title: Certificate 2 certgen_title: Certificate 2
certificate_name: cert-2 certificate_name: cert-2
certificate_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else '' }}"
key_type: ec256 key_type: ec256
subject_alt_name: "DNS:*.example.com,DNS:example.com" subject_alt_name: "DNS:*.example.com,DNS:example.com"
subject_alt_name_critical: yes subject_alt_name_critical: yes
@@ -99,6 +119,10 @@
set_fact: set_fact:
cert_2_obtain_results: "{{ certificate_obtain_result }}" cert_2_obtain_results: "{{ certificate_obtain_result }}"
cert_2_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" cert_2_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
- name: Read account key (RSA)
slurp:
src: '{{ remote_tmp_dir }}/account-rsa.pem'
register: slurp_account_key
- name: Obtain cert 3 - name: Obtain cert 3
include_tasks: obtain-cert.yml include_tasks: obtain-cert.yml
vars: vars:
@@ -107,7 +131,7 @@
key_type: ec384 key_type: ec384
subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com" subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com"
subject_alt_name_critical: no subject_alt_name_critical: no
account_key_content: "{{ lookup('file', output_dir ~ '/account-rsa.pem') }}" account_key_content: "{{ slurp_account_key.content | b64decode }}"
challenge: dns-01 challenge: dns-01
modify_account: no modify_account: no
deactivate_authzs: no deactivate_authzs: no
@@ -215,6 +239,10 @@
set_fact: set_fact:
cert_5_recreate_2: "{{ challenge_data is changed }}" cert_5_recreate_2: "{{ challenge_data is changed }}"
cert_5c_obtain_results: "{{ certificate_obtain_result }}" cert_5c_obtain_results: "{{ certificate_obtain_result }}"
- name: Read account key (EC384)
slurp:
src: '{{ remote_tmp_dir }}/account-ec384.pem'
register: slurp_account_key
- name: Obtain cert 5 (should again by force) - name: Obtain cert 5 (should again by force)
include_tasks: obtain-cert.yml include_tasks: obtain-cert.yml
vars: vars:
@@ -223,7 +251,7 @@
key_type: ec521 key_type: ec521
subject_alt_name: "DNS:t2.example.com" subject_alt_name: "DNS:t2.example.com"
subject_alt_name_critical: no subject_alt_name_critical: no
account_key_content: "{{ lookup('file', output_dir ~ '/account-ec384.pem') }}" account_key_content: "{{ slurp_account_key.content | b64decode }}"
challenge: http-01 challenge: http-01
modify_account: no modify_account: no
deactivate_authzs: yes deactivate_authzs: yes
@@ -236,189 +264,204 @@
set_fact: set_fact:
cert_5_recreate_3: "{{ challenge_data is changed }}" cert_5_recreate_3: "{{ challenge_data is changed }}"
cert_5d_obtain_results: "{{ certificate_obtain_result }}" cert_5d_obtain_results: "{{ certificate_obtain_result }}"
- name: Obtain cert 6 - block:
include_tasks: obtain-cert.yml - name: Obtain cert 6
vars: include_tasks: obtain-cert.yml
certgen_title: Certificate 6 vars:
certificate_name: cert-6 certgen_title: Certificate 6
key_type: rsa certificate_name: cert-6
rsa_bits: "{{ default_rsa_key_size }}" key_type: rsa
subject_alt_name: "DNS:example.org" rsa_bits: "{{ default_rsa_key_size }}"
subject_alt_name_critical: no subject_alt_name: "DNS:example.org"
account_key: account-ec256 subject_alt_name_critical: no
challenge: tls-alpn-01 account_key: account-ec256
modify_account: yes challenge: tls-alpn-01
deactivate_authzs: no modify_account: yes
force: no deactivate_authzs: no
remaining_days: 10 force: no
terms_agreed: yes remaining_days: 10
account_email: "example@example.org" terms_agreed: yes
acme_expected_root_number: 0 account_email: "example@example.org"
select_chain: acme_expected_root_number: 0
# All intermediates have the same subject key identifier, so always select_chain:
# the first chain will be found, and we need a second condition to # All intermediates have the same subject key identifier, so always
# make sure that the first condition actually works. (The second # the first chain will be found, and we need a second condition to
# condition has been tested above.) # make sure that the first condition actually works. (The second
- test_certificates: first # condition has been tested above.)
subject_key_identifier: "{{ acme_intermediates[0].subject_key_identifier }}" - test_certificates: first
- test_certificates: last subject_key_identifier: "{{ acme_intermediates[0].subject_key_identifier }}"
issuer: "{{ acme_roots[1].subject }}" - test_certificates: last
use_csr_content: true issuer: "{{ acme_roots[1].subject }}"
- name: Store obtain results for cert 6 use_csr_content: true
set_fact: - name: Store obtain results for cert 6
cert_6_obtain_results: "{{ certificate_obtain_result }}" set_fact:
cert_6_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" cert_6_obtain_results: "{{ certificate_obtain_result }}"
- name: Obtain cert 7 cert_6_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
include_tasks: obtain-cert.yml when: acme_intermediates[0].subject_key_identifier is defined
vars: - block:
certgen_title: Certificate 7 - name: Obtain cert 7
certificate_name: cert-7 include_tasks: obtain-cert.yml
key_type: rsa vars:
rsa_bits: "{{ default_rsa_key_size }}" certgen_title: Certificate 7
subject_alt_name: certificate_name: cert-7
- "IP:127.0.0.1" key_type: rsa
# - "IP:::1" rsa_bits: "{{ default_rsa_key_size }}"
subject_alt_name_critical: no subject_alt_name:
account_key: account-ec256 - "IP:127.0.0.1"
challenge: http-01 # - "IP:::1"
modify_account: yes subject_alt_name_critical: no
deactivate_authzs: no account_key: account-ec256
force: no challenge: http-01
remaining_days: 10 modify_account: yes
terms_agreed: yes deactivate_authzs: no
account_email: "example@example.org" force: no
acme_expected_root_number: 2 remaining_days: 10
select_chain: terms_agreed: yes
- test_certificates: last account_email: "example@example.org"
authority_key_identifier: "{{ acme_roots[2].subject_key_identifier }}" acme_expected_root_number: 2
use_csr_content: false select_chain:
- name: Store obtain results for cert 7 - test_certificates: last
set_fact: authority_key_identifier: "{{ acme_roots[2].subject_key_identifier }}"
cert_7_obtain_results: "{{ certificate_obtain_result }}" use_csr_content: false
cert_7_alternate: "{{ 2 if select_crypto_backend == 'cryptography' else 0 }}" - name: Store obtain results for cert 7
- name: Obtain cert 8 set_fact:
include_tasks: obtain-cert.yml cert_7_obtain_results: "{{ certificate_obtain_result }}"
vars: cert_7_alternate: "{{ 2 if select_crypto_backend == 'cryptography' else 0 }}"
certgen_title: Certificate 8 when: acme_roots[2].subject_key_identifier is defined
certificate_name: cert-8 - block:
key_type: rsa - name: Obtain cert 8
rsa_bits: "{{ default_rsa_key_size }}" include_tasks: obtain-cert.yml
subject_alt_name: vars:
- "IP:127.0.0.1" certgen_title: Certificate 8
# IPv4 only since our test validation server doesn't work certificate_name: cert-8
# with IPv6 (thanks to Python's socketserver). key_type: rsa
subject_alt_name_critical: no rsa_bits: "{{ default_rsa_key_size }}"
account_key: account-ec256 subject_alt_name:
challenge: tls-alpn-01 - "IP:127.0.0.1"
challenge_alpn_tls: acme_challenge_cert_helper # IPv4 only since our test validation server doesn't work
modify_account: yes # with IPv6 (thanks to Python's socketserver).
deactivate_authzs: no subject_alt_name_critical: no
force: no account_key: account-ec256
remaining_days: 10 challenge: tls-alpn-01
terms_agreed: yes challenge_alpn_tls: acme_challenge_cert_helper
account_email: "example@example.org" modify_account: yes
use_csr_content: true deactivate_authzs: no
- name: Store obtain results for cert 8 force: no
set_fact: remaining_days: 10
cert_8_obtain_results: "{{ certificate_obtain_result }}" terms_agreed: yes
cert_8_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" account_email: "example@example.org"
use_csr_content: true
- name: Store obtain results for cert 8
set_fact:
cert_8_obtain_results: "{{ certificate_obtain_result }}"
cert_8_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
when: cryptography_version.stdout is version('1.3', '>=')
## DISSECT CERTIFICATES ####################################################################### ## DISSECT CERTIFICATES #######################################################################
# Make sure certificates are valid. Root certificate for Pebble equals the chain certificate. # Make sure certificates are valid. Root certificate for Pebble equals the chain certificate.
- name: Verifying cert 1 - name: Verifying cert 1
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-1-root.pem" -untrusted "{{ output_dir }}/cert-1-chain.pem" "{{ output_dir }}/cert-1.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-1-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-1-chain.pem" "{{ remote_tmp_dir }}/cert-1.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_1_valid register: cert_1_valid
- name: Verifying cert 2 - name: Verifying cert 2
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-2-root.pem" -untrusted "{{ output_dir }}/cert-2-chain.pem" "{{ output_dir }}/cert-2.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-2-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-2-chain.pem" "{{ remote_tmp_dir }}/cert-2.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_2_valid register: cert_2_valid
- name: Verifying cert 3 - name: Verifying cert 3
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-3-root.pem" -untrusted "{{ output_dir }}/cert-3-chain.pem" "{{ output_dir }}/cert-3.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-3-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-3-chain.pem" "{{ remote_tmp_dir }}/cert-3.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_3_valid register: cert_3_valid
- name: Verifying cert 4 - name: Verifying cert 4
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-4-root.pem" -untrusted "{{ output_dir }}/cert-4-chain.pem" "{{ output_dir }}/cert-4.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-4-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-4-chain.pem" "{{ remote_tmp_dir }}/cert-4.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_4_valid register: cert_4_valid
- name: Verifying cert 5 - name: Verifying cert 5
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-5-root.pem" -untrusted "{{ output_dir }}/cert-5-chain.pem" "{{ output_dir }}/cert-5.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-5-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-5-chain.pem" "{{ remote_tmp_dir }}/cert-5.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_5_valid register: cert_5_valid
- name: Verifying cert 6 - name: Verifying cert 6
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-6-root.pem" -untrusted "{{ output_dir }}/cert-6-chain.pem" "{{ output_dir }}/cert-6.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-6-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-6-chain.pem" "{{ remote_tmp_dir }}/cert-6.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_6_valid register: cert_6_valid
when: acme_intermediates[0].subject_key_identifier is defined
- name: Verifying cert 7 - name: Verifying cert 7
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-7-root.pem" -untrusted "{{ output_dir }}/cert-7-chain.pem" "{{ output_dir }}/cert-7.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-7-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-7-chain.pem" "{{ remote_tmp_dir }}/cert-7.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_7_valid register: cert_7_valid
when: acme_roots[2].subject_key_identifier is defined
- name: Verifying cert 8 - name: Verifying cert 8
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-8-root.pem" -untrusted "{{ output_dir }}/cert-8-chain.pem" "{{ output_dir }}/cert-8.pem"' command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-8-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-8-chain.pem" "{{ remote_tmp_dir }}/cert-8.pem"'
ignore_errors: yes ignore_errors: yes
register: cert_8_valid register: cert_8_valid
when: cryptography_version.stdout is version('1.3', '>=')
# Dump certificate info # Dump certificate info
- name: Dumping cert 1 - name: Dumping cert 1
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-1.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-1.pem" -noout -text'
register: cert_1_text register: cert_1_text
- name: Dumping cert 2 - name: Dumping cert 2
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-2.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-2.pem" -noout -text'
register: cert_2_text register: cert_2_text
- name: Dumping cert 3 - name: Dumping cert 3
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-3.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-3.pem" -noout -text'
register: cert_3_text register: cert_3_text
- name: Dumping cert 4 - name: Dumping cert 4
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-4.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-4.pem" -noout -text'
register: cert_4_text register: cert_4_text
- name: Dumping cert 5 - name: Dumping cert 5
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-5.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-5.pem" -noout -text'
register: cert_5_text register: cert_5_text
- name: Dumping cert 6 - name: Dumping cert 6
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-6.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-6.pem" -noout -text'
register: cert_6_text register: cert_6_text
when: acme_intermediates[0].subject_key_identifier is defined
- name: Dumping cert 7 - name: Dumping cert 7
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-7.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-7.pem" -noout -text'
register: cert_7_text register: cert_7_text
when: acme_roots[2].subject_key_identifier is defined
- name: Dumping cert 8 - name: Dumping cert 8
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-8.pem" -noout -text' command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-8.pem" -noout -text'
register: cert_8_text register: cert_8_text
when: cryptography_version.stdout is version('1.3', '>=')
# Dump certificate info # Dump certificate info
- name: Dumping cert 1 - name: Dumping cert 1
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-1.pem" path: "{{ remote_tmp_dir }}/cert-1.pem"
register: cert_1_info register: cert_1_info
- name: Dumping cert 2 - name: Dumping cert 2
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-2.pem" path: "{{ remote_tmp_dir }}/cert-2.pem"
register: cert_2_info register: cert_2_info
- name: Dumping cert 3 - name: Dumping cert 3
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-3.pem" path: "{{ remote_tmp_dir }}/cert-3.pem"
register: cert_3_info register: cert_3_info
- name: Dumping cert 4 - name: Dumping cert 4
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-4.pem" path: "{{ remote_tmp_dir }}/cert-4.pem"
register: cert_4_info register: cert_4_info
- name: Dumping cert 5 - name: Dumping cert 5
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-5.pem" path: "{{ remote_tmp_dir }}/cert-5.pem"
register: cert_5_info register: cert_5_info
- name: Dumping cert 6 - name: Dumping cert 6
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-6.pem" path: "{{ remote_tmp_dir }}/cert-6.pem"
register: cert_6_info register: cert_6_info
when: acme_intermediates[0].subject_key_identifier is defined
- name: Dumping cert 7 - name: Dumping cert 7
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-7.pem" path: "{{ remote_tmp_dir }}/cert-7.pem"
register: cert_7_info register: cert_7_info
when: acme_roots[2].subject_key_identifier is defined
- name: Dumping cert 8 - name: Dumping cert 8
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/cert-8.pem" path: "{{ remote_tmp_dir }}/cert-8.pem"
register: cert_8_info register: cert_8_info
when: cryptography_version.stdout is version('1.3', '>=')
## GET ACCOUNT ORDERS ######################################################################### ## GET ACCOUNT ORDERS #########################################################################
- name: Don't retrieve orders - name: Don't retrieve orders
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/account-ec256.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -427,7 +470,7 @@
- name: Retrieve orders as URL list (1/2) - name: Retrieve orders as URL list (1/2)
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/account-ec256.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -436,7 +479,7 @@
- name: Retrieve orders as URL list (2/2) - name: Retrieve orders as URL list (2/2)
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/account-ec384.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec384.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -445,7 +488,7 @@
- name: Retrieve orders as object list (1/2) - name: Retrieve orders as object list (1/2)
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/account-ec256.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -454,7 +497,7 @@
- name: Retrieve orders as object list (2/2) - name: Retrieve orders as object list (2/2)
acme_account_info: acme_account_info:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/account-ec384.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec384.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no

View File

@@ -8,38 +8,48 @@
- name: Obtain root and intermediate certificates - name: Obtain root and intermediate certificates
get_url: get_url:
url: "http://{{ acme_host }}:5000/{{ item.0 }}-certificate-for-ca/{{ item.1 }}" url: "http://{{ acme_host }}:5000/{{ item.0 }}-certificate-for-ca/{{ item.1 }}"
dest: "{{ output_dir }}/acme-{{ item.0 }}-{{ item.1 }}.pem" dest: "{{ remote_tmp_dir }}/acme-{{ item.0 }}-{{ item.1 }}.pem"
loop: "{{ query('nested', types, root_numbers) }}" loop: "{{ query('nested', types, root_numbers) }}"
- name: Analyze root certificates - name: Analyze root certificates
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/acme-root-{{ item }}.pem" path: "{{ remote_tmp_dir }}/acme-root-{{ item }}.pem"
loop: "{{ root_numbers }}" loop: "{{ root_numbers }}"
register: acme_roots register: acme_roots
- name: Analyze intermediate certificates - name: Analyze intermediate certificates
x509_certificate_info: x509_certificate_info:
path: "{{ output_dir }}/acme-intermediate-{{ item }}.pem" path: "{{ remote_tmp_dir }}/acme-intermediate-{{ item }}.pem"
loop: "{{ root_numbers }}" loop: "{{ root_numbers }}"
register: acme_intermediates register: acme_intermediates
- set_fact: - name: Read root certificates
x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}" slurp:
y__: "{{ lookup('file', output_dir ~ '/acme-root-' ~ item.item ~ '.pem', rstrip=False) }}" src: "{{ remote_tmp_dir ~ '/acme-root-' ~ item ~ '.pem' }}"
loop: "{{ acme_roots.results }}" loop: "{{ root_numbers }}"
register: acme_roots_tmp register: slurp_roots
- set_fact:
x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}"
loop: "{{ acme_roots.results }}"
register: acme_roots_tmp
- name: Read intermediate certificates
slurp:
src: "{{ remote_tmp_dir ~ '/acme-intermediate-' ~ item ~ '.pem' }}"
loop: "{{ root_numbers }}"
register: slurp_intermediates
- set_fact: - set_fact:
x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}" x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}"
y__: "{{ lookup('file', output_dir ~ '/acme-intermediate-' ~ item.item ~ '.pem', rstrip=False) }}"
loop: "{{ acme_intermediates.results }}" loop: "{{ acme_intermediates.results }}"
register: acme_intermediates_tmp register: acme_intermediates_tmp
- set_fact: - set_fact:
acme_roots: "{{ acme_roots_tmp.results | map(attribute='ansible_facts.x__') | list }}" acme_roots: "{{ acme_roots_tmp.results | map(attribute='ansible_facts.x__') | list }}"
acme_root_certs: "{{ acme_roots_tmp.results | map(attribute='ansible_facts.y__') | list }}" acme_root_certs: "{{ slurp_roots.results | map(attribute='content') | map('b64decode') | list }}"
acme_intermediates: "{{ acme_intermediates_tmp.results | map(attribute='ansible_facts.x__') | list }}" acme_intermediates: "{{ acme_intermediates_tmp.results | map(attribute='ansible_facts.x__') | list }}"
acme_intermediate_certs: "{{ acme_intermediates_tmp.results | map(attribute='ansible_facts.y__') | list }}" acme_intermediate_certs: "{{ slurp_intermediates.results | map(attribute='content') | map('b64decode') | list }}"
vars: vars:
types: types:
@@ -88,12 +98,12 @@
- name: Remove output directory - name: Remove output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: absent state: absent
- name: Re-create output directory - name: Re-create output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: directory state: directory
- block: - block:

View File

@@ -7,6 +7,14 @@
assert: assert:
that: that:
- "'DNS:example.com' in cert_1_text.stdout" - "'DNS:example.com' in cert_1_text.stdout"
- name: Read certificate 1 files
slurp:
src: '{{ remote_tmp_dir }}/{{ item }}'
loop:
- cert-1.pem
- cert-1-chain.pem
- cert-1-fullchain.pem
register: slurp
- name: Check that certificate 1 retrieval got all chains - name: Check that certificate 1 retrieval got all chains
assert: assert:
that: that:
@@ -15,9 +23,9 @@
- "'cert' in cert_1_obtain_results.all_chains[cert_1_alternate | int]" - "'cert' in cert_1_obtain_results.all_chains[cert_1_alternate | int]"
- "'chain' in cert_1_obtain_results.all_chains[cert_1_alternate | int]" - "'chain' in cert_1_obtain_results.all_chains[cert_1_alternate | int]"
- "'full_chain' in cert_1_obtain_results.all_chains[cert_1_alternate | int]" - "'full_chain' in cert_1_obtain_results.all_chains[cert_1_alternate | int]"
- "lookup('file', output_dir ~ '/cert-1.pem', rstrip=False) == cert_1_obtain_results.all_chains[cert_1_alternate | int].cert" - "(slurp.results[0].content | b64decode) == cert_1_obtain_results.all_chains[cert_1_alternate | int].cert"
- "lookup('file', output_dir ~ '/cert-1-chain.pem', rstrip=False) == cert_1_obtain_results.all_chains[cert_1_alternate | int].chain" - "(slurp.results[1].content | b64decode) == cert_1_obtain_results.all_chains[cert_1_alternate | int].chain"
- "lookup('file', output_dir ~ '/cert-1-fullchain.pem', rstrip=False) == cert_1_obtain_results.all_chains[cert_1_alternate | int].full_chain" - "(slurp.results[2].content | b64decode) == cert_1_obtain_results.all_chains[cert_1_alternate | int].full_chain"
- name: Check that certificate 2 is valid - name: Check that certificate 2 is valid
assert: assert:
@@ -28,6 +36,14 @@
that: that:
- "'DNS:*.example.com' in cert_2_text.stdout" - "'DNS:*.example.com' in cert_2_text.stdout"
- "'DNS:example.com' in cert_2_text.stdout" - "'DNS:example.com' in cert_2_text.stdout"
- name: Read certificate 2 files
slurp:
src: '{{ remote_tmp_dir }}/{{ item }}'
loop:
- cert-2.pem
- cert-2-chain.pem
- cert-2-fullchain.pem
register: slurp
- name: Check that certificate 1 retrieval got all chains - name: Check that certificate 1 retrieval got all chains
assert: assert:
that: that:
@@ -36,9 +52,9 @@
- "'cert' in cert_2_obtain_results.all_chains[cert_2_alternate | int]" - "'cert' in cert_2_obtain_results.all_chains[cert_2_alternate | int]"
- "'chain' in cert_2_obtain_results.all_chains[cert_2_alternate | int]" - "'chain' in cert_2_obtain_results.all_chains[cert_2_alternate | int]"
- "'full_chain' in cert_2_obtain_results.all_chains[cert_2_alternate | int]" - "'full_chain' in cert_2_obtain_results.all_chains[cert_2_alternate | int]"
- "lookup('file', output_dir ~ '/cert-2.pem', rstrip=False) == cert_2_obtain_results.all_chains[cert_2_alternate | int].cert" - "(slurp.results[0].content | b64decode) == cert_2_obtain_results.all_chains[cert_2_alternate | int].cert"
- "lookup('file', output_dir ~ '/cert-2-chain.pem', rstrip=False) == cert_2_obtain_results.all_chains[cert_2_alternate | int].chain" - "(slurp.results[1].content | b64decode) == cert_2_obtain_results.all_chains[cert_2_alternate | int].chain"
- "lookup('file', output_dir ~ '/cert-2-fullchain.pem', rstrip=False) == cert_2_obtain_results.all_chains[cert_2_alternate | int].full_chain" - "(slurp.results[2].content | b64decode) == cert_2_obtain_results.all_chains[cert_2_alternate | int].full_chain"
- name: Check that certificate 3 is valid - name: Check that certificate 3 is valid
assert: assert:
@@ -50,6 +66,14 @@
- "'DNS:*.example.com' in cert_3_text.stdout" - "'DNS:*.example.com' in cert_3_text.stdout"
- "'DNS:example.org' in cert_3_text.stdout" - "'DNS:example.org' in cert_3_text.stdout"
- "'DNS:t1.example.com' in cert_3_text.stdout" - "'DNS:t1.example.com' in cert_3_text.stdout"
- name: Read certificate 3 files
slurp:
src: '{{ remote_tmp_dir }}/{{ item }}'
loop:
- cert-3.pem
- cert-3-chain.pem
- cert-3-fullchain.pem
register: slurp
- name: Check that certificate 1 retrieval got all chains - name: Check that certificate 1 retrieval got all chains
assert: assert:
that: that:
@@ -58,9 +82,9 @@
- "'cert' in cert_3_obtain_results.all_chains[cert_3_alternate | int]" - "'cert' in cert_3_obtain_results.all_chains[cert_3_alternate | int]"
- "'chain' in cert_3_obtain_results.all_chains[cert_3_alternate | int]" - "'chain' in cert_3_obtain_results.all_chains[cert_3_alternate | int]"
- "'full_chain' in cert_3_obtain_results.all_chains[cert_3_alternate | int]" - "'full_chain' in cert_3_obtain_results.all_chains[cert_3_alternate | int]"
- "lookup('file', output_dir ~ '/cert-3.pem', rstrip=False) == cert_3_obtain_results.all_chains[cert_3_alternate | int].cert" - "(slurp.results[0].content | b64decode) == cert_3_obtain_results.all_chains[cert_3_alternate | int].cert"
- "lookup('file', output_dir ~ '/cert-3-chain.pem', rstrip=False) == cert_3_obtain_results.all_chains[cert_3_alternate | int].chain" - "(slurp.results[1].content | b64decode) == cert_3_obtain_results.all_chains[cert_3_alternate | int].chain"
- "lookup('file', output_dir ~ '/cert-3-fullchain.pem', rstrip=False) == cert_3_obtain_results.all_chains[cert_3_alternate | int].full_chain" - "(slurp.results[2].content | b64decode) == cert_3_obtain_results.all_chains[cert_3_alternate | int].full_chain"
- name: Check that certificate 4 is valid - name: Check that certificate 4 is valid
assert: assert:
@@ -100,14 +124,38 @@
that: that:
- cert_5_recreate_3 == True - cert_5_recreate_3 == True
- name: Check that certificate 6 is valid - block:
assert: - name: Check that certificate 6 is valid
that: assert:
- cert_6_valid is not failed that:
- name: Check that certificate 6 contains correct SANs - cert_6_valid is not failed
assert: - name: Check that certificate 6 contains correct SANs
that: assert:
- "'DNS:example.org' in cert_6_text.stdout" that:
- "'DNS:example.org' in cert_6_text.stdout"
when: acme_intermediates[0].subject_key_identifier is defined
- block:
- name: Check that certificate 7 is valid
assert:
that:
- cert_7_valid is not failed
- name: Check that certificate 7 contains correct SANs
assert:
that:
- "'IP Address:127.0.0.1' in cert_8_text.stdout or 'IP:127.0.0.1' in cert_8_text.stdout"
when: acme_roots[2].subject_key_identifier is defined
- block:
- name: Check that certificate 8 is valid
assert:
that:
- cert_8_valid is not failed
- name: Check that certificate 8 contains correct SANs
assert:
that:
- "'IP Address:127.0.0.1' in cert_8_text.stdout or 'IP:127.0.0.1' in cert_8_text.stdout"
when: cryptography_version.stdout is version('1.3', '>=')
- name: Validate that orders were not retrieved - name: Validate that orders were not retrieved
assert: assert:

View File

@@ -1,2 +1,14 @@
shippable/cloud/group1 shippable/cloud/group1
cloud/acme cloud/acme
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
# (https://github.com/ansible/ansible/issues/75711)
# shippable/posix/group1
# Skip all VMs, since we cannot talk to the ACME simulator from these:
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
# skip/aix
# skip/freebsd
# skip/macos
# skip/osx
# skip/rhel

View File

@@ -1,2 +1,3 @@
dependencies: dependencies:
- setup_acme - setup_acme
- setup_remote_tmp_dir

View File

@@ -1,12 +1,31 @@
--- ---
## SET UP ACCOUNT KEYS ######################################################################## ## SET UP ACCOUNT KEYS ########################################################################
- name: Create ECC256 account key - block:
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem" - name: Generate account keys
- name: Create ECC384 account key openssl_privatekey:
command: "{{ openssl_binary }} ecparam -name secp384r1 -genkey -out {{ output_dir }}/account-ec384.pem" path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
- name: Create RSA account key type: "{{ item.type }}"
command: "{{ openssl_binary }} genrsa -out {{ output_dir }}/account-rsa.pem {{ default_rsa_key_size }}" size: "{{ item.size | default(omit) }}"
curve: "{{ item.curve | default(omit) }}"
force: true
loop: "{{ account_keys }}"
vars:
account_keys:
- name: account-ec256
type: ECC
curve: secp256r1
- name: account-ec384
type: ECC
curve: secp384r1
- name: account-rsa
type: RSA
size: "{{ default_rsa_key_size }}"
## CREATE ACCOUNTS AND OBTAIN CERTIFICATES #################################################### ## CREATE ACCOUNTS AND OBTAIN CERTIFICATES ####################################################
- name: Read account key (EC256)
slurp:
src: '{{ remote_tmp_dir }}/account-ec256.pem'
register: slurp_account_key
- name: Obtain cert 1 - name: Obtain cert 1
include_tasks: obtain-cert.yml include_tasks: obtain-cert.yml
vars: vars:
@@ -16,7 +35,7 @@
rsa_bits: "{{ default_rsa_key_size }}" rsa_bits: "{{ default_rsa_key_size }}"
subject_alt_name: "DNS:example.com" subject_alt_name: "DNS:example.com"
subject_alt_name_critical: no subject_alt_name_critical: no
account_key_content: "{{ lookup('file', output_dir ~ '/account-ec256.pem') }}" account_key_content: "{{ slurp_account_key.content | b64decode }}"
challenge: http-01 challenge: http-01
modify_account: yes modify_account: yes
deactivate_authzs: no deactivate_authzs: no
@@ -29,6 +48,7 @@
vars: vars:
certgen_title: Certificate 2 for revocation certgen_title: Certificate 2 for revocation
certificate_name: cert-2 certificate_name: cert-2
certificate_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else '' }}"
key_type: ec256 key_type: ec256
subject_alt_name: "DNS:*.example.com" subject_alt_name: "DNS:*.example.com"
subject_alt_name_critical: yes subject_alt_name_critical: yes
@@ -60,8 +80,8 @@
- name: Revoke certificate 1 via account key - name: Revoke certificate 1 via account key
acme_certificate_revoke: acme_certificate_revoke:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_src: "{{ output_dir }}/account-ec256.pem" account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
certificate: "{{ output_dir }}/cert-1.pem" certificate: "{{ remote_tmp_dir }}/cert-1.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
@@ -70,18 +90,23 @@
- name: Revoke certificate 2 via certificate private key - name: Revoke certificate 2 via certificate private key
acme_certificate_revoke: acme_certificate_revoke:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
private_key_src: "{{ output_dir }}/cert-2.key" private_key_src: "{{ remote_tmp_dir }}/cert-2.key"
certificate: "{{ output_dir }}/cert-2.pem" private_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
certificate: "{{ remote_tmp_dir }}/cert-2.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no
ignore_errors: yes ignore_errors: yes
register: cert_2_revoke register: cert_2_revoke
- name: Read account key (RSA)
slurp:
src: '{{ remote_tmp_dir }}/account-rsa.pem'
register: slurp_account_key
- name: Revoke certificate 3 via account key (fullchain) - name: Revoke certificate 3 via account key (fullchain)
acme_certificate_revoke: acme_certificate_revoke:
select_crypto_backend: "{{ select_crypto_backend }}" select_crypto_backend: "{{ select_crypto_backend }}"
account_key_content: "{{ lookup('file', output_dir ~ '/account-rsa.pem') }}" account_key_content: "{{ slurp_account_key.content | b64decode }}"
certificate: "{{ output_dir }}/cert-3-fullchain.pem" certificate: "{{ remote_tmp_dir }}/cert-3-fullchain.pem"
acme_version: 2 acme_version: 2
acme_directory: https://{{ acme_host }}:14000/dir acme_directory: https://{{ acme_host }}:14000/dir
validate_certs: no validate_certs: no

View File

@@ -17,12 +17,12 @@
- name: Remove output directory - name: Remove output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: absent state: absent
- name: Re-create output directory - name: Re-create output directory
file: file:
path: "{{ output_dir }}" path: "{{ remote_tmp_dir }}"
state: directory state: directory
- block: - block:

View File

@@ -1,2 +1,14 @@
shippable/cloud/group1 shippable/cloud/group1
cloud/acme cloud/acme
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
# (https://github.com/ansible/ansible/issues/75711)
# shippable/posix/group1
# Skip all VMs, since we cannot talk to the ACME simulator from these:
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
# skip/aix
# skip/freebsd
# skip/macos
# skip/osx
# skip/rhel

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