Compare commits

...

177 Commits
1.3.0 ... 2.0.2

Author SHA1 Message Date
Felix Fontein
605cf2631e Release 2.0.2. 2021-12-20 20:20:32 +01:00
Felix Fontein
ee196fd8a3 Prepare 2.0.2 release. 2021-12-20 20:15:27 +01:00
Jasmine Hegman
45b7aa797e Fix module reference in example (#351)
openssl_privatekey -> openssl_publickey
2021-12-13 06:48:59 +01:00
Bruno Vernay
32dab841d7 Fix CSR copy/paste error (#349)
The first case about ca_csr has been copy/pasted.
But in the following cases, the CSR must be the certificate csr.
2021-12-09 20:52:18 +01:00
Felix Fontein
5b1816719f Next expected release is 2.1.0. 2021-11-22 12:19:10 +01:00
Felix Fontein
bd2270fb05 Release 2.0.1. 2021-11-22 11:41:56 +01:00
Felix Fontein
3f40795a98 Extension parsing: add new fallback code which uses the new cryptography API (#331)
* Add new code as fallback which re-serializes de-serialized extensions using the new cryptography API.

* Forgot Base64 encoding.

* Add extension by OID tests.

* There's one value which is different with the new code.

* Differences in CI.

* Working around older Jinjas.

* Value depends on which SAN was included.

* Force complete CI run now since cryptography 36.0.0 is out.

ci_complete
2021-11-22 07:42:49 +01:00
Felix Fontein
73bc0f5de7 Use new PKCS#12 deserialization code from cryptography 36.0.0 if available (#302)
* Use new PKCS#12 deserialization code from cryptography 36.0.0 if available.

* Refactor into smaller functions.

* Force complete CI run now since cryptography 36.0.0 is out.

ci_complete
2021-11-22 07:41:54 +01:00
Felix Fontein
f832c0a4ac Fix missing s. 2021-11-22 07:41:39 +01:00
Felix Fontein
10579c8834 Prepare 2.0.1 release. 2021-11-22 07:40:23 +01:00
Felix Fontein
f1a6baadc7 Fix collection dependency installation in CI. (#341) 2021-11-18 20:42:57 +01:00
Felix Fontein
5de50b9f91 Fix compatibility to fetch_url change in ansible-core devel (#339)
* Fix compatibility to fetch_url change in ansible-core devel.

* Adjust tests.
2021-11-17 21:26:49 +01:00
Felix Fontein
cf0d2679aa Replace RHEL 8.4 by RHEL 8.5 for devel. (#337) 2021-11-17 07:18:08 +01:00
Felix Fontein
2d388bf8d0 This is no longer a problem with the dev version of cryptography. (#335) 2021-11-13 17:59:17 +01:00
Felix Fontein
056a86fcae Replace Bash codecov uploader by new Python codecov uploader. (#333)
ci_coverage
2021-11-13 12:06:10 +01:00
Felix Fontein
ebbfd7c56f luks_device: add built-in signature wiper to work around older wipefs versions with LUKS2 containers (#327)
* Use 'cryptsetup erase' to kill LUKS signature.

* Adjust unit test.

* Use own wiper for LUKS headers.

* Add comments.

* Fix tests.

* Update changelog.

* Remove 'cryptsetup erase'.

* Improve error messages.
2021-11-11 06:59:35 +01:00
Felix Fontein
91d98c4413 Replace Fedora 33 with Fedora 35 for devel tests. (#328) 2021-11-08 22:17:30 +01:00
Felix Fontein
51b6bb210d acme_certificate: fix crash when using fullchain_dest (#324)
* Fix crash when using fullchain_dest.

* Adjust changelog.

* Update plugins/module_utils/acme/backend_cryptography.py

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

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-11-05 08:51:43 +01:00
Felix Fontein
94634a347d Next expected release is 2.1.0. 2021-11-01 20:11:50 +01:00
Felix Fontein
e5acd27c9b Release 2.0.0. 2021-11-01 19:18:03 +01:00
Felix Fontein
e6cd66df53 Add release summary. (#319) 2021-11-01 18:53:46 +01:00
Felix Fontein
589e7c72ef Allow to specify subject (for CSRs) and issuer (for CRLs) ordered (#316)
* Allow to specify subject (for CSRs) and issuer (for CRLs) ordered.

* Forgot import.

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Fix typo.

* Simplify error handling, reject empty values outright.

* Document d497231e1c.

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-10-31 15:05:04 +01:00
Felix Fontein
ecbd44df22 Add warning for ASN.1 encoded extension values returned by some modules (#318)
* Add warning that ASN.1 encoded extension values returned by some modules might not reflect the exact byte sequence in the source file anymore depending on the cryptography version.

* Apply suggestions from code review

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

Co-authored-by: Ajpantuso <ajpantuso@gmail.com>
2021-10-31 14:34:33 +01:00
Felix Fontein
4ab2ed8b77 Add ignore_timestamps option. (#317) 2021-10-30 16:34:27 +02:00
Felix Fontein
eb8dabce84 Improve Python 2 Unicode handling. (#313) 2021-10-22 07:15:20 +02:00
Felix Fontein
c5df302faa openssl_privatekey_info: disable private key consistency checks by default (#309)
* Disable private key consistency checks by default.

* Improve formulations, mention side-channel attacks.
2021-10-20 18:28:22 +02:00
Felix Fontein
a581f1ebcd Remove other deprecations (#290)
* Remove deprecated redirects.

* Remove deprecations.

* Add changelog fragment.

* Add some forgotten pieces.

* Bump version to 2.0.0.

* Fix formulation.
2021-10-16 21:00:48 +02:00
Felix Fontein
78b27ffedb Remove centos8 for devel from CI. (#307) 2021-10-16 08:33:29 +02:00
Felix Fontein
e735bdab60 Adjust to latest devel changes. 2021-10-12 19:16:46 +02:00
Felix Fontein
5f1efb6f7e Remove assertonly (#289)
* Remove assertonly backend.

* Remove assertonly tests.

* The expired test is basically a test of assertonly.

* Replace assertonly verification by _info + assert.
2021-10-10 10:24:00 +02:00
Felix Fontein
c68bfedbaa Fix shellcheck error. (#303) 2021-10-08 14:14:08 +02:00
Felix Fontein
871a185ecb Remove vendored copy of ipaddress (#287)
* Remove vendored copy of ipaddress.

* Forgot an import.

* Remove sanity ignores and checks related to ipaddress.

* Remove octal IPv4 address.

Such IPs are no longer accepted by ipaddress in Python's standard library (CVE-2021-29921).

* Remove unused import.

Co-authored-by: Sviatoslav Sydorenko <wk.cvs.github@sydorenko.org.ua>

Co-authored-by: Sviatoslav Sydorenko <wk.cvs.github@sydorenko.org.ua>
2021-10-06 14:53:40 +02:00
Felix Fontein
ed03841fd1 x509_certificate: document that *notBefore/*notAfter are not used for idempotency (#298)
* Document that *notBefore/*notAfter are not used for idempotency.

* Change formulation.
2021-10-03 22:03:58 +02:00
Felix Fontein
d6c0d53442 Fix PKCS#12 friendly name extraction for cryptography 35.0.0. (#296) 2021-10-03 20:25:24 +02:00
Felix Fontein
a2a7d94055 Support cryptography 35.0.0 for all modules except openssl_pkcs12 (#294)
* Add some workarounds for cryptography 35.0.0.

* Make fix work with very old cryptography versions as well (which supported multiple backends).

* [TEMP] Disable openssl_pkcs12 tests to see whether everything else works.

* Revert "[TEMP] Disable openssl_pkcs12 tests to see whether everything else works."

This reverts commit 3f905bc795.

* Add changelog fragment.

* Remove unnecessary assignment.

* Simplify code change.

* [TEMP] Disable openssl_pkcs12 tests to see whether everything else works.

* Revert "[TEMP] Disable openssl_pkcs12 tests to see whether everything else works."

This reverts commit fdb210528e.
2021-10-03 16:53:22 +02:00
Felix Fontein
2a7e452cf8 Ansible-core devel dropped support for Python 2.6. 2021-10-01 13:45:57 +02:00
Felix Fontein
74ae95038c Add patchback config. 2021-09-30 17:27:12 +02:00
Felix Fontein
57c364fe87 Temporarily fix CI for cryptography 35.0.0 release. (#292) 2021-09-30 13:39:34 +02:00
Felix Fontein
04958ece31 Deprecate ACME v1 (#288)
* Deprecate ACME v1.

* Fix syntax error.
2021-09-29 06:44:31 +02:00
Felix Fontein
838bdd711b Make Dirname (de)serialization conformant to RFC 4514 (#274)
* Adjust dirName serialization to RFC 4514.

* Adjust deserialization to RFC 4514.

* Add changelog fragment.

* Use Unicode strings, and work around Python 2 and Python 3 differences and problems with old cryptography versions.

* Work with bytes, not Unicode strings, to handle escaping of Unicode endpoints correctly.
2021-09-28 18:15:38 +02:00
Felix Fontein
f644db3c79 Remove PyOpenSSL backends (except for openssl_pkcs12) (#273)
* Remove Ubuntu 16.04 (Xenial Xerus) from CI.

* Removing PyOpenSSL backend from everywhere but openssl_pkcs12.

* Remove PyOpenSSL support from module_utils that's not needed for openssl_pkcs12.

* Add changelog fragment.
2021-09-28 17:46:35 +02:00
Felix Fontein
24e7d07973 Run CI on stable branches only once per week. 2021-09-28 17:34:30 +02:00
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
Felix Fontein
eb97f8ee75 Release 1.5.0. 2021-03-08 06:54:42 +01:00
Felix Fontein
ea4aac6af1 Add 1.5.0 release summary. 2021-03-08 02:16:11 +01:00
Felix Fontein
a1897fd3b1 luks_device: add sector_size option (#193)
* Add sector_size option to luks_device.

* Trying to improve error handling.

* Improve error handling.
2021-03-02 22:02:31 +01:00
Felix Fontein
ea889ce2ad Linting. (#191) 2021-02-11 07:53:50 +01:00
Felix Fontein
c20553ce68 Update targets for CI for devel branch; move some targets to stable-2.10. (#190) 2021-02-10 22:52:40 +01:00
Felix Fontein
9732107ba6 Bump AZP test container version. (#189) 2021-02-10 13:47:04 +01:00
Felix Fontein
b22c4fb65a Fix CI (#188)
* Limit cryptography to < 3.4 for Python < 3.6.

* Make sure cryptography 3.3+ is installed on Darwin.

* Work around old pip versions.
2021-02-09 06:47:30 +01:00
Felix Fontein
4b638a9608 Improve documentation, in particular of ACME modules (#181)
* Use B(...) instead of RST formatting (which does not work for options).

* Improve the documentation on acme_directory.

It now mentions the ACME v1 deprecation for Let's Encrypt,
and mentions that ZeroSSL works.

* Improve ACME module documentation.

* Update plugins/doc_fragments/acme.py
2021-02-02 19:29:49 +01:00
Ed Schaller
b0dbccaf3c openssl_csr: handle missing basic constraint (#180)
* openssl_csr: handle missing basic constraint

* openssl_csr: condense missing basic constraint check

As suggested by felixfontein

* add changelog fragment

* Update changelogs/fragments/179-openssl-csr-basic-constraint.yml

Co-authored-by: Felix Fontein <felix@fontein.de>
2021-02-01 21:40:51 +01:00
Felix Fontein
36683e1dd7 Fedora 30 and 31 are EOL and will eventually be removed from devel. (#182) 2021-02-01 15:30:52 +00:00
Felix Fontein
15a0be6107 Deprecate returning orders when retrieve_orders=url_list. (#178)
This allows to get rid of the ignore.txt entries for the return value syntax
error since then orders will always have the same type when returned.
2021-01-27 09:03:34 +01:00
Felix Fontein
a728cb61d2 Next replanned release is 1.5.0. 2021-01-26 13:13:36 +01:00
Felix Fontein
606e1cd4da Release 1.4.0. 2021-01-26 12:38:36 +01:00
Felix Fontein
c7ef362d7a openssl_pkcs12: allow to specify certificate bundles in other_certificates (#166)
* Rename identify.py to pem.py.

* Move split PEM list code to pem.py crypto module_utils.

* Extend and use global certificate splitting code in acme_certificate.

* openssl_pkcs12: allow to load multiple certificates from files mentioned in other_certificates.

* Add changelog and module_utils redirect.

* Remove old check.

* Fix typo.

* Apply suggestions from code review

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* Add example.

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>
2021-01-26 10:21:49 +01:00
Felix Fontein
d8ccebce60 openssl_csr: allow to specify CRL distribution endpoints (#167)
* Improve error messages for name decoding (not all names appear in SANs).

* Refactor DN parsing, add relative DN parsing code.

* Allow to specify CRL distribution points.

* Add changelog fragment.

* Fix typo.

* Make sure value argument to x509.NameAttribute is a text.

* Update changelogs/fragments/167-openssl_csr-crl-distribution-points.yml

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* Add example.

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>
2021-01-26 09:57:40 +01:00
Felix Fontein
a7c06b2ec4 Add release summary for 1.4.0. 2021-01-26 09:57:25 +01:00
Felix Fontein
4f7ab6733d Add Ubuntu 20.04 to CI (#176)
* Add Ubuntu 20.04 to CI.

* Also use longer keys for Ubuntu 20.04.

* Fix condition.
2021-01-22 21:39:53 +01:00
Felix Fontein
7714893294 Bump CI to FreeBSD 11.4, 12.2, add FreeBSD 12.1 to remote 2.10 tests (#174)
* Bump CI to FreeBSD 11.4, 12.2, add FreeBSD 12.1 to remote 2.10 tests.

* Use correct package prefix.

* Make more future-proof.
2021-01-22 15:52:43 +01:00
Felix Fontein
d921ff1f68 Allow to configure PBKDF (#163)
* Allow to configure PBKDF.

* Also add PBKDF options to key add operation.

* Simplify code.

* Update plugins/modules/luks_device.py

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* Fix indent.

* Use more of the options.

* Bump iteration count.

* Increase memory limit.

* Fall back to default PBKDF.

* Apply suggestions from code review

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>
2021-01-22 12:21:03 +00:00
Orosz Dávid
3ca4c48b00 acme_certificate: Improve challenge docs (#175)
* Fix "'list object' has no attribute 'value'" error
2021-01-22 08:01:07 +01:00
Felix Fontein
cd64bf8324 Add macOS 11.1 tests. (#170) 2021-01-18 10:52:04 +00:00
NorthFuture
2031787506 Added sever name option to use for SNI (#172)
* Added sever name option to use for SNI

* cleanup code

Co-authored-by: Felix Fontein <felix@fontein.de>

* added module version for new parameter

Co-authored-by: Felix Fontein <felix@fontein.de>

* added SNI explanation

Co-authored-by: Felix Fontein <felix@fontein.de>

* added SNI link to module description

* linting

* cleanup code

* Update plugins/modules/get_certificate.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* integration test for SNI server_name option

Co-authored-by: Felix Fontein <felix@fontein.de>
2021-01-17 12:21:12 +01:00
Felix Fontein
d43998facf acme_certificate: error when requested challenge type is not found for non-valid challenges instead of hanging (#173)
* Error when requested challenge type is not found for non-valid challenges, instead of hanging on step 2.

* Only run check on first step.
2021-01-17 12:17:52 +01:00
Felix Fontein
acab276d51 Relicense plugins/module_utils/acme.py under GPLv3+. (#165)
The code was originally licensed under GPLv3+ when being part of the
letsencrypt module. It was relicensed under BSD 2-clause when moving
to Ansible's module_utils (https://github.com/ansible/ansible/pull/40697).
The code was only touched by two persons
(https://github.com/ansible/ansible/commits/pre-ansible-base/lib/ansible/module_utils/acme.py,
https://github.com/ansible-collections/community.crypto/commits/main/plugins/module_utils/acme.py)
and both persons consented in this PR
(https://github.com/ansible-collections/community.crypto/pull/165)
before it was merged.
2021-01-14 14:54:51 +00:00
Felix Fontein
95040da881 Remove shippable config. 2021-01-12 21:42:27 +01:00
Felix Fontein
ccb25eab36 luks_device - make add/removal of keyfile/passphrase idempotent (#168)
* Update documentation, adjust tests, add changelog fragment.

* Move module unit test to correct place.

* Implement keyfile / passphrase test.
2021-01-03 11:22:41 +01:00
AutumnalAntlers
fb2f3ef2b5 Correct minor typo in OpenSSH module documentation (#164)
Co-authored-by: Antlers <Antlers@luris.net>
2020-12-31 09:43:04 +01:00
Andrew Klychkov
b2e13d3c03 Documentation: fix modules formatting (#161)
* Documentation: fix modules formatting

* Apply suggestions from code review

* Fix sanity

Co-authored-by: Felix Fontein <felix@fontein.de>
2020-12-19 17:15:10 +01:00
Felix Fontein
b10e86a4ba Remove Shippable badge. 2020-12-18 17:24:21 +01:00
John R Barker
04611d833d AZP Coverage support for Ansible 2.9 (#162)
Pull in latest changes from shippable-migration-tool
ci_complete ci_coverage
2020-12-16 11:42:33 +00:00
Andrew Klychkov
2c25719da5 Documentation: fix formatting (#157)
* Documentation: fix formatting

* Apply suggestions from code review

Co-authored-by: Felix Fontein <felix@fontein.de>
2020-12-12 18:19:49 +01:00
John R Barker
3c7514f653 AZP: Install ansible:devel during report-coverage (#159)
ci_complete ci_coverage
2020-12-11 15:29:56 +00:00
John R Barker
42e8279c75 Remove PATH hack (#158) 2020-12-11 13:43:34 +00:00
John R Barker
5ba60e6f66 README- AZP Badge 2020-12-10 16:28:05 +00:00
John R Barker
3fa229b7b3 Fix Azure Pipelines (#155)
* Fix Azure Pipelines

* Simplify AZP stages
* Remove hacks, use azure-pipelines-test-container:1.7.0
* Expand test matrix

* chown
2020-12-10 15:40:28 +00:00
John R Barker
52f7f0212b Basic AZP configuration (#154) 2020-12-08 20:15:34 +00:00
Felix Fontein
4d8dcad190 Speed up tests (#153)
* Improve openssh_* tests.

* Use 2048 instead of 4096 bit keys in many places.

ci_complete

* Parameterize default RSA key length for tests.

* Reduce default RSA key size to 1024.

ci_complete

* Fix error.

ci_complete

* Use variable more often.

* Use 2048 bits for RSA keys for certificates on RHEL8 and CentOS8.

ci_complete

* Fix missing constant.

ci_complete

* Print default key sizes.
2020-12-04 13:08:14 +00:00
Felix Fontein
d7ad3e32d4 Bump version. 2020-11-24 19:41:26 +01:00
301 changed files with 18055 additions and 13887 deletions

View File

@@ -0,0 +1,3 @@
## Azure Pipelines Configuration
Please see the [Documentation](https://github.com/ansible/community/wiki/Testing:-Azure-Pipelines) for more information.

View File

@@ -0,0 +1,353 @@
trigger:
batch: true
branches:
include:
- main
- stable-*
pr:
autoCancel: true
branches:
include:
- main
- stable-*
schedules:
- cron: 0 9 * * *
displayName: Nightly
always: true
branches:
include:
- main
- cron: 0 12 * * 0
displayName: Weekly (old stable branches)
always: true
branches:
include:
- stable-*
variables:
- name: checkoutPath
value: ansible_collections/community/crypto
- name: coverageBranches
value: main
- name: pipelinesCoverage
value: coverage
- name: entryPoint
value: tests/utils/shippable/shippable.sh
- name: fetchDepth
value: 0
resources:
containers:
- container: default
image: quay.io/ansible/azure-pipelines-test-container:1.9.0
pool: Standard
stages:
### Sanity & units
- stage: Ansible_devel
displayName: Sanity & Units devel
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
targets:
- name: Sanity
test: 'devel/sanity/1'
- name: Sanity Extra # Only on devel
test: 'devel/sanity/extra'
- name: Units
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
displayName: Sanity & Units 2.10
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
targets:
- name: Sanity
test: '2.10/sanity/1'
- name: Units
test: '2.10/units/1'
- stage: Ansible_2_9
displayName: Sanity & Units 2.9
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
targets:
- name: Sanity
test: '2.9/sanity/1'
- name: Units
test: '2.9/units/1'
### Docker
- stage: Docker_devel
displayName: Docker devel
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: devel/linux/{0}/1
targets:
- name: CentOS 7
test: centos7
- name: Fedora 34
test: fedora34
- name: Fedora 35
test: fedora35
- name: openSUSE 15 py2
test: opensuse15py2
- name: openSUSE 15 py3
test: opensuse15
- name: Ubuntu 18.04
test: ubuntu1804
- name: Ubuntu 20.04
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 6
test: centos6
- 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
displayName: Docker 2.10
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.10/linux/{0}/1
targets:
- name: CentOS 6
test: centos6
- name: Fedora 31
test: fedora31
- stage: Docker_2_9
displayName: Docker 2.9
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.9/linux/{0}/1
targets:
- name: CentOS 6
test: centos6
- name: CentOS 7
test: centos7
- name: Fedora 31
test: fedora31
- name: openSUSE 15 py3
test: opensuse15
- name: Ubuntu 18.04
test: ubuntu1804
### Remote
- stage: Remote_devel
displayName: Remote devel
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: devel/{0}/1
targets:
- name: macOS 11.1
test: macos/11.1
- name: RHEL 7.9
test: rhel/7.9
- name: RHEL 8.5
test: rhel/8.5
- 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
test: rhel/7.9
- name: RHEL 8.3
test: rhel/8.3
- name: FreeBSD 12.2
test: freebsd/12.2
- stage: Remote_2_10
displayName: Remote 2.10
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.10/{0}/1
targets:
- name: OS X 10.11
test: osx/10.11
- name: macOS 10.15
test: macos/10.15
- name: FreeBSD 12.1
test: freebsd/12.1
- stage: Remote_2_9
displayName: Remote 2.9
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
testFormat: 2.9/{0}/1
targets:
- name: 'RHEL 7.8'
test: 'rhel/7.8'
### cloud
- stage: Cloud_devel
displayName: Cloud devel
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
nameFormat: Python {0}
testFormat: devel/cloud/{0}/1
targets:
- test: 2.7
- test: 3.5
- test: 3.6
- test: 3.7
- test: 3.8
- 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: 2.6
- 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
displayName: Cloud 2.10
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
nameFormat: Python {0}
testFormat: 2.10/cloud/{0}/1
targets:
- test: 3.6
- stage: Cloud_2_9
displayName: Cloud 2.9
dependsOn: []
jobs:
- template: templates/matrix.yml
parameters:
nameFormat: Python {0}
testFormat: 2.9/cloud/{0}/1
targets:
- test: 3.5
## Finally
- stage: Summary
condition: succeededOrFailed()
dependsOn:
- Ansible_devel
- Ansible_2_12
- Ansible_2_11
- Ansible_2_10
- Ansible_2_9
- Remote_devel
- Remote_2_12
- Remote_2_11
- Remote_2_10
- Remote_2_9
- Docker_devel
- Docker_2_12
- Docker_2_11
- Docker_2_10
- Docker_2_9
- Cloud_devel
- Cloud_2_12
- Cloud_2_11
- Cloud_2_10
- Cloud_2_9
jobs:
- template: templates/coverage.yml

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Aggregate code coverage results for later processing.
set -o pipefail -eu
agent_temp_directory="$1"
PATH="${PWD}/bin:${PATH}"
mkdir "${agent_temp_directory}/coverage/"
options=(--venv --venv-system-site-packages --color -v)
ansible-test coverage combine --group-by command --export "${agent_temp_directory}/coverage/" "${options[@]}"
if ansible-test coverage analyze targets generate --help >/dev/null 2>&1; then
# Only analyze coverage if the installed version of ansible-test supports it.
# Doing so allows this script to work unmodified for multiple Ansible versions.
ansible-test coverage analyze targets generate "${agent_temp_directory}/coverage/coverage-analyze-targets.json" "${options[@]}"
fi

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python
"""
Combine coverage data from multiple jobs, keeping the data only from the most recent attempt from each job.
Coverage artifacts must be named using the format: "Coverage $(System.JobAttempt) {StableUniqueNameForEachJob}"
The recommended coverage artifact name format is: Coverage $(System.JobAttempt) $(System.StageDisplayName) $(System.JobDisplayName)
Keep in mind that Azure Pipelines does not enforce unique job display names (only names).
It is up to pipeline authors to avoid name collisions when deviating from the recommended format.
"""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import os
import re
import shutil
import sys
def main():
"""Main program entry point."""
source_directory = sys.argv[1]
if '/ansible_collections/' in os.getcwd():
output_path = "tests/output"
else:
output_path = "test/results"
destination_directory = os.path.join(output_path, 'coverage')
if not os.path.exists(destination_directory):
os.makedirs(destination_directory)
jobs = {}
count = 0
for name in os.listdir(source_directory):
match = re.search('^Coverage (?P<attempt>[0-9]+) (?P<label>.+)$', name)
label = match.group('label')
attempt = int(match.group('attempt'))
jobs[label] = max(attempt, jobs.get(label, 0))
for label, attempt in jobs.items():
name = 'Coverage {attempt} {label}'.format(label=label, attempt=attempt)
source = os.path.join(source_directory, name)
source_files = os.listdir(source)
for source_file in source_files:
source_path = os.path.join(source, source_file)
destination_path = os.path.join(destination_directory, source_file + '.' + label)
print('"%s" -> "%s"' % (source_path, destination_path))
shutil.copyfile(source_path, destination_path)
count += 1
print('Coverage file count: %d' % count)
print('##vso[task.setVariable variable=coverageFileCount]%d' % count)
print('##vso[task.setVariable variable=outputPath]%s' % output_path)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Check the test results and set variables for use in later steps.
set -o pipefail -eu
if [[ "$PWD" =~ /ansible_collections/ ]]; then
output_path="tests/output"
else
output_path="test/results"
fi
echo "##vso[task.setVariable variable=outputPath]${output_path}"
if compgen -G "${output_path}"'/junit/*.xml' > /dev/null; then
echo "##vso[task.setVariable variable=haveTestResults]true"
fi
if compgen -G "${output_path}"'/bot/ansible-test-*' > /dev/null; then
echo "##vso[task.setVariable variable=haveBotResults]true"
fi
if compgen -G "${output_path}"'/coverage/*' > /dev/null; then
echo "##vso[task.setVariable variable=haveCoverageData]true"
fi

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python
"""
Upload code coverage reports to codecov.io.
Multiple coverage files from multiple languages are accepted and aggregated after upload.
Python coverage, as well as PowerShell and Python stubs can all be uploaded.
"""
import argparse
import dataclasses
import pathlib
import shutil
import subprocess
import tempfile
import typing as t
import urllib.request
@dataclasses.dataclass(frozen=True)
class CoverageFile:
name: str
path: pathlib.Path
flags: t.List[str]
@dataclasses.dataclass(frozen=True)
class Args:
dry_run: bool
path: pathlib.Path
def parse_args() -> Args:
parser = argparse.ArgumentParser()
parser.add_argument('-n', '--dry-run', action='store_true')
parser.add_argument('path', type=pathlib.Path)
args = parser.parse_args()
# Store arguments in a typed dataclass
fields = dataclasses.fields(Args)
kwargs = {field.name: getattr(args, field.name) for field in fields}
return Args(**kwargs)
def process_files(directory: pathlib.Path) -> t.Tuple[CoverageFile, ...]:
processed = []
for file in directory.joinpath('reports').glob('coverage*.xml'):
name = file.stem.replace('coverage=', '')
# Get flags from name
flags = name.replace('-powershell', '').split('=') # Drop '-powershell' suffix
flags = [flag if not flag.startswith('stub') else flag.split('-')[0] for flag in flags] # Remove "-01" from stub files
processed.append(CoverageFile(name, file, flags))
return tuple(processed)
def upload_files(codecov_bin: pathlib.Path, files: t.Tuple[CoverageFile, ...], dry_run: bool = False) -> None:
for file in files:
cmd = [
str(codecov_bin),
'--name', file.name,
'--file', str(file.path),
]
for flag in file.flags:
cmd.extend(['--flags', flag])
if dry_run:
print(f'DRY-RUN: Would run command: {cmd}')
continue
subprocess.run(cmd, check=True)
def download_file(url: str, dest: pathlib.Path, flags: int, dry_run: bool = False) -> None:
if dry_run:
print(f'DRY-RUN: Would download {url} to {dest} and set mode to {flags:o}')
return
with urllib.request.urlopen(url) as resp:
with dest.open('w+b') as f:
# Read data in chunks rather than all at once
shutil.copyfileobj(resp, f, 64 * 1024)
dest.chmod(flags)
def main():
args = parse_args()
url = 'https://ansible-ci-files.s3.amazonaws.com/codecov/linux/codecov'
with tempfile.TemporaryDirectory(prefix='codecov-') as tmpdir:
codecov_bin = pathlib.Path(tmpdir) / 'codecov'
download_file(url, codecov_bin, 0o755, args.dry_run)
files = process_files(args.path)
upload_files(codecov_bin, files, args.dry_run)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Generate code coverage reports for uploading to Azure Pipelines and codecov.io.
set -o pipefail -eu
PATH="${PWD}/bin:${PATH}"
if ! ansible-test --help >/dev/null 2>&1; then
# Install the devel version of ansible-test for generating code coverage reports.
# This is only used by Ansible Collections, which are typically tested against multiple Ansible versions (in separate jobs).
# Since a version of ansible-test is required that can work the output from multiple older releases, the devel version is used.
pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check
fi
ansible-test coverage xml --group-by command --stub --venv --venv-system-site-packages --color -v

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Configure the test environment and run the tests.
set -o pipefail -eu
entry_point="$1"
test="$2"
read -r -a coverage_branches <<< "$3" # space separated list of branches to run code coverage on for scheduled builds
export COMMIT_MESSAGE
export COMPLETE
export COVERAGE
export IS_PULL_REQUEST
if [ "${SYSTEM_PULLREQUEST_TARGETBRANCH:-}" ]; then
IS_PULL_REQUEST=true
COMMIT_MESSAGE=$(git log --format=%B -n 1 HEAD^2)
else
IS_PULL_REQUEST=
COMMIT_MESSAGE=$(git log --format=%B -n 1 HEAD)
fi
COMPLETE=
COVERAGE=
if [ "${BUILD_REASON}" = "Schedule" ]; then
COMPLETE=yes
if printf '%s\n' "${coverage_branches[@]}" | grep -q "^${BUILD_SOURCEBRANCHNAME}$"; then
COVERAGE=yes
fi
fi
"${entry_point}" "${test}" 2>&1 | "$(dirname "$0")/time-command.py"

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python
"""Prepends a relative timestamp to each input line from stdin and writes it to stdout."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import sys
import time
def main():
"""Main program entry point."""
start = time.time()
sys.stdin.reconfigure(errors='surrogateescape')
sys.stdout.reconfigure(errors='surrogateescape')
for line in sys.stdin:
seconds = time.time() - start
sys.stdout.write('%02d:%02d %s' % (seconds // 60, seconds % 60, line))
sys.stdout.flush()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,39 @@
# This template adds a job for processing code coverage data.
# It will upload results to Azure Pipelines and codecov.io.
# Use it from a job stage that completes after all other jobs have completed.
# This can be done by placing it in a separate summary stage that runs after the test stage(s) have completed.
jobs:
- job: Coverage
displayName: Code Coverage
container: default
workspace:
clean: all
steps:
- checkout: self
fetchDepth: $(fetchDepth)
path: $(checkoutPath)
- task: DownloadPipelineArtifact@2
displayName: Download Coverage Data
inputs:
path: coverage/
patterns: "Coverage */*=coverage.combined"
- bash: .azure-pipelines/scripts/combine-coverage.py coverage/
displayName: Combine Coverage Data
- bash: .azure-pipelines/scripts/report-coverage.sh
displayName: Generate Coverage Report
condition: gt(variables.coverageFileCount, 0)
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: Cobertura
# Azure Pipelines only accepts a single coverage data file.
# That means only Python or PowerShell coverage can be uploaded, but not both.
# Set the "pipelinesCoverage" variable to determine which type is uploaded.
# Use "coverage" for Python and "coverage-powershell" for PowerShell.
summaryFileLocation: "$(outputPath)/reports/$(pipelinesCoverage).xml"
displayName: Publish to Azure Pipelines
condition: gt(variables.coverageFileCount, 0)
- bash: .azure-pipelines/scripts/publish-codecov.py "$(outputPath)"
displayName: Publish to codecov.io
condition: gt(variables.coverageFileCount, 0)
continueOnError: true

View File

@@ -0,0 +1,55 @@
# This template uses the provided targets and optional groups to generate a matrix which is then passed to the test template.
# If this matrix template does not provide the required functionality, consider using the test template directly instead.
parameters:
# A required list of dictionaries, one per test target.
# Each item in the list must contain a "test" or "name" key.
# Both may be provided. If one is omitted, the other will be used.
- name: targets
type: object
# An optional list of values which will be used to multiply the targets list into a matrix.
# Values can be strings or numbers.
- name: groups
type: object
default: []
# An optional format string used to generate the job name.
# - {0} is the name of an item in the targets list.
- name: nameFormat
type: string
default: "{0}"
# An optional format string used to generate the test name.
# - {0} is the name of an item in the targets list.
- name: testFormat
type: string
default: "{0}"
# An optional format string used to add the group to the job name.
# {0} is the formatted name of an item in the targets list.
# {{1}} is the group -- be sure to include the double "{{" and "}}".
- name: nameGroupFormat
type: string
default: "{0} - {{1}}"
# An optional format string used to add the group to the test name.
# {0} is the formatted test of an item in the targets list.
# {{1}} is the group -- be sure to include the double "{{" and "}}".
- name: testGroupFormat
type: string
default: "{0}/{{1}}"
jobs:
- template: test.yml
parameters:
jobs:
- ${{ if eq(length(parameters.groups), 0) }}:
- ${{ each target in parameters.targets }}:
- name: ${{ format(parameters.nameFormat, coalesce(target.name, target.test)) }}
test: ${{ format(parameters.testFormat, coalesce(target.test, target.name)) }}
- ${{ if not(eq(length(parameters.groups), 0)) }}:
- ${{ each group in parameters.groups }}:
- ${{ each target in parameters.targets }}:
- name: ${{ format(format(parameters.nameGroupFormat, parameters.nameFormat), coalesce(target.name, target.test), group) }}
test: ${{ format(format(parameters.testGroupFormat, parameters.testFormat), coalesce(target.test, target.name), group) }}

View File

@@ -0,0 +1,45 @@
# This template uses the provided list of jobs to create test one or more test jobs.
# It can be used directly if needed, or through the matrix template.
parameters:
# A required list of dictionaries, one per test job.
# Each item in the list must contain a "job" and "name" key.
- name: jobs
type: object
jobs:
- ${{ each job in parameters.jobs }}:
- job: test_${{ replace(replace(replace(job.test, '/', '_'), '.', '_'), '-', '_') }}
displayName: ${{ job.name }}
container: default
workspace:
clean: all
steps:
- checkout: self
fetchDepth: $(fetchDepth)
path: $(checkoutPath)
- bash: .azure-pipelines/scripts/run-tests.sh "$(entryPoint)" "${{ job.test }}" "$(coverageBranches)"
displayName: Run Tests
- bash: .azure-pipelines/scripts/process-results.sh
condition: succeededOrFailed()
displayName: Process Results
- bash: .azure-pipelines/scripts/aggregate-coverage.sh "$(Agent.TempDirectory)"
condition: eq(variables.haveCoverageData, 'true')
displayName: Aggregate Coverage Data
- task: PublishTestResults@2
condition: eq(variables.haveTestResults, 'true')
inputs:
testResultsFiles: "$(outputPath)/junit/*.xml"
displayName: Publish Test Results
- task: PublishPipelineArtifact@1
condition: eq(variables.haveBotResults, 'true')
displayName: Publish Bot Results
inputs:
targetPath: "$(outputPath)/bot/"
artifactName: "Bot $(System.JobAttempt) $(System.StageDisplayName) $(System.JobDisplayName)"
- task: PublishPipelineArtifact@1
condition: eq(variables.haveCoverageData, 'true')
displayName: Publish Coverage Data
inputs:
targetPath: "$(Agent.TempDirectory)/coverage/"
artifactName: "Coverage $(System.JobAttempt) $(System.StageDisplayName) $(System.JobDisplayName)"

5
.github/patchback.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
---
backport_branch_prefix: patchback/backports/
backport_label_prefix: backport-
target_branch_prefix: stable-
...

View File

@@ -5,6 +5,354 @@ Community Crypto Release Notes
.. contents:: Topics
v2.0.2
======
Release Summary
---------------
Documentation fix release. No actual code changes.
v2.0.1
======
Release Summary
---------------
Bugfix release with extra forward compatibility for newer versions of cryptography.
Minor Changes
-------------
- acme_* modules - fix usage of ``fetch_url`` with changes in latest ansible-core ``devel`` branch (https://github.com/ansible-collections/community.crypto/pull/339).
Bugfixes
--------
- acme_certificate - avoid passing multiple certificates to ``cryptography``'s X.509 certificate loader when ``fullchain_dest`` is used (https://github.com/ansible-collections/community.crypto/pull/324).
- get_certificate, openssl_csr_info, x509_certificate_info - add fallback code for extension parsing that works with cryptography 36.0.0 and newer. This code re-serializes de-serialized extensions and thus can return slightly different values if the extension in the original CSR resp. certificate was not canonicalized correctly. This code is currently used as a fallback if the existing code stops working, but we will switch it to be the main code in a future release (https://github.com/ansible-collections/community.crypto/pull/331).
- luks_device - now also runs a built-in LUKS signature cleaner on ``state=absent`` to make sure that also the secondary LUKS2 header is wiped when older versions of wipefs are used (https://github.com/ansible-collections/community.crypto/issues/326, https://github.com/ansible-collections/community.crypto/pull/327).
- openssl_pkcs12 - use new PKCS#12 deserialization infrastructure from cryptography 36.0.0 if available (https://github.com/ansible-collections/community.crypto/pull/302).
v2.0.0
======
Release Summary
---------------
A new major release of the ``community.crypto`` collection. The main changes are removal of the PyOpenSSL backends for almost all modules (``openssl_pkcs12`` being the only exception), and removal of the ``assertonly`` provider in the ``x509_certificate`` provider. There are also some other breaking changes which should improve the user interface/experience of this collection long-term.
Minor Changes
-------------
- acme_certificate - the ``subject`` and ``issuer`` fields in in the ``select_chain`` entries are now more strictly validated (https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_csr, openssl_csr_pipe - provide a new ``subject_ordered`` option if the order of the components in the subject is of importance (https://github.com/ansible-collections/community.crypto/issues/291, https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_csr, openssl_csr_pipe - there is now stricter validation of the values of the ``subject`` option (https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_privatekey_info - add ``check_consistency`` option to request private key consistency checks to be done (https://github.com/ansible-collections/community.crypto/pull/309).
- x509_certificate, x509_certificate_pipe - add ``ignore_timestamps`` option which allows to enable idempotency for 'not before' and 'not after' options (https://github.com/ansible-collections/community.crypto/issues/295, https://github.com/ansible-collections/community.crypto/pull/317).
- x509_crl - provide a new ``issuer_ordered`` option if the order of the components in the issuer is of importance (https://github.com/ansible-collections/community.crypto/issues/291, https://github.com/ansible-collections/community.crypto/pull/316).
- x509_crl - there is now stricter validation of the values of the ``issuer`` option (https://github.com/ansible-collections/community.crypto/pull/316).
Breaking Changes / Porting Guide
--------------------------------
- Adjust ``dirName`` text parsing and to text converting code to conform to `Sections 2 and 3 of RFC 4514 <https://datatracker.ietf.org/doc/html/rfc4514.html>`_. This is similar to how `cryptography handles this <https://cryptography.io/en/latest/x509/reference/#cryptography.x509.Name.rfc4514_string>`_ (https://github.com/ansible-collections/community.crypto/pull/274).
- acme module utils - removing compatibility code (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_* modules - removed vendored copy of the Python library ``ipaddress``. If you are using Python 2.x, please make sure to install the library (https://github.com/ansible-collections/community.crypto/pull/287).
- compatibility module_utils - removed vendored copy of the Python library ``ipaddress`` (https://github.com/ansible-collections/community.crypto/pull/287).
- crypto module utils - removing compatibility code (https://github.com/ansible-collections/community.crypto/pull/290).
- get_certificate, openssl_csr_info, x509_certificate_info - depending on the ``cryptography`` version used, the modules might not return the ASN.1 value for an extension as contained in the certificate respectively CSR, but a re-encoded version of it. This should usually be identical to the value contained in the source file, unless the value was malformed. For extensions not handled by C(cryptography) the value contained in the source file is always returned unaltered (https://github.com/ansible-collections/community.crypto/pull/318).
- module_utils - removed various PyOpenSSL support functions and default backend values that are not needed for the openssl_pkcs12 module (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_csr, openssl_csr_pipe, x509_crl - the ``subject`` respectively ``issuer`` fields no longer ignore empty values, but instead fail when encountering them (https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_privatekey_info - by default consistency checks are not run; they need to be explicitly requested by passing ``check_consistency=true`` (https://github.com/ansible-collections/community.crypto/pull/309).
- x509_crl - for idempotency checks, the ``issuer`` order is ignored. If order is important, use the new ``issuer_ordered`` option (https://github.com/ansible-collections/community.crypto/pull/316).
Deprecated Features
-------------------
- acme_* modules - ACME version 1 is now deprecated and support for it will be removed in community.crypto 2.0.0 (https://github.com/ansible-collections/community.crypto/pull/288).
Removed Features (previously deprecated)
----------------------------------------
- acme_* modules - the ``acme_directory`` option is now required (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_* modules - the ``acme_version`` option is now required (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_account_facts - the deprecated redirect has been removed. Use community.crypto.acme_account_info instead (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_account_info - ``retrieve_orders=url_list`` no longer returns the return value ``orders``. Use the ``order_uris`` return value instead (https://github.com/ansible-collections/community.crypto/pull/290).
- crypto.info module utils - the deprecated redirect has been removed. Use ``crypto.pem`` instead (https://github.com/ansible-collections/community.crypto/pull/290).
- get_certificate - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_certificate - the deprecated redirect has been removed. Use community.crypto.x509_certificate instead (https://github.com/ansible-collections/community.crypto/pull/290).
- openssl_certificate_info - the deprecated redirect has been removed. Use community.crypto.x509_certificate_info instead (https://github.com/ansible-collections/community.crypto/pull/290).
- openssl_csr - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_csr and openssl_csr_pipe - ``version`` now only accepts the (default) value 1 (https://github.com/ansible-collections/community.crypto/pull/290).
- openssl_csr_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_csr_pipe - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_privatekey - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_privatekey_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_privatekey_pipe - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_publickey - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_publickey_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_signature - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_signature_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- x509_certificate - remove ``assertonly`` provider (https://github.com/ansible-collections/community.crypto/pull/289).
- x509_certificate - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- x509_certificate_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- x509_certificate_pipe - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
Bugfixes
--------
- cryptography backend - improve Unicode handling for Python 2 (https://github.com/ansible-collections/community.crypto/pull/313).
- get_certificate - fix compatibility with the cryptography 35.0.0 release (https://github.com/ansible-collections/community.crypto/pull/294).
- openssl_csr_info - fix compatibility with the cryptography 35.0.0 release (https://github.com/ansible-collections/community.crypto/pull/294).
- openssl_pkcs12 - fix compatibility with the cryptography 35.0.0 release (https://github.com/ansible-collections/community.crypto/pull/296).
- x509_certificate_info - fix compatibility with the cryptography 35.0.0 release (https://github.com/ansible-collections/community.crypto/pull/294).
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
======
Release Summary
---------------
Regular feature and bugfix release. Deprecates a return value.
Minor Changes
-------------
- acme_account_info - when ``retrieve_orders`` is not ``ignore`` and the ACME server allows to query orders, the new return value ``order_uris`` is always populated with a list of URIs (https://github.com/ansible-collections/community.crypto/pull/178).
- luks_device - allow to specify sector size for LUKS2 containers with new ``sector_size`` parameter (https://github.com/ansible-collections/community.crypto/pull/193).
Deprecated Features
-------------------
- acme_account_info - when ``retrieve_orders=url_list``, ``orders`` will no longer be returned in community.crypto 2.0.0. Use ``order_uris`` instead (https://github.com/ansible-collections/community.crypto/pull/178).
Bugfixes
--------
- openssl_csr - no longer fails when comparing CSR without basic constraint when ``basic_constraints`` is specified (https://github.com/ansible-collections/community.crypto/issues/179, https://github.com/ansible-collections/community.crypto/pull/180).
v1.4.0
======
Release Summary
---------------
Release with several new features and bugfixes.
Minor Changes
-------------
- The ACME module_utils has been relicensed back from the Simplified BSD License (https://opensource.org/licenses/BSD-2-Clause) to the GPLv3+ (same license used by most other code in this collection). This undoes a licensing change when the original GPLv3+ licensed code was moved to module_utils in https://github.com/ansible/ansible/pull/40697 (https://github.com/ansible-collections/community.crypto/pull/165).
- The ``crypto/identify.py`` module_utils has been renamed to ``crypto/pem.py`` (https://github.com/ansible-collections/community.crypto/pull/166).
- luks_device - ``new_keyfile``, ``new_passphrase``, ``remove_keyfile`` and ``remove_passphrase`` are now idempotent (https://github.com/ansible-collections/community.crypto/issues/19, https://github.com/ansible-collections/community.crypto/pull/168).
- luks_device - allow to configure PBKDF (https://github.com/ansible-collections/community.crypto/pull/163).
- openssl_csr, openssl_csr_pipe - allow to specify CRL distribution endpoints with ``crl_distribution_points`` (https://github.com/ansible-collections/community.crypto/issues/147, https://github.com/ansible-collections/community.crypto/pull/167).
- openssl_pkcs12 - allow to specify certificate bundles in ``other_certificates`` by using new option ``other_certificates_parse_all`` (https://github.com/ansible-collections/community.crypto/issues/149, https://github.com/ansible-collections/community.crypto/pull/166).
Bugfixes
--------
- acme_certificate - error when requested challenge type is not found for non-valid challenges, instead of hanging on step 2 (https://github.com/ansible-collections/community.crypto/issues/171, https://github.com/ansible-collections/community.crypto/pull/173).
v1.3.0
======

View File

@@ -1,15 +1,17 @@
# Ansible Community Crypto Collection
[![Shippable build status](https://api.shippable.com/projects/5e66776ca27f990007073a42/badge?branch=main)](https://app.shippable.com/projects/5e66776ca27f990007073a42)
[![Build Status](https://dev.azure.com/ansible/community.crypto/_apis/build/status/CI?branchName=main)](https://dev.azure.com/ansible/community.crypto/_build?definitionId=21)
[![Codecov](https://img.shields.io/codecov/c/github/ansible-collections/community.crypto)](https://codecov.io/gh/ansible-collections/community.crypto)
Provides modules for [Ansible](https://www.ansible.com/community) for various cryptographic operations.
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 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

View File

@@ -285,3 +285,429 @@ releases:
name: x509_certificate_pipe
namespace: ''
release_date: '2020-11-24'
1.4.0:
changes:
bugfixes:
- acme_certificate - error when requested challenge type is not found for non-valid
challenges, instead of hanging on step 2 (https://github.com/ansible-collections/community.crypto/issues/171,
https://github.com/ansible-collections/community.crypto/pull/173).
minor_changes:
- The ACME module_utils has been relicensed back from the Simplified BSD License
(https://opensource.org/licenses/BSD-2-Clause) to the GPLv3+ (same license
used by most other code in this collection). This undoes a licensing change
when the original GPLv3+ licensed code was moved to module_utils in https://github.com/ansible/ansible/pull/40697
(https://github.com/ansible-collections/community.crypto/pull/165).
- The ``crypto/identify.py`` module_utils has been renamed to ``crypto/pem.py``
(https://github.com/ansible-collections/community.crypto/pull/166).
- luks_device - ``new_keyfile``, ``new_passphrase``, ``remove_keyfile`` and
``remove_passphrase`` are now idempotent (https://github.com/ansible-collections/community.crypto/issues/19,
https://github.com/ansible-collections/community.crypto/pull/168).
- luks_device - allow to configure PBKDF (https://github.com/ansible-collections/community.crypto/pull/163).
- openssl_csr, openssl_csr_pipe - allow to specify CRL distribution endpoints
with ``crl_distribution_points`` (https://github.com/ansible-collections/community.crypto/issues/147,
https://github.com/ansible-collections/community.crypto/pull/167).
- openssl_pkcs12 - allow to specify certificate bundles in ``other_certificates``
by using new option ``other_certificates_parse_all`` (https://github.com/ansible-collections/community.crypto/issues/149,
https://github.com/ansible-collections/community.crypto/pull/166).
release_summary: Release with several new features and bugfixes.
fragments:
- 1.4.0.yml
- 163-luks-pbkdf.yml
- 166-openssl_pkcs12-certificate-bundles.yml
- 167-openssl_csr-crl-distribution-points.yml
- 168-luks_device-add-remove-idempotence.yml
- 173-acme_certificate-wrong-challenge.yml
- acme-module-utils-relicense.yml
release_date: '2021-01-26'
1.5.0:
changes:
bugfixes:
- openssl_csr - no longer fails when comparing CSR without basic constraint
when ``basic_constraints`` is specified (https://github.com/ansible-collections/community.crypto/issues/179,
https://github.com/ansible-collections/community.crypto/pull/180).
deprecated_features:
- acme_account_info - when ``retrieve_orders=url_list``, ``orders`` will no
longer be returned in community.crypto 2.0.0. Use ``order_uris`` instead (https://github.com/ansible-collections/community.crypto/pull/178).
minor_changes:
- acme_account_info - when ``retrieve_orders`` is not ``ignore`` and the ACME
server allows to query orders, the new return value ``order_uris`` is always
populated with a list of URIs (https://github.com/ansible-collections/community.crypto/pull/178).
- luks_device - allow to specify sector size for LUKS2 containers with new ``sector_size``
parameter (https://github.com/ansible-collections/community.crypto/pull/193).
release_summary: Regular feature and bugfix release. Deprecates a return value.
fragments:
- 1.5.0.yml
- 178-acme_account_info-orders-urls.yml
- 179-openssl-csr-basic-constraint.yml
- 193-luks_device-sector_size.yml
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'
2.0.0:
changes:
breaking_changes:
- Adjust ``dirName`` text parsing and to text converting code to conform to
`Sections 2 and 3 of RFC 4514 <https://datatracker.ietf.org/doc/html/rfc4514.html>`_.
This is similar to how `cryptography handles this <https://cryptography.io/en/latest/x509/reference/#cryptography.x509.Name.rfc4514_string>`_
(https://github.com/ansible-collections/community.crypto/pull/274).
- acme module utils - removing compatibility code (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_* modules - removed vendored copy of the Python library ``ipaddress``.
If you are using Python 2.x, please make sure to install the library (https://github.com/ansible-collections/community.crypto/pull/287).
- compatibility module_utils - removed vendored copy of the Python library ``ipaddress``
(https://github.com/ansible-collections/community.crypto/pull/287).
- crypto module utils - removing compatibility code (https://github.com/ansible-collections/community.crypto/pull/290).
- get_certificate, openssl_csr_info, x509_certificate_info - depending on the
``cryptography`` version used, the modules might not return the ASN.1 value
for an extension as contained in the certificate respectively CSR, but a re-encoded
version of it. This should usually be identical to the value contained in
the source file, unless the value was malformed. For extensions not handled
by C(cryptography) the value contained in the source file is always returned
unaltered (https://github.com/ansible-collections/community.crypto/pull/318).
- module_utils - removed various PyOpenSSL support functions and default backend
values that are not needed for the openssl_pkcs12 module (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_csr, openssl_csr_pipe, x509_crl - the ``subject`` respectively ``issuer``
fields no longer ignore empty values, but instead fail when encountering them
(https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_privatekey_info - by default consistency checks are not run; they
need to be explicitly requested by passing ``check_consistency=true`` (https://github.com/ansible-collections/community.crypto/pull/309).
- x509_crl - for idempotency checks, the ``issuer`` order is ignored. If order
is important, use the new ``issuer_ordered`` option (https://github.com/ansible-collections/community.crypto/pull/316).
bugfixes:
- cryptography backend - improve Unicode handling for Python 2 (https://github.com/ansible-collections/community.crypto/pull/313).
- get_certificate - fix compatibility with the cryptography 35.0.0 release (https://github.com/ansible-collections/community.crypto/pull/294).
- openssl_csr_info - fix compatibility with the cryptography 35.0.0 release
(https://github.com/ansible-collections/community.crypto/pull/294).
- openssl_pkcs12 - fix compatibility with the cryptography 35.0.0 release (https://github.com/ansible-collections/community.crypto/pull/296).
- x509_certificate_info - fix compatibility with the cryptography 35.0.0 release
(https://github.com/ansible-collections/community.crypto/pull/294).
deprecated_features:
- acme_* modules - ACME version 1 is now deprecated and support for it will
be removed in community.crypto 2.0.0 (https://github.com/ansible-collections/community.crypto/pull/288).
minor_changes:
- acme_certificate - the ``subject`` and ``issuer`` fields in in the ``select_chain``
entries are now more strictly validated (https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_csr, openssl_csr_pipe - provide a new ``subject_ordered`` option if
the order of the components in the subject is of importance (https://github.com/ansible-collections/community.crypto/issues/291,
https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_csr, openssl_csr_pipe - there is now stricter validation of the values
of the ``subject`` option (https://github.com/ansible-collections/community.crypto/pull/316).
- openssl_privatekey_info - add ``check_consistency`` option to request private
key consistency checks to be done (https://github.com/ansible-collections/community.crypto/pull/309).
- x509_certificate, x509_certificate_pipe - add ``ignore_timestamps`` option
which allows to enable idempotency for 'not before' and 'not after' options
(https://github.com/ansible-collections/community.crypto/issues/295, https://github.com/ansible-collections/community.crypto/pull/317).
- x509_crl - provide a new ``issuer_ordered`` option if the order of the components
in the issuer is of importance (https://github.com/ansible-collections/community.crypto/issues/291,
https://github.com/ansible-collections/community.crypto/pull/316).
- x509_crl - there is now stricter validation of the values of the ``issuer``
option (https://github.com/ansible-collections/community.crypto/pull/316).
release_summary: 'A new major release of the ``community.crypto`` collection.
The main changes are removal of the PyOpenSSL backends for almost all modules
(``openssl_pkcs12`` being the only exception), and removal of the ``assertonly``
provider in the ``x509_certificate`` provider. There are also some other breaking
changes which should improve the user interface/experience of this collection
long-term.
'
removed_features:
- acme_* modules - the ``acme_directory`` option is now required (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_* modules - the ``acme_version`` option is now required (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_account_facts - the deprecated redirect has been removed. Use community.crypto.acme_account_info
instead (https://github.com/ansible-collections/community.crypto/pull/290).
- acme_account_info - ``retrieve_orders=url_list`` no longer returns the return
value ``orders``. Use the ``order_uris`` return value instead (https://github.com/ansible-collections/community.crypto/pull/290).
- crypto.info module utils - the deprecated redirect has been removed. Use ``crypto.pem``
instead (https://github.com/ansible-collections/community.crypto/pull/290).
- get_certificate - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_certificate - the deprecated redirect has been removed. Use community.crypto.x509_certificate
instead (https://github.com/ansible-collections/community.crypto/pull/290).
- openssl_certificate_info - the deprecated redirect has been removed. Use community.crypto.x509_certificate_info
instead (https://github.com/ansible-collections/community.crypto/pull/290).
- openssl_csr - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_csr and openssl_csr_pipe - ``version`` now only accepts the (default)
value 1 (https://github.com/ansible-collections/community.crypto/pull/290).
- openssl_csr_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_csr_pipe - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_privatekey - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_privatekey_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_privatekey_pipe - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_publickey - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_publickey_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_signature - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- openssl_signature_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- x509_certificate - remove ``assertonly`` provider (https://github.com/ansible-collections/community.crypto/pull/289).
- x509_certificate - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- x509_certificate_info - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
- x509_certificate_pipe - removed the ``pyopenssl`` backend (https://github.com/ansible-collections/community.crypto/pull/273).
fragments:
- 2.0.0.yml
- 273-pyopenssl-removal.yml
- 274-dirname-rfc4514.yml
- 287-remove-ipaddress.yml
- 288-depecate-acme-v1.yml
- 289-assertonly-removed.yml
- 290-remove-deprecations.yml
- 294-cryptography-35.0.0.yml
- 296-openssl_pkcs12-cryptography-35.yml
- 309-openssl_privatekey_info-consistency.yml
- 313-unicode-names.yml
- 315-ordered-names.yml
- 317-ignore-timestamps.yml
- 318-extension-value-note.yml
release_date: '2021-11-01'
2.0.1:
changes:
bugfixes:
- acme_certificate - avoid passing multiple certificates to ``cryptography``'s
X.509 certificate loader when ``fullchain_dest`` is used (https://github.com/ansible-collections/community.crypto/pull/324).
- get_certificate, openssl_csr_info, x509_certificate_info - add fallback code
for extension parsing that works with cryptography 36.0.0 and newer. This
code re-serializes de-serialized extensions and thus can return slightly different
values if the extension in the original CSR resp. certificate was not canonicalized
correctly. This code is currently used as a fallback if the existing code
stops working, but we will switch it to be the main code in a future release
(https://github.com/ansible-collections/community.crypto/pull/331).
- luks_device - now also runs a built-in LUKS signature cleaner on ``state=absent``
to make sure that also the secondary LUKS2 header is wiped when older versions
of wipefs are used (https://github.com/ansible-collections/community.crypto/issues/326,
https://github.com/ansible-collections/community.crypto/pull/327).
- openssl_pkcs12 - use new PKCS#12 deserialization infrastructure from cryptography
36.0.0 if available (https://github.com/ansible-collections/community.crypto/pull/302).
minor_changes:
- acme_* modules - fix usage of ``fetch_url`` with changes in latest ansible-core
``devel`` branch (https://github.com/ansible-collections/community.crypto/pull/339).
release_summary: Bugfix release with extra forward compatibility for newer versions
of cryptography.
fragments:
- 2.0.1.yml
- 302-openssl_pkcs12-cryptography-36.0.0.yml
- 324-acme_certificate-fullchain.yml
- 327-luks_device-wipe.yml
- 331-cryptography-extensions.yml
- fetch_url-devel.yml
release_date: '2021-11-22'
2.0.2:
changes:
release_summary: Documentation fix release. No actual code changes.
fragments:
- 2.0.2.yml
release_date: '2021-12-20'

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: "{{ 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: "{{ 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
name: crypto
version: 1.3.0
version: 2.0.2
readme: README.md
authors:
- Ansible (github.com/ansible)

View File

@@ -12,14 +12,19 @@ action_groups:
plugin_routing:
modules:
acme_account_facts:
deprecation:
tombstone:
removal_version: 2.0.0
warning_text: The 'community.crypto.acme_account_facts' module has been renamed to 'community.crypto.acme_account_info'.
openssl_certificate:
deprecation:
tombstone:
removal_version: 2.0.0
warning_text: The 'community.crypto.openssl_certificate' module has been renamed to 'community.crypto.x509_certificate'
openssl_certificate_info:
deprecation:
tombstone:
removal_version: 2.0.0
warning_text: The 'community.crypto.openssl_certificate_info' module has been renamed to 'community.crypto.x509_certificate_info'
module_utils:
crypto.identify:
tombstone:
removal_version: 2.0.0
warning_text: The 'crypto/identify.py' module_utils has been renamed 'crypto/pem.py'. Please update your imports

View File

@@ -9,7 +9,7 @@ __metaclass__ = type
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

View File

@@ -24,19 +24,19 @@ notes:
principle be used with any CA providing an ACME endpoint, such as
L(Buypass Go SSL,https://www.buypass.com/ssl/products/acme)."
requirements:
- python >= 2.6
- either openssl or L(cryptography,https://cryptography.io/) >= 1.5
- ipaddress
options:
account_key_src:
description:
- "Path to a file containing the ACME account RSA or Elliptic Curve
key."
- "Private keys can be created with the
M(community.crypto.openssl_privatekey) module. If the requisites
(pyOpenSSL or cryptography) are not available, keys can also be
created directly with the C(openssl) command line tool: RSA keys
can be created with C(openssl genrsa ...). Elliptic curve keys can be
created with C(openssl ecparam -genkey ...). Any other tool creating
M(community.crypto.openssl_privatekey) or M(community.crypto.openssl_privatekey_pipe)
modules. If the requisite (cryptography) is not available,
keys can also be created directly with the C(openssl) command line tool:
RSA keys can be created with C(openssl genrsa ...). Elliptic curve keys
can be created with C(openssl ecparam -genkey ...). Any other tool creating
private keys in PEM format can be used as well."
- "Mutually exclusive with C(account_key_content)."
- "Required if C(account_key_content) is not used."
@@ -47,7 +47,7 @@ options:
- "Content of the ACME account RSA or Elliptic Curve key."
- "Mutually exclusive with C(account_key_src)."
- "Required if C(account_key_src) is not used."
- "*Warning:* the content will be written into a temporary file, which will
- "B(Warning:) the content will be written into a temporary file, which will
be deleted by Ansible when the module completes. Since this is an
important private key — it can be used to change the account key,
or to revoke your certificates without knowing their private keys
@@ -57,6 +57,12 @@ options:
Ansible in the process of moving the module with its argument to
the node where it is executed."
type: str
account_key_passphrase:
description:
- Phassphrase to use to decode the account key.
- "B(Note:) this is not supported by the C(openssl) backend, only by the C(cryptography) backend."
type: str
version_added: 1.6.0
account_uri:
description:
- "If specified, assumes that the account URI is as given. If the
@@ -66,40 +72,43 @@ options:
acme_version:
description:
- "The ACME version of the endpoint."
- "Must be 1 for the classic Let's Encrypt and Buypass ACME endpoints,
or 2 for standardized ACME v2 endpoints."
- "The default value is 1. Note that in community.crypto 2.0.0, this
option *will be required* and will no longer have a default."
- "Please also note that we will deprecate ACME v1 support eventually."
- "Must be C(1) for the classic Let's Encrypt and Buypass ACME endpoints,
or C(2) for standardized ACME v2 endpoints."
- "The value C(1) is deprecated since community.crypto 2.0.0 and will be
removed from community.crypto 3.0.0."
required: true
type: int
choices: [ 1, 2 ]
acme_directory:
description:
- "The ACME directory to use. This is the entry point URL to access
CA server API."
the ACME CA server API."
- "For safety reasons the default is set to the Let's Encrypt staging
server (for the ACME v1 protocol). This will create technically correct,
but untrusted certificates."
- "The default value is U(https://acme-staging.api.letsencrypt.org/directory).
Note that in community.crypto 2.0.0, this option *will be required* and
will no longer have a default."
- "For Let's Encrypt, all staging endpoints can be found here:
U(https://letsencrypt.org/docs/staging-environment/). For Buypass, all
endpoints can be found here:
U(https://community.buypass.com/t/63d4ay/buypass-go-ssl-endpoints)"
- "For Let's Encrypt, the production directory URL for ACME v1 is
U(https://acme-v01.api.letsencrypt.org/directory), and the production
directory URL for ACME v2 is U(https://acme-v02.api.letsencrypt.org/directory)."
- "For Buypass, the production directory URL for ACME v2 and v1 is
- "For B(Let's Encrypt), the production directory URL for ACME v2 is
U(https://acme-v02.api.letsencrypt.org/directory)."
- "For B(Buypass), the production directory URL for ACME v2 and v1 is
U(https://api.buypass.com/acme/directory)."
- "*Warning:* So far, the module has only been tested against Let's Encrypt
(staging and production), Buypass (staging and production), and
L(Pebble testing server,https://github.com/letsencrypt/Pebble)."
- "For B(ZeroSSL), the production directory URL for ACME v2 is
U(https://acme.zerossl.com/v2/DV90)."
- "B(Warning:) So far, the ACME modules have only been tested against Let's Encrypt
(staging and production), Buypass (staging and production), ZeroSSL (production),
and L(Pebble testing server,https://github.com/letsencrypt/Pebble). If you
experience problems with another ACME server, please
L(create an issue,https://github.com/ansible-collections/community.crypto/issues/new/choose)
to help us supporting it. Feedback that an ACME server not mentioned does work
is also appreciated."
required: true
type: str
validate_certs:
description:
- Whether calls to the ACME directory will validate TLS certificates.
- "*Warning:* Should *only ever* be set to C(no) for testing purposes,
- "B(Warning:) Should B(only ever) be set to C(no) for testing purposes,
for example when testing against a local Pebble server."
type: bool
default: yes

View File

@@ -14,12 +14,9 @@ class ModuleDocFragment(object):
DOCUMENTATION = r'''
description:
- This module allows one to (re)generate OpenSSL certificates.
- 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.
- It uses the cryptography python library to interact with OpenSSL.
requirements:
- PyOpenSSL >= 0.15 or cryptography >= 1.6 (if using C(selfsigned), C(ownca) or C(assertonly) provider)
- cryptography >= 1.6 (if using C(selfsigned) or C(ownca) provider)
options:
force:
description:
@@ -55,17 +52,22 @@ options:
- This is required if the private key is password protected.
type: str
ignore_timestamps:
description:
- Whether the "not before" and "not after" timestamps should be ignored for idempotency checks.
- It is better to keep the default value C(true) when using relative timestamps (like C(+0s) for now).
type: bool
default: true
version_added: 2.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 set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- 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 ]
choices: [ auto, cryptography ]
notes:
- All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern.
@@ -119,201 +121,6 @@ options:
default: https://acme-v02.api.letsencrypt.org/directory
'''
BACKEND_ASSERTONLY_DOCUMENTATION = r'''
description:
- The C(assertonly) provider is intended for use cases where one is only interested in
checking properties of a supplied certificate. Please note that this provider has been
deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0. See the examples on how
to emulate C(assertonly) usage with M(community.crypto.x509_certificate_info),
M(community.crypto.openssl_csr_info), M(community.crypto.openssl_privatekey_info) and
M(ansible.builtin.assert). This also allows more flexible checks than
the ones offered by the C(assertonly) provider.
- Many properties that can be specified in this module are for validation of an
existing or newly generated certificate. The proper place to specify them, if you
want to receive a certificate with these properties is a CSR (Certificate Signing Request).
options:
csr_path:
description:
- This is not required for the C(assertonly) provider.
csr_content:
description:
- This is not required for the C(assertonly) provider.
signature_algorithms:
description:
- A list of algorithms that you would accept the certificate to be signed with
(e.g. ['sha256WithRSAEncryption', 'sha512WithRSAEncryption']).
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: list
elements: str
issuer:
description:
- The key/value pairs that must be present in the issuer name field of the certificate.
- If you need to specify more than one value with the same key, use a list as value.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: dict
issuer_strict:
description:
- If set to C(yes), the I(issuer) field must contain only these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
subject:
description:
- The key/value pairs that must be present in the subject name field of the certificate.
- If you need to specify more than one value with the same key, use a list as value.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: dict
subject_strict:
description:
- If set to C(yes), the I(subject) field must contain only these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
has_expired:
description:
- Checks if the certificate is expired/not expired at the time the module is executed.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
version:
description:
- The version of the certificate.
- Nowadays it should almost always be 3.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: int
valid_at:
description:
- The certificate must be valid at this point in time.
- The timestamp is formatted as an ASN.1 TIME.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: str
invalid_at:
description:
- The certificate must be invalid at this point in time.
- The timestamp is formatted as an ASN.1 TIME.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: str
not_before:
description:
- The certificate must start to become valid at this point in time.
- The timestamp is formatted as an ASN.1 TIME.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: str
aliases: [ notBefore ]
not_after:
description:
- The certificate must expire at this point in time.
- The timestamp is formatted as an ASN.1 TIME.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: str
aliases: [ notAfter ]
valid_in:
description:
- The certificate must still be valid at this relative time offset from now.
- Valid format is C([+-]timespec | number_of_seconds) where timespec can be an integer
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
- Note that if using this parameter, this module is NOT idempotent.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: str
key_usage:
description:
- The I(key_usage) extension field must contain all these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: list
elements: str
aliases: [ keyUsage ]
key_usage_strict:
description:
- If set to C(yes), the I(key_usage) extension field must contain only these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
aliases: [ keyUsage_strict ]
extended_key_usage:
description:
- The I(extended_key_usage) extension field must contain all these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: list
elements: str
aliases: [ extendedKeyUsage ]
extended_key_usage_strict:
description:
- If set to C(yes), the I(extended_key_usage) extension field must contain only these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
aliases: [ extendedKeyUsage_strict ]
subject_alt_name:
description:
- The I(subject_alt_name) extension field must contain these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: list
elements: str
aliases: [ subjectAltName ]
subject_alt_name_strict:
description:
- If set to C(yes), the I(subject_alt_name) extension field must contain only these values.
- This is only used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
aliases: [ subjectAltName_strict ]
'''
BACKEND_ENTRUST_DOCUMENTATION = r'''
options:
entrust_cert_type:
@@ -386,6 +193,7 @@ options:
- The minimum certificate lifetime is 90 days, and maximum is three years.
- If this value is not specified, the certificate will stop being valid 365 days the date of issue.
- This is only used by the C(entrust) provider.
- Please note that this value is B(not) covered by the I(ignore_timestamps) option.
type: str
default: +365d
@@ -457,8 +265,10 @@ options:
- Time will always be interpreted as UTC.
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
- Note that if using relative time this module is NOT idempotent.
- If this value is not specified, the certificate will start being valid from now.
- Note that this value is B(not used to determine whether an existing certificate should be regenerated).
This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should
avoid relative timestamps when setting I(ignore_timestamps=false).
- This is only used by the C(ownca) provider.
type: str
default: +0s
@@ -470,8 +280,10 @@ options:
- Time will always be interpreted as UTC.
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
- Note that if using relative time this module is NOT idempotent.
- If this value is not specified, the certificate will stop being valid 10 years from now.
- Note that this value is B(not used to determine whether an existing certificate should be regenerated).
This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should
avoid relative timestamps when setting I(ignore_timestamps=false).
- This is only used by the C(ownca) provider.
- On macOS 10.15 and onwards, TLS server certificates must have a validity period of 825 days or fewer.
Please see U(https://support.apple.com/en-us/HT210176) for more details.
@@ -548,8 +360,10 @@ options:
- Time will always be interpreted as UTC.
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
- Note that if using relative time this module is NOT idempotent.
- If this value is not specified, the certificate will start being valid from now.
- Note that this value is B(not used to determine whether an existing certificate should be regenerated).
This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should
avoid relative timestamps when setting I(ignore_timestamps=false).
- This is only used by the C(selfsigned) provider.
type: str
default: +0s
@@ -562,8 +376,10 @@ options:
- Time will always be interpreted as UTC.
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h).
- Note that if using relative time this module is NOT idempotent.
- If this value is not specified, the certificate will stop being valid 10 years from now.
- Note that this value is B(not used to determine whether an existing certificate should be regenerated).
This can be changed by setting the I(ignore_timestamps) option to C(false). Please note that you should
avoid relative timestamps when setting I(ignore_timestamps=false).
- This is only used by the C(selfsigned) provider.
- On macOS 10.15 and onwards, TLS server certificates must have a validity period of 825 days or fewer.
Please see U(https://support.apple.com/en-us/HT210176) for more details.

View File

@@ -15,13 +15,8 @@ description:
- This module allows one to (re)generate OpenSSL certificate signing requests.
- This module supports the subjectAltName, keyUsage, extendedKeyUsage, basicConstraints and OCSP Must Staple
extensions.
- "The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available. This can be
overridden with the I(select_crypto_backend) option. Please note that the
PyOpenSSL backend was deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0."
requirements:
- Either cryptography >= 1.3
- Or pyOpenSSL >= 0.15
- cryptography >= 1.3
options:
digest:
description:
@@ -48,14 +43,29 @@ options:
- The version of the certificate signing request.
- "The only allowed value according to L(RFC 2986,https://tools.ietf.org/html/rfc2986#section-4.1)
is 1."
- This option will no longer accept unsupported values from community.crypto 2.0.0 on.
- This option no longer accepts unsupported values since community.crypto 2.0.0.
type: int
default: 1
choices:
- 1
subject:
description:
- Key/value pairs that will be present in the subject name field of the certificate signing request.
- If you need to specify more than one value with the same key, use a list as value.
- If the order of the components is important, use I(subject_ordered).
- Mutually exclusive with I(subject_ordered).
type: dict
subject_ordered:
description:
- A list of dictionaries, where every dictionary must contain one key/value pair. This key/value pair
will be present in the subject name field of the certificate signing request.
- If you want to specify more than one value with the same key in a row, you can use a list as value.
- Mutually exclusive with I(subject), and any other subject field option, such as I(country_name),
I(state_or_province_name), I(locality_name), I(organization_name), I(organizational_unit_name),
I(common_name), or I(email_address).
type: list
elements: dict
version_added: 2.0.0
country_name:
description:
- The countryName field of the certificate signing request subject.
@@ -183,7 +193,7 @@ options:
name_constraints_excluded:
description:
- For CA certificates, this specifies a list of identifiers which describe
subtrees of names that this CA is *not* allowed to issue certificates for.
subtrees of names that this CA is B(not) allowed to issue certificates for.
- 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).
type: list
@@ -196,14 +206,11 @@ options:
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- 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 ]
choices: [ auto, cryptography ]
create_subject_key_identifier:
description:
- Create the Subject Key Identifier from the public key.
@@ -227,12 +234,11 @@ options:
description:
- 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)"
- If specified, I(authority_cert_issuer) must also be specified.
- "Please note that commercial CAs ignore this value, respectively use a value of their
own choice. Specifying this option is mostly useful for self-signed certificates
or for own CAs."
- 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.
type: str
authority_cert_issuer:
@@ -241,25 +247,68 @@ options:
- 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)
- "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
own choice. Specifying this option is mostly useful for self-signed certificates
or for own CAs."
- Note that this is only supported if the C(cryptography) backend is used!
- 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.
type: list
elements: str
authority_cert_serial_number:
description:
- 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!
- "Please note that commercial CAs ignore this value, respectively use a value of their
own choice. Specifying this option is mostly useful for self-signed certificates
or for own CAs."
- 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.
type: int
crl_distribution_points:
description:
- Allows to specify one or multiple CRL distribution points.
- Only supported by the C(cryptography) backend.
type: list
elements: dict
suboptions:
full_name:
description:
- Describes how the CRL can be retrieved.
- Mutually exclusive with I(relative_name).
- "Example: C(URI:https://ca.example.com/revocations.crl)."
type: list
elements: str
relative_name:
description:
- Describes how the CRL can be retrieved relative to the CRL issuer.
- Mutually exclusive with I(full_name).
- "Example: C(/CN=example.com)."
- Can only be used when cryptography >= 1.6 is installed.
type: list
elements: str
crl_issuer:
description:
- Information about the issuer of the CRL.
type: list
elements: str
reasons:
description:
- List of reasons that this distribution point can be used for when performing revocation checks.
type: list
elements: str
choices:
- key_compromise
- ca_compromise
- affiliation_changed
- superseded
- cessation_of_operation
- certificate_hold
- privilege_withdrawn
- aa_compromise
version_added: 1.4.0
notes:
- If the certificate signing request already exists it will be checked whether subjectAltName,
keyUsage, extendedKeyUsage and basicConstraints only contain the requested values, whether

View File

@@ -20,15 +20,10 @@ description:
- "Please note that the module regenerates private keys if they don't match
the module's options. In particular, if you provide another passphrase
(or specify none), change the keysize, etc., the private key will be
regenerated. If you are concerned that this could **overwrite your private key**,
regenerated. If you are concerned that this could B(overwrite your private key),
consider using the I(backup) option."
- "The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available. This can be
overridden with the I(select_crypto_backend) option. Please note that the
PyOpenSSL backend was deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0."
requirements:
- Either cryptography >= 1.2.3 (older versions might work as well)
- Or pyOpenSSL
- cryptography >= 1.2.3 (older versions might work as well)
options:
size:
description:
@@ -80,22 +75,16 @@ options:
type: str
cipher:
description:
- The cipher to encrypt the private key. (Valid values can be found by
running `openssl list -cipher-algorithms` or `openssl list-cipher-algorithms`,
depending on your OpenSSL version.)
- When using the C(cryptography) backend, use C(auto).
- The cipher to encrypt the private key. Must be C(auto).
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- 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 ]
choices: [ auto, cryptography ]
format:
description:
- Determines which format the private key is written in. By default, PKCS1 (traditional OpenSSL format)
@@ -103,10 +92,8 @@ options:
- The value C(auto) selects a fromat based on the key format. The value C(auto_ignore) does the same,
but for existing private key files, it will not force a regenerate when its format is not the automatically
selected one for generation.
- Note that if the format for an existing private key mismatches, the key is *regenerated* by default.
- Note that if the format for an existing private key mismatches, the key is B(regenerated) by default.
To change this behavior, use the I(format_mismatch) option.
- The I(format) option is only supported by the C(cryptography) backend. The C(pyopenssl) backend will
fail if a value different from C(auto_ignore) is used.
type: str
default: auto_ignore
choices: [ pkcs1, pkcs8, raw, auto, auto_ignore ]

File diff suppressed because it is too large Load Diff

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,389 @@
# -*- 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
import traceback
from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.urls import fetch_url
from ansible.module_utils.six import PY3
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,
)
try:
import ipaddress
except ImportError:
HAS_IPADDRESS = False
IPADDRESS_IMPORT_ERROR = traceback.format_exc()
else:
HAS_IPADDRESS = True
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:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if PY3 and resp.closed:
raise TypeError
content = resp.read()
except (AttributeError, TypeError):
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:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if PY3 and resp.closed:
raise TypeError
content = resp.read()
except (AttributeError, TypeError):
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', required=True),
acme_version=dict(type='int', required=True, choices=[1, 2]),
validate_certs=dict(type='bool', default=True),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']),
)
def create_backend(module, needs_acme_v2):
if not HAS_IPADDRESS:
module.fail_json(msg=missing_required_lib('ipaddress'), exception=IPADDRESS_IMPORT_ERROR)
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 needs_acme_v2 and module.params['acme_version'] < 2:
module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name))
if module.params['acme_version'] == 1:
module.deprecate("The value 1 for 'acme_version' is deprecated. Please switch to ACME v2",
version='3.0.0', collection_name='community.crypto')
# AnsibleModule() changes the locale, so change it back to C because we rely
# on datetime.datetime.strptime() when parsing certificate dates.
locale.setlocale(locale.LC_ALL, 'C')
return module_backend

View File

@@ -0,0 +1,382 @@
# -*- 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, to_text
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,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
extract_first_pem,
)
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, 'subject')
]
if criterium.issuer:
self.issuer = [
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.issuer, '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
# Make sure we have at most one PEM. Otherwise cryptography 36.0.0 will barf.
cert_content = to_bytes(extract_first_pem(to_text(cert_content)) or '')
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,301 @@
# -*- 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
try:
import ipaddress
except ImportError:
pass
_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(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,302 @@
# -*- 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.acme.utils import (
nopad_b64,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
format_error_problem,
ACMEProtocolException,
ModuleFailException,
)
try:
import ipaddress
except ImportError:
pass
def create_key_authorization(client, token):
'''
Returns the key authorization for the given token
https://tools.ietf.org/html/rfc8555#section-8.1
'''
accountkey_json = json.dumps(client.account_jwk, sort_keys=True, separators=(',', ':'))
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
return "{0}.{1}".format(token, thumbprint)
def combine_identifier(identifier_type, identifier):
return '{type}:{identifier}'.format(type=identifier_type, identifier=identifier)
def 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 = 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,135 @@
# -*- 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.common.text.converters import to_text
from ansible.module_utils.six import binary_type, PY3
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:
# In Python 2, reading from a closed response yields a TypeError.
# In Python 3, read() simply returns ''
if PY3 and response.closed:
raise TypeError
content = response.read()
except (AttributeError, TypeError):
content = info.pop('body', None)
# Make sure that content_json is None or a dictionary
if content_json is not None and not isinstance(content_json, dict):
if content is None and isinstance(content_json, binary_type):
content = content_json
content_json = None
# Try to get hold of JSON decoded content, when content is given and JSON not provided
if content_json is None and content is not None and module is not None:
try:
content_json = module.from_json(to_text(content))
except Exception 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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +0,0 @@
# -*- coding: utf-8 -*-
#
# (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
#
# 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
# THIS FILE IS FOR COMPATIBILITY ONLY! YOU SHALL NOT IMPORT IT!
#
# This fill will be removed eventually, so if you're using it,
# please stop doing so.
from .basic import (
HAS_PYOPENSSL,
CRYPTOGRAPHY_HAS_X25519,
CRYPTOGRAPHY_HAS_X25519_FULL,
CRYPTOGRAPHY_HAS_X448,
CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448,
HAS_CRYPTOGRAPHY,
OpenSSLObjectError,
OpenSSLBadPassphraseError,
)
from .cryptography_crl import (
REVOCATION_REASON_MAP,
REVOCATION_REASON_MAP_INVERSE,
cryptography_decode_revoked_certificate,
)
from .cryptography_support import (
cryptography_get_extensions_from_cert,
cryptography_get_extensions_from_csr,
cryptography_name_to_oid,
cryptography_oid_to_name,
cryptography_get_name,
cryptography_decode_name,
cryptography_parse_key_usage_params,
cryptography_get_basic_constraints,
cryptography_key_needs_digest_for_signing,
cryptography_compare_public_keys,
)
from .identify import (
identify_private_key_format,
)
from .math import (
binary_exp_mod,
simple_gcd,
quick_is_not_prime,
count_bits,
)
from ._obj2txt import obj2txt as _obj2txt
from ._objects_data import OID_MAP as _OID_MAP
from ._objects import OID_LOOKUP as _OID_LOOKUP
from ._objects import NORMALIZE_NAMES as _NORMALIZE_NAMES
from ._objects import NORMALIZE_NAMES_SHORT as _NORMALIZE_NAMES_SHORT
from .pyopenssl_support import (
pyopenssl_normalize_name,
pyopenssl_get_extensions_from_cert,
pyopenssl_get_extensions_from_csr,
)
from .support import (
get_fingerprint_of_bytes,
get_fingerprint,
load_privatekey,
load_certificate,
load_certificate_request,
parse_name_field,
convert_relative_to_datetime,
get_relative_time_option,
select_message_digest,
OpenSSLObject,
)
from ..io import (
load_file_if_exists,
write_file,
)

View File

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

View File

@@ -20,6 +20,10 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
# WARNING: this function no longer works with cryptography 35.0.0 and newer!
# It must **ONLY** be used in compatibility code for older
# cryptography versions!
def obj2txt(openssl_lib, openssl_ffi, obj):
# Set to 80 on the recommendation of
# https://www.openssl.org/docs/crypto/OBJ_nid2ln.html#return_values

View File

@@ -22,14 +22,6 @@ __metaclass__ = type
from distutils.version import LooseVersion
try:
import OpenSSL # noqa
from OpenSSL import crypto # noqa
HAS_PYOPENSSL = True
except ImportError:
# Error handled in the calling module.
HAS_PYOPENSSL = False
try:
import cryptography
from cryptography import x509

View File

@@ -22,19 +22,41 @@ __metaclass__ = type
import base64
import binascii
import re
import sys
from ansible.module_utils._text import to_text
from distutils.version import LooseVersion
from ansible.module_utils.common.text.converters import to_text, to_bytes
from ._asn1 import serialize_asn1_string_as_der
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import ipaddress
except ImportError:
# Error handled in the calling module.
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
try:
# This is a separate try/except since this is only present in cryptography 36.0.0 or newer
from cryptography.hazmat.primitives.serialization.pkcs12 import (
load_pkcs12 as _load_pkcs12,
)
except ImportError:
# Error handled in the calling module.
_load_pkcs12 = None
from .basic import (
CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448,
@@ -55,60 +77,114 @@ DOTTED_OID = re.compile(r'^\d+(?:\.\d+)+$')
def cryptography_get_extensions_from_cert(cert):
# Since cryptography won't give us the DER value for an extension
# (that is only stored for unrecognized extensions), we have to re-do
# the extension parsing outselves.
result = dict()
backend = cert._backend
x509_obj = cert._x509
try:
# Since cryptography won't give us the DER value for an extension
# (that is only stored for unrecognized extensions), we have to re-do
# the extension parsing outselves.
backend = default_backend()
try:
# For certain old versions of cryptography, backend is a MultiBackend object,
# which has no _lib attribute. In that case, revert to the old approach.
backend._lib
except AttributeError:
backend = cert._backend
x509_obj = cert._x509
# With cryptography 35.0.0, we can no longer use obj2txt. Unfortunately it still does
# not allow to get the raw value of an extension, so we have to use this ugly hack:
exts = list(cert.extensions)
for i in range(backend._lib.X509_get_ext_count(x509_obj)):
ext = backend._lib.X509_get_ext(x509_obj, i)
if ext == backend._ffi.NULL:
continue
crit = backend._lib.X509_EXTENSION_get_critical(ext)
data = backend._lib.X509_EXTENSION_get_data(ext)
backend.openssl_assert(data != backend._ffi.NULL)
der = backend._ffi.buffer(data.data, data.length)[:]
entry = dict(
critical=(crit == 1),
value=base64.b64encode(der),
)
try:
oid = obj2txt(backend._lib, backend._ffi, backend._lib.X509_EXTENSION_get_object(ext))
except AttributeError:
oid = exts[i].oid.dotted_string
result[oid] = entry
except Exception:
# In case the above method breaks, we likely have cryptography 36.0.0 or newer.
# Use it's public_bytes() feature in that case. We will later switch this around
# so that this code will be the default, but for now this will act as a fallback
# since it will re-serialize de-serialized data, which can be different (if the
# original data was not canonicalized) from what was contained in the certificate.
for ext in cert.extensions:
result[ext.oid.dotted_string] = dict(
critical=ext.critical,
value=base64.b64encode(ext.value.public_bytes()),
)
for i in range(backend._lib.X509_get_ext_count(x509_obj)):
ext = backend._lib.X509_get_ext(x509_obj, i)
if ext == backend._ffi.NULL:
continue
crit = backend._lib.X509_EXTENSION_get_critical(ext)
data = backend._lib.X509_EXTENSION_get_data(ext)
backend.openssl_assert(data != backend._ffi.NULL)
der = backend._ffi.buffer(data.data, data.length)[:]
entry = dict(
critical=(crit == 1),
value=base64.b64encode(der),
)
oid = obj2txt(backend._lib, backend._ffi, backend._lib.X509_EXTENSION_get_object(ext))
result[oid] = entry
return result
def cryptography_get_extensions_from_csr(csr):
# Since cryptography won't give us the DER value for an extension
# (that is only stored for unrecognized extensions), we have to re-do
# the extension parsing outselves.
result = dict()
backend = csr._backend
try:
# Since cryptography won't give us the DER value for an extension
# (that is only stored for unrecognized extensions), we have to re-do
# the extension parsing outselves.
backend = default_backend()
try:
# For certain old versions of cryptography, backend is a MultiBackend object,
# which has no _lib attribute. In that case, revert to the old approach.
backend._lib
except AttributeError:
backend = csr._backend
extensions = backend._lib.X509_REQ_get_extensions(csr._x509_req)
extensions = backend._ffi.gc(
extensions,
lambda ext: backend._lib.sk_X509_EXTENSION_pop_free(
ext,
backend._ffi.addressof(backend._lib._original_lib, "X509_EXTENSION_free")
extensions = backend._lib.X509_REQ_get_extensions(csr._x509_req)
extensions = backend._ffi.gc(
extensions,
lambda ext: backend._lib.sk_X509_EXTENSION_pop_free(
ext,
backend._ffi.addressof(backend._lib._original_lib, "X509_EXTENSION_free")
)
)
)
for i in range(backend._lib.sk_X509_EXTENSION_num(extensions)):
ext = backend._lib.sk_X509_EXTENSION_value(extensions, i)
if ext == backend._ffi.NULL:
continue
crit = backend._lib.X509_EXTENSION_get_critical(ext)
data = backend._lib.X509_EXTENSION_get_data(ext)
backend.openssl_assert(data != backend._ffi.NULL)
der = backend._ffi.buffer(data.data, data.length)[:]
entry = dict(
critical=(crit == 1),
value=base64.b64encode(der),
)
oid = obj2txt(backend._lib, backend._ffi, backend._lib.X509_EXTENSION_get_object(ext))
result[oid] = entry
# With cryptography 35.0.0, we can no longer use obj2txt. Unfortunately it still does
# not allow to get the raw value of an extension, so we have to use this ugly hack:
exts = list(csr.extensions)
for i in range(backend._lib.sk_X509_EXTENSION_num(extensions)):
ext = backend._lib.sk_X509_EXTENSION_value(extensions, i)
if ext == backend._ffi.NULL:
continue
crit = backend._lib.X509_EXTENSION_get_critical(ext)
data = backend._lib.X509_EXTENSION_get_data(ext)
backend.openssl_assert(data != backend._ffi.NULL)
der = backend._ffi.buffer(data.data, data.length)[:]
entry = dict(
critical=(crit == 1),
value=base64.b64encode(der),
)
try:
oid = obj2txt(backend._lib, backend._ffi, backend._lib.X509_EXTENSION_get_object(ext))
except AttributeError:
oid = exts[i].oid.dotted_string
result[oid] = entry
except Exception:
# In case the above method breaks, we likely have cryptography 36.0.0 or newer.
# Use it's public_bytes() feature in that case. We will later switch this around
# so that this code will be the default, but for now this will act as a fallback
# since it will re-serialize de-serialized data, which can be different (if the
# original data was not canonicalized) from what was contained in the CSR.
for ext in csr.extensions:
result[ext.oid.dotted_string] = dict(
critical=ext.critical,
value=base64.b64encode(ext.value.public_bytes()),
)
return result
@@ -152,6 +228,70 @@ def _parse_hex(bytesstr):
return data
DN_COMPONENT_START_RE = re.compile(b'^ *([a-zA-z0-9.]+) *= *')
DN_HEX_LETTER = b'0123456789abcdef'
if sys.version_info[0] < 3:
_int_to_byte = chr
else:
def _int_to_byte(value):
return bytes((value, ))
def _parse_dn_component(name, sep=b',', decode_remainder=True):
m = DN_COMPONENT_START_RE.match(name)
if not m:
raise OpenSSLObjectError(u'cannot start part in "{0}"'.format(to_text(name)))
oid = cryptography_name_to_oid(to_text(m.group(1)))
idx = len(m.group(0))
decoded_name = []
sep_str = sep + b'\\'
if decode_remainder:
length = len(name)
if length > idx and name[idx:idx + 1] == b'#':
# Decoding a hex string
idx += 1
while idx + 1 < length:
ch1 = name[idx:idx + 1]
ch2 = name[idx + 1:idx + 2]
idx1 = DN_HEX_LETTER.find(ch1.lower())
idx2 = DN_HEX_LETTER.find(ch2.lower())
if idx1 < 0 or idx2 < 0:
raise OpenSSLObjectError(u'Invalid hex sequence entry "{0}"'.format(to_text(ch1 + ch2)))
idx += 2
decoded_name.append(_int_to_byte(idx1 * 16 + idx2))
else:
# Decoding a regular string
while idx < length:
i = idx
while i < length and name[i:i + 1] not in sep_str:
i += 1
if i > idx:
decoded_name.append(name[idx:i])
idx = i
while idx + 1 < length and name[idx:idx + 1] == b'\\':
ch = name[idx + 1:idx + 2]
idx1 = DN_HEX_LETTER.find(ch.lower())
if idx1 >= 0:
if idx + 2 >= length:
raise OpenSSLObjectError(u'Hex escape sequence "\\{0}" incomplete at end of string'.format(to_text(ch)))
ch2 = name[idx + 2:idx + 3]
idx2 = DN_HEX_LETTER.find(ch2.lower())
if idx2 < 0:
raise OpenSSLObjectError(u'Hex escape sequence "\\{0}" has invalid second letter'.format(to_text(ch + ch2)))
ch = _int_to_byte(idx1 * 16 + idx2)
idx += 1
idx += 2
decoded_name.append(ch)
if idx < length and name[idx:idx + 1] == sep:
break
else:
decoded_name.append(name[idx:])
idx = len(name)
return x509.NameAttribute(oid, to_text(b''.join(decoded_name))), name[idx:]
def _parse_dn(name):
'''
Parse a Distinguished Name.
@@ -160,45 +300,37 @@ def _parse_dn(name):
'''
original_name = name
name = name.lstrip()
sep = ','
if name.startswith('/'):
sep = '/'
sep = b','
if name.startswith(b'/'):
sep = b'/'
name = name[1:]
sep_str = sep + '\\'
result = []
start_re = re.compile(r'^ *([a-zA-z0-9]+) *= *')
while name:
m = start_re.match(name)
if not m:
raise OpenSSLObjectError('Error while parsing distinguished name "{0}": cannot start part in "{1}"'.format(original_name, name))
oid = cryptography_name_to_oid(m.group(1))
idx = len(m.group(0))
decoded_name = []
length = len(name)
while idx < length:
i = idx
while i < length and name[i] not in sep_str:
i += 1
if i > idx:
decoded_name.append(name[idx:i])
idx = i
while idx + 1 < length and name[idx] == '\\':
decoded_name.append(name[idx + 1])
idx += 2
if idx < length and name[idx] == sep:
break
result.append(x509.NameAttribute(oid, ''.join(decoded_name)))
name = name[idx:]
try:
attribute, name = _parse_dn_component(name, sep=sep)
except OpenSSLObjectError as e:
raise OpenSSLObjectError(u'Error while parsing distinguished name "{0}": {1}'.format(to_text(original_name), e))
result.append(attribute)
if name:
if name[0] != sep or len(name) < 2:
raise OpenSSLObjectError('Error while parsing distinguished name "{0}": unexpected end of string'.format(original_name))
if name[0:1] != sep or len(name) < 2:
raise OpenSSLObjectError(u'Error while parsing distinguished name "{0}": unexpected end of string'.format(to_text(original_name)))
name = name[1:]
return result
def cryptography_get_name(name):
def cryptography_parse_relative_distinguished_name(rdn):
names = []
for part in rdn:
try:
names.append(_parse_dn_component(to_bytes(part), decode_remainder=False)[0])
except OpenSSLObjectError as e:
raise OpenSSLObjectError(u'Error while parsing relative distinguished name "{0}": {1}'.format(part, e))
return cryptography.x509.RelativeDistinguishedName(names)
def cryptography_get_name(name, what='Subject Alternative Name'):
'''
Given a name string, returns a cryptography x509.Name object.
Given a name string, returns a cryptography x509.GeneralName object.
Raises an OpenSSLObjectError if the name is unknown or cannot be parsed.
'''
try:
@@ -216,7 +348,7 @@ def cryptography_get_name(name):
if name.startswith('RID:'):
m = re.match(r'^([0-9]+(?:\.[0-9]+)*)$', to_text(name[4:]))
if not m:
raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}"'.format(name))
raise OpenSSLObjectError('Cannot parse {what} "{name}"'.format(name=name, what=what))
return x509.RegisteredID(x509.oid.ObjectIdentifier(m.group(1)))
if name.startswith('otherName:'):
# otherName can either be a raw ASN.1 hex string or in the format that OpenSSL works with.
@@ -228,58 +360,63 @@ def cryptography_get_name(name):
# defailts on the format expected.
name = to_text(name[10:], errors='surrogate_or_strict')
if ';' not in name:
raise OpenSSLObjectError('Cannot parse Subject Alternative Name otherName "{0}", must be in the '
raise OpenSSLObjectError('Cannot parse {what} otherName "{name}", must be in the '
'format "otherName:<OID>;<ASN.1 OpenSSL Encoded String>" or '
'"otherName:<OID>;<hex string>"'.format(name))
'"otherName:<OID>;<hex string>"'.format(name=name, what=what))
oid, value = name.split(';', 1)
b_value = serialize_asn1_string_as_der(value)
return x509.OtherName(x509.ObjectIdentifier(oid), b_value)
if name.startswith('dirName:'):
return x509.DirectoryName(x509.Name(_parse_dn(to_text(name[8:]))))
return x509.DirectoryName(x509.Name(reversed(_parse_dn(to_bytes(name[8:])))))
except Exception as e:
raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}": {1}'.format(name, e))
raise OpenSSLObjectError('Cannot parse {what} "{name}": {error}'.format(name=name, what=what, error=e))
if ':' not in name:
raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}" (forgot "DNS:" prefix?)'.format(name))
raise OpenSSLObjectError('Cannot parse Subject Alternative Name "{0}" (potentially unsupported by cryptography backend)'.format(name))
raise OpenSSLObjectError('Cannot parse {what} "{name}" (forgot "DNS:" prefix?)'.format(name=name, what=what))
raise OpenSSLObjectError('Cannot parse {what} "{name}" (potentially unsupported by cryptography backend)'.format(name=name, what=what))
def _dn_escape_value(value):
'''
Escape Distinguished Name's attribute value.
'''
value = value.replace('\\', '\\\\')
for ch in [',', '#', '+', '<', '>', ';', '"', '=', '/']:
value = value.replace(ch, '\\%s' % ch)
if value.startswith(' '):
value = r'\ ' + value[1:]
value = value.replace(u'\\', u'\\\\')
for ch in [u',', u'+', u'<', u'>', u';', u'"']:
value = value.replace(ch, u'\\%s' % ch)
value = value.replace(u'\0', u'\\00')
if value.startswith((u' ', u'#')):
value = u'\\%s' % value[0] + value[1:]
if value.endswith(u' '):
value = value[:-1] + u'\\ '
return value
def cryptography_decode_name(name):
'''
Given a cryptography x509.Name object, returns a string.
Given a cryptography x509.GeneralName object, returns a string.
Raises an OpenSSLObjectError if the name is not supported.
'''
if isinstance(name, x509.DNSName):
return 'DNS:{0}'.format(name.value)
return u'DNS:{0}'.format(name.value)
if isinstance(name, x509.IPAddress):
if isinstance(name.value, (ipaddress.IPv4Network, ipaddress.IPv6Network)):
return 'IP:{0}/{1}'.format(name.value.network_address.compressed, name.value.prefixlen)
return 'IP:{0}'.format(name.value.compressed)
return u'IP:{0}/{1}'.format(name.value.network_address.compressed, name.value.prefixlen)
return u'IP:{0}'.format(name.value.compressed)
if isinstance(name, x509.RFC822Name):
return 'email:{0}'.format(name.value)
return u'email:{0}'.format(name.value)
if isinstance(name, x509.UniformResourceIdentifier):
return 'URI:{0}'.format(name.value)
return u'URI:{0}'.format(name.value)
if isinstance(name, x509.DirectoryName):
return 'dirName:' + ''.join([
'/{0}={1}'.format(cryptography_oid_to_name(attribute.oid, short=True), _dn_escape_value(attribute.value))
for attribute in name.value
# According to https://datatracker.ietf.org/doc/html/rfc4514.html#section-2.1 the
# list needs to be reversed, and joined by commas
return u'dirName:' + ','.join([
u'{0}={1}'.format(to_text(cryptography_oid_to_name(attribute.oid, short=True)), _dn_escape_value(attribute.value))
for attribute in reversed(list(name.value))
])
if isinstance(name, x509.RegisteredID):
return 'RID:{0}'.format(name.value.dotted_string)
return u'RID:{0}'.format(name.value.dotted_string)
if isinstance(name, x509.OtherName):
return 'otherName:{0};{1}'.format(name.type_id.dotted_string, _get_hex(name.value))
return u'otherName:{0};{1}'.format(name.type_id.dotted_string, _get_hex(name.value))
raise OpenSSLObjectError('Cannot decode name "{0}"'.format(name))
@@ -405,3 +542,79 @@ def cryptography_serial_number_of_cert(cert):
except AttributeError:
# The property was called "serial" before cryptography 1.4
return cert.serial
def parse_pkcs12(pkcs12_bytes, passphrase=None):
'''Returns a tuple (private_key, certificate, additional_certificates, friendly_name).
'''
if _load_pkcs12 is None and _load_key_and_certificates is None:
raise ValueError('neither load_pkcs12() nor load_key_and_certificates() present in the current cryptography version')
if passphrase is not None:
passphrase = to_bytes(passphrase)
# Main code for cryptography 36.0.0 and forward
if _load_pkcs12 is not None:
return _parse_pkcs12_36_0_0(pkcs12_bytes, passphrase)
if LooseVersion(cryptography.__version__) >= LooseVersion('35.0'):
return _parse_pkcs12_35_0_0(pkcs12_bytes, passphrase)
return _parse_pkcs12_legacy(pkcs12_bytes, passphrase)
def _parse_pkcs12_36_0_0(pkcs12_bytes, passphrase=None):
# Requires cryptography 36.0.0 or newer
pkcs12 = _load_pkcs12(pkcs12_bytes, passphrase)
additional_certificates = [cert.certificate for cert in pkcs12.additional_certs]
private_key = pkcs12.key
certificate = None
friendly_name = None
if pkcs12.cert:
certificate = pkcs12.cert.certificate
friendly_name = pkcs12.cert.friendly_name
return private_key, certificate, additional_certificates, friendly_name
def _parse_pkcs12_35_0_0(pkcs12_bytes, passphrase=None):
# Backwards compatibility code for cryptography 35.x
private_key, certificate, additional_certificates = _load_key_and_certificates(pkcs12_bytes, passphrase)
friendly_name = None
if certificate:
# See https://github.com/pyca/cryptography/issues/5760#issuecomment-842687238
backend = default_backend()
# This code basically does what load_key_and_certificates() does, but without error-checking.
# Since load_key_and_certificates succeeded, it should not fail.
pkcs12 = backend._ffi.gc(
backend._lib.d2i_PKCS12_bio(backend._bytes_to_bio(pkcs12_bytes).bio, backend._ffi.NULL),
backend._lib.PKCS12_free)
certificate_x509_ptr = backend._ffi.new("X509 **")
with backend._zeroed_null_terminated_buf(to_bytes(passphrase) if passphrase is not None else None) as passphrase_buffer:
backend._lib.PKCS12_parse(
pkcs12,
passphrase_buffer,
backend._ffi.new("EVP_PKEY **"),
certificate_x509_ptr,
backend._ffi.new("Cryptography_STACK_OF_X509 **"))
if certificate_x509_ptr[0] != backend._ffi.NULL:
maybe_name = backend._lib.X509_alias_get0(certificate_x509_ptr[0], backend._ffi.NULL)
if maybe_name != backend._ffi.NULL:
friendly_name = backend._ffi.string(maybe_name)
return private_key, certificate, additional_certificates, friendly_name
def _parse_pkcs12_legacy(pkcs12_bytes, passphrase=None):
# Backwards compatibility code for cryptography < 35.0.0
private_key, certificate, additional_certificates = _load_key_and_certificates(pkcs12_bytes, passphrase)
friendly_name = None
if certificate:
# See https://github.com/pyca/cryptography/issues/5760#issuecomment-842687238
backend = certificate._backend
maybe_name = backend._lib.X509_alias_get0(certificate._x509, backend._ffi.NULL)
if maybe_name != backend._ffi.NULL:
friendly_name = backend._ffi.string(maybe_name)
return private_key, certificate, additional_certificates, friendly_name

View File

@@ -33,19 +33,11 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp
cryptography_compare_public_keys,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
MINIMAL_PYOPENSSL_VERSION = '0.15'
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import (
get_certificate_info,
)
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
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
CRYPTOGRAPHY_IMP_ERR = None
CRYPTOGRAPHY_VERSION = None
@@ -71,6 +63,7 @@ class CertificateBackend(object):
self.backend = backend
self.force = module.params['force']
self.ignore_timestamps = module.params['ignore_timestamps']
self.privatekey_path = module.params['privatekey_path']
self.privatekey_content = module.params['privatekey_content']
if self.privatekey_content is not None:
@@ -95,6 +88,19 @@ class CertificateBackend(object):
self.check_csr_subject = True
self.check_csr_extensions = True
self.diff_before = self._get_info(None)
self.diff_after = self._get_info(None)
def _get_info(self, data):
if data is None:
return dict()
try:
result = get_certificate_info(self.module, self.backend, data, prefer_one_fingerprint=True)
result['can_parse_certificate'] = True
return result
except Exception as exc:
return dict(can_parse_certificate=False)
@abc.abstractmethod
def generate_certificate(self):
"""(Re-)Generate certificate."""
@@ -108,6 +114,7 @@ class CertificateBackend(object):
def set_existing(self, certificate_bytes):
"""Set existing certificate bytes. None indicates that the key does not exist."""
self.existing_certificate_bytes = certificate_bytes
self.diff_after = self.diff_before = self._get_info(self.existing_certificate_bytes)
def has_existing(self):
"""Query whether an existing certificate is/has been there."""
@@ -155,43 +162,12 @@ class CertificateBackend(object):
def _check_privatekey(self):
"""Check whether provided parameters match, assuming self.existing_certificate and self.privatekey have been populated."""
if self.backend == 'pyopenssl':
ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_2_METHOD)
ctx.use_privatekey(self.privatekey)
ctx.use_certificate(self.existing_certificate)
try:
ctx.check_privatekey()
return True
except OpenSSL.SSL.Error:
return False
elif self.backend == 'cryptography':
if self.backend == 'cryptography':
return cryptography_compare_public_keys(self.existing_certificate.public_key(), self.privatekey.public_key())
def _check_csr(self):
"""Check whether provided parameters match, assuming self.existing_certificate and self.csr have been populated."""
if self.backend == 'pyopenssl':
# Verify that CSR is signed by certificate's private key
try:
self.csr.verify(self.existing_certificate.get_pubkey())
except OpenSSL.crypto.Error:
return False
# Check subject
if self.check_csr_subject and self.csr.get_subject() != self.existing_certificate.get_subject():
return False
# Check extensions
if not self.check_csr_extensions:
return True
csr_extensions = self.csr.get_extensions()
cert_extension_count = self.existing_certificate.get_extension_count()
if len(csr_extensions) != cert_extension_count:
return False
for extension_number in range(0, cert_extension_count):
cert_extension = self.existing_certificate.get_extension(extension_number)
csr_extension = filter(lambda extension: extension.get_short_name() == cert_extension.get_short_name(), csr_extensions)
if cert_extension.get_data() != list(csr_extension)[0].get_data():
return False
return True
elif self.backend == 'cryptography':
if self.backend == 'cryptography':
# Verify that CSR is signed by certificate's private key
if not self.csr.is_signature_valid:
return False
@@ -226,10 +202,6 @@ class CertificateBackend(object):
def _check_subject_key_identifier(self):
"""Check whether Subject Key Identifier matches, assuming self.existing_certificate has been populated."""
if self.backend != 'cryptography':
# We do not support SKI with pyOpenSSL backend
return True
# Get hold of certificate's SKI
try:
ext = self.existing_certificate.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
@@ -252,7 +224,7 @@ class CertificateBackend(object):
return False
return True
def needs_regeneration(self):
def needs_regeneration(self, not_before=None, not_after=None):
"""Check whether a regeneration is necessary."""
if self.force or self.existing_certificate_bytes is None:
return True
@@ -276,6 +248,15 @@ class CertificateBackend(object):
if self.create_subject_key_identifier != 'never_create' and not self._check_subject_key_identifier():
return True
# Check not before
if not_before is not None and not self.ignore_timestamps:
if self.existing_certificate.not_valid_before != not_before:
return True
# Check not after
if not_after is not None and not self.ignore_timestamps:
if self.existing_certificate.not_valid_after != not_after:
return True
return False
def dump(self, include_certificate):
@@ -284,13 +265,19 @@ class CertificateBackend(object):
'privatekey': self.privatekey_path,
'csr': self.csr_path
}
# Get hold of certificate bytes
certificate_bytes = self.existing_certificate_bytes
if self.cert is not None:
certificate_bytes = self.get_certificate_data()
self.diff_after = self._get_info(certificate_bytes)
if include_certificate:
# 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
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
@@ -304,10 +291,6 @@ class CertificateProvider(object):
def needs_version_two_certs(self, module):
"""Whether the provider needs to create a version 2 certificate."""
def needs_pyopenssl_get_extensions(self, module):
"""Whether the provider needs to use get_extensions() with pyOpenSSL."""
return True
@abc.abstractmethod
def create_backend(self, module, backend):
"""Create an implementation for a backend.
@@ -328,45 +311,22 @@ def select_backend(module, backend, provider):
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'
if provider.needs_version_two_certs(module):
module.warn('crypto backend forced to pyopenssl. The cryptography library does not support v2 certificates')
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))
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
if backend == 'pyopenssl':
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
version='2.0.0', collection_name='community.crypto')
if not PYOPENSSL_FOUND:
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
exception=PYOPENSSL_IMP_ERR)
if provider.needs_pyopenssl_get_extensions(module):
try:
getattr(crypto.X509Req, 'get_extensions')
except AttributeError:
module.fail_json(msg='You need to have PyOpenSSL>=0.15')
elif backend == 'cryptography':
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
if provider.needs_version_two_certs(module):
module.fail_json(msg='The cryptography backend does not support v2 certificates, '
'use select_crypto_backend=pyopenssl for v2 certificates')
module.fail_json(msg='The cryptography backend does not support v2 certificates')
return provider.create_backend(module, backend)
@@ -378,7 +338,8 @@ def get_certificate_argument_spec():
force=dict(type='bool', default=False,),
csr_path=dict(type='path'),
csr_content=dict(type='str'),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
ignore_timestamps=dict(type='bool', default=True),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']),
# General properties of a certificate
privatekey_path=dict(type='path'),

View File

@@ -12,7 +12,7 @@ import os
import tempfile
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 (
CertificateError,

View File

@@ -1,664 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import abc
import datetime
from ansible.module_utils._text import to_native, to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
parse_name_field,
get_relative_time_option,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
cryptography_compare_public_keys,
cryptography_get_name,
cryptography_name_to_oid,
cryptography_parse_key_usage_params,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import (
pyopenssl_normalize_name_attribute,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate import (
CertificateBackend,
CertificateProvider,
)
try:
import OpenSSL
from OpenSSL import crypto
except ImportError:
pass
try:
import cryptography
from cryptography import x509
from cryptography.x509 import NameAttribute, Name
except ImportError:
pass
def compare_sets(subset, superset, equality=False):
if equality:
return set(subset) == set(superset)
else:
return all(x in superset for x in subset)
def compare_dicts(subset, superset, equality=False):
if equality:
return subset == superset
else:
return all(superset.get(x) == v for x, v in subset.items())
NO_EXTENSION = 'no extension'
class AssertOnlyCertificateBackend(CertificateBackend):
def __init__(self, module, backend):
super(AssertOnlyCertificateBackend, self).__init__(module, backend)
self.signature_algorithms = module.params['signature_algorithms']
if module.params['subject']:
self.subject = parse_name_field(module.params['subject'])
else:
self.subject = []
self.subject_strict = module.params['subject_strict']
if module.params['issuer']:
self.issuer = parse_name_field(module.params['issuer'])
else:
self.issuer = []
self.issuer_strict = module.params['issuer_strict']
self.has_expired = module.params['has_expired']
self.version = module.params['version']
self.key_usage = module.params['key_usage']
self.key_usage_strict = module.params['key_usage_strict']
self.extended_key_usage = module.params['extended_key_usage']
self.extended_key_usage_strict = module.params['extended_key_usage_strict']
self.subject_alt_name = module.params['subject_alt_name']
self.subject_alt_name_strict = module.params['subject_alt_name_strict']
self.not_before = module.params['not_before']
self.not_after = module.params['not_after']
self.valid_at = module.params['valid_at']
self.invalid_at = module.params['invalid_at']
self.valid_in = module.params['valid_in']
if self.valid_in and not self.valid_in.startswith("+") and not self.valid_in.startswith("-"):
try:
int(self.valid_in)
except ValueError:
module.fail_json(msg='The supplied value for "valid_in" (%s) is not an integer or a valid timespec' % self.valid_in)
self.valid_in = "+" + self.valid_in + "s"
# Load objects
self._ensure_private_key_loaded()
self._ensure_csr_loaded()
@abc.abstractmethod
def _validate_privatekey(self):
pass
@abc.abstractmethod
def _validate_csr_signature(self):
pass
@abc.abstractmethod
def _validate_csr_subject(self):
pass
@abc.abstractmethod
def _validate_csr_extensions(self):
pass
@abc.abstractmethod
def _validate_signature_algorithms(self):
pass
@abc.abstractmethod
def _validate_subject(self):
pass
@abc.abstractmethod
def _validate_issuer(self):
pass
@abc.abstractmethod
def _validate_has_expired(self):
pass
@abc.abstractmethod
def _validate_version(self):
pass
@abc.abstractmethod
def _validate_key_usage(self):
pass
@abc.abstractmethod
def _validate_extended_key_usage(self):
pass
@abc.abstractmethod
def _validate_subject_alt_name(self):
pass
@abc.abstractmethod
def _validate_not_before(self):
pass
@abc.abstractmethod
def _validate_not_after(self):
pass
@abc.abstractmethod
def _validate_valid_at(self):
pass
@abc.abstractmethod
def _validate_invalid_at(self):
pass
@abc.abstractmethod
def _validate_valid_in(self):
pass
def assertonly(self):
messages = []
if self.privatekey_path is not None or self.privatekey_content is not None:
if not self._validate_privatekey():
messages.append(
'Certificate %s and private key %s do not match' %
(self.path, self.privatekey_path or '(provided in module options)')
)
if self.csr_path is not None or self.csr_content is not None:
if not self._validate_csr_signature():
messages.append(
'Certificate %s and CSR %s do not match: private key mismatch' %
(self.path, self.csr_path or '(provided in module options)')
)
if not self._validate_csr_subject():
messages.append(
'Certificate %s and CSR %s do not match: subject mismatch' %
(self.path, self.csr_path or '(provided in module options)')
)
if not self._validate_csr_extensions():
messages.append(
'Certificate %s and CSR %s do not match: extensions mismatch' %
(self.path, self.csr_path or '(provided in module options)')
)
if self.signature_algorithms is not None:
wrong_alg = self._validate_signature_algorithms()
if wrong_alg:
messages.append(
'Invalid signature algorithm (got %s, expected one of %s)' %
(wrong_alg, self.signature_algorithms)
)
if self.subject is not None:
failure = self._validate_subject()
if failure:
dummy, cert_subject = failure
messages.append(
'Invalid subject component (got %s, expected all of %s to be present)' %
(cert_subject, self.subject)
)
if self.issuer is not None:
failure = self._validate_issuer()
if failure:
dummy, cert_issuer = failure
messages.append(
'Invalid issuer component (got %s, expected all of %s to be present)' % (cert_issuer, self.issuer)
)
if self.has_expired is not None:
cert_expired = self._validate_has_expired()
if cert_expired != self.has_expired:
messages.append(
'Certificate expiration check failed (certificate expiration is %s, expected %s)' %
(cert_expired, self.has_expired)
)
if self.version is not None:
cert_version = self._validate_version()
if cert_version != self.version:
messages.append(
'Invalid certificate version number (got %s, expected %s)' %
(cert_version, self.version)
)
if self.key_usage is not None:
failure = self._validate_key_usage()
if failure == NO_EXTENSION:
messages.append('Found no keyUsage extension')
elif failure:
dummy, cert_key_usage = failure
messages.append(
'Invalid keyUsage components (got %s, expected all of %s to be present)' %
(cert_key_usage, self.key_usage)
)
if self.extended_key_usage is not None:
failure = self._validate_extended_key_usage()
if failure == NO_EXTENSION:
messages.append('Found no extendedKeyUsage extension')
elif failure:
dummy, ext_cert_key_usage = failure
messages.append(
'Invalid extendedKeyUsage component (got %s, expected all of %s to be present)' % (ext_cert_key_usage, self.extended_key_usage)
)
if self.subject_alt_name is not None:
failure = self._validate_subject_alt_name()
if failure == NO_EXTENSION:
messages.append('Found no subjectAltName extension')
elif failure:
dummy, cert_san = failure
messages.append(
'Invalid subjectAltName component (got %s, expected all of %s to be present)' %
(cert_san, self.subject_alt_name)
)
if self.not_before is not None:
cert_not_valid_before = self._validate_not_before()
if cert_not_valid_before != get_relative_time_option(self.not_before, 'not_before', backend=self.backend):
messages.append(
'Invalid not_before component (got %s, expected %s to be present)' %
(cert_not_valid_before, self.not_before)
)
if self.not_after is not None:
cert_not_valid_after = self._validate_not_after()
if cert_not_valid_after != get_relative_time_option(self.not_after, 'not_after', backend=self.backend):
messages.append(
'Invalid not_after component (got %s, expected %s to be present)' %
(cert_not_valid_after, self.not_after)
)
if self.valid_at is not None:
not_before, valid_at, not_after = self._validate_valid_at()
if not (not_before <= valid_at <= not_after):
messages.append(
'Certificate is not valid for the specified date (%s) - not_before: %s - not_after: %s' %
(self.valid_at, not_before, not_after)
)
if self.invalid_at is not None:
not_before, invalid_at, not_after = self._validate_invalid_at()
if not_before <= invalid_at <= not_after:
messages.append(
'Certificate is not invalid for the specified date (%s) - not_before: %s - not_after: %s' %
(self.invalid_at, not_before, not_after)
)
if self.valid_in is not None:
not_before, valid_in, not_after = self._validate_valid_in()
if not not_before <= valid_in <= not_after:
messages.append(
'Certificate is not valid in %s from now (that would be %s) - not_before: %s - not_after: %s' %
(self.valid_in, valid_in, not_before, not_after)
)
return messages
def needs_regeneration(self):
self._ensure_existing_certificate_loaded()
if self.existing_certificate is None:
self.messages = ['Certificate not provided']
else:
self.messages = self.assertonly()
return len(self.messages) != 0
def generate_certificate(self):
self.module.fail_json(msg=' | '.join(self.messages))
def get_certificate_data(self):
return self.existing_certificate_bytes
class AssertOnlyCertificateBackendCryptography(AssertOnlyCertificateBackend):
"""Validate the supplied cert, using the cryptography backend"""
def __init__(self, module):
super(AssertOnlyCertificateBackendCryptography, self).__init__(module, 'cryptography')
def _validate_privatekey(self):
return cryptography_compare_public_keys(self.existing_certificate.public_key(), self.privatekey.public_key())
def _validate_csr_signature(self):
if not self.csr.is_signature_valid:
return False
return cryptography_compare_public_keys(self.csr.public_key(), self.existing_certificate.public_key())
def _validate_csr_subject(self):
return self.csr.subject == self.existing_certificate.subject
def _validate_csr_extensions(self):
cert_exts = self.existing_certificate.extensions
csr_exts = self.csr.extensions
if len(cert_exts) != len(csr_exts):
return False
for cert_ext in cert_exts:
try:
csr_ext = csr_exts.get_extension_for_oid(cert_ext.oid)
if cert_ext != csr_ext:
return False
except cryptography.x509.ExtensionNotFound as dummy:
return False
return True
def _validate_signature_algorithms(self):
if self.existing_certificate.signature_algorithm_oid._name not in self.signature_algorithms:
return self.existing_certificate.signature_algorithm_oid._name
def _validate_subject(self):
expected_subject = Name([NameAttribute(oid=cryptography_name_to_oid(sub[0]), value=to_text(sub[1]))
for sub in self.subject])
cert_subject = self.existing_certificate.subject
if not compare_sets(expected_subject, cert_subject, self.subject_strict):
return expected_subject, cert_subject
def _validate_issuer(self):
expected_issuer = Name([NameAttribute(oid=cryptography_name_to_oid(iss[0]), value=to_text(iss[1]))
for iss in self.issuer])
cert_issuer = self.existing_certificate.issuer
if not compare_sets(expected_issuer, cert_issuer, self.issuer_strict):
return self.issuer, cert_issuer
def _validate_has_expired(self):
cert_not_after = self.existing_certificate.not_valid_after
cert_expired = cert_not_after < datetime.datetime.utcnow()
return cert_expired
def _validate_version(self):
if self.existing_certificate.version == x509.Version.v1:
return 1
if self.existing_certificate.version == x509.Version.v3:
return 3
return "unknown"
def _validate_key_usage(self):
try:
current_key_usage = self.existing_certificate.extensions.get_extension_for_class(x509.KeyUsage).value
test_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 test_key_usage['key_agreement']:
test_key_usage.update(dict(
encipher_only=current_key_usage.encipher_only,
decipher_only=current_key_usage.decipher_only
))
key_usages = cryptography_parse_key_usage_params(self.key_usage)
if not compare_dicts(key_usages, test_key_usage, self.key_usage_strict):
return self.key_usage, [k for k, v in test_key_usage.items() if v is True]
except cryptography.x509.ExtensionNotFound:
# This is only bad if the user specified a non-empty list
if self.key_usage:
return NO_EXTENSION
def _validate_extended_key_usage(self):
try:
current_ext_keyusage = self.existing_certificate.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
usages = [cryptography_name_to_oid(usage) for usage in self.extended_key_usage]
expected_ext_keyusage = x509.ExtendedKeyUsage(usages)
if not compare_sets(expected_ext_keyusage, current_ext_keyusage, self.extended_key_usage_strict):
return [eku.value for eku in expected_ext_keyusage], [eku.value for eku in current_ext_keyusage]
except cryptography.x509.ExtensionNotFound:
# This is only bad if the user specified a non-empty list
if self.extended_key_usage:
return NO_EXTENSION
def _validate_subject_alt_name(self):
try:
current_san = self.existing_certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
expected_san = [cryptography_get_name(san) for san in self.subject_alt_name]
if not compare_sets(expected_san, current_san, self.subject_alt_name_strict):
return self.subject_alt_name, current_san
except cryptography.x509.ExtensionNotFound:
# This is only bad if the user specified a non-empty list
if self.subject_alt_name:
return NO_EXTENSION
def _validate_not_before(self):
return self.existing_certificate.not_valid_before
def _validate_not_after(self):
return self.existing_certificate.not_valid_after
def _validate_valid_at(self):
rt = get_relative_time_option(self.valid_at, 'valid_at', backend=self.backend)
return self.existing_certificate.not_valid_before, rt, self.existing_certificate.not_valid_after
def _validate_invalid_at(self):
rt = get_relative_time_option(self.invalid_at, 'invalid_at', backend=self.backend)
return self.existing_certificate.not_valid_before, rt, self.existing_certificate.not_valid_after
def _validate_valid_in(self):
valid_in_date = get_relative_time_option(self.valid_in, "valid_in", backend=self.backend)
return self.existing_certificate.not_valid_before, valid_in_date, self.existing_certificate.not_valid_after
class AssertOnlyCertificateBackendPyOpenSSL(AssertOnlyCertificateBackend):
"""validate the supplied certificate."""
def __init__(self, module):
super(AssertOnlyCertificateBackendPyOpenSSL, self).__init__(module, 'pyopenssl')
# Ensure inputs are properly sanitized before comparison.
for param in ['signature_algorithms', 'key_usage', 'extended_key_usage',
'subject_alt_name', 'subject', 'issuer', 'not_before',
'not_after', 'valid_at', 'invalid_at']:
attr = getattr(self, param)
if isinstance(attr, list) and attr:
if isinstance(attr[0], str):
setattr(self, param, [to_bytes(item) for item in attr])
elif isinstance(attr[0], tuple):
setattr(self, param, [(to_bytes(item[0]), to_bytes(item[1])) for item in attr])
elif isinstance(attr, tuple):
setattr(self, param, dict((to_bytes(k), to_bytes(v)) for (k, v) in attr.items()))
elif isinstance(attr, dict):
setattr(self, param, dict((to_bytes(k), to_bytes(v)) for (k, v) in attr.items()))
elif isinstance(attr, str):
setattr(self, param, to_bytes(attr))
def _validate_privatekey(self):
ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_2_METHOD)
ctx.use_privatekey(self.privatekey)
ctx.use_certificate(self.existing_certificate)
try:
ctx.check_privatekey()
return True
except OpenSSL.SSL.Error:
return False
def _validate_csr_signature(self):
try:
self.csr.verify(self.existing_certificate.get_pubkey())
except OpenSSL.crypto.Error:
return False
def _validate_csr_subject(self):
if self.csr.get_subject() != self.existing_certificate.get_subject():
return False
def _validate_csr_extensions(self):
csr_extensions = self.csr.get_extensions()
cert_extension_count = self.existing_certificate.get_extension_count()
if len(csr_extensions) != cert_extension_count:
return False
for extension_number in range(0, cert_extension_count):
cert_extension = self.existing_certificate.get_extension(extension_number)
csr_extension = filter(lambda extension: extension.get_short_name() == cert_extension.get_short_name(), csr_extensions)
if cert_extension.get_data() != list(csr_extension)[0].get_data():
return False
return True
def _validate_signature_algorithms(self):
if self.existing_certificate.get_signature_algorithm() not in self.signature_algorithms:
return self.existing_certificate.get_signature_algorithm()
def _validate_subject(self):
expected_subject = [(OpenSSL._util.lib.OBJ_txt2nid(sub[0]), sub[1]) for sub in self.subject]
cert_subject = self.existing_certificate.get_subject().get_components()
current_subject = [(OpenSSL._util.lib.OBJ_txt2nid(sub[0]), sub[1]) for sub in cert_subject]
if not compare_sets(expected_subject, current_subject, self.subject_strict):
return expected_subject, current_subject
def _validate_issuer(self):
expected_issuer = [(OpenSSL._util.lib.OBJ_txt2nid(iss[0]), iss[1]) for iss in self.issuer]
cert_issuer = self.existing_certificate.get_issuer().get_components()
current_issuer = [(OpenSSL._util.lib.OBJ_txt2nid(iss[0]), iss[1]) for iss in cert_issuer]
if not compare_sets(expected_issuer, current_issuer, self.issuer_strict):
return self.issuer, cert_issuer
def _validate_has_expired(self):
# The following 3 lines are the same as the current PyOpenSSL code for cert.has_expired().
# Older version of PyOpenSSL have a buggy implementation,
# to avoid issues with those we added the code from a more recent release here.
time_string = to_native(self.existing_certificate.get_notAfter())
not_after = datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
cert_expired = not_after < datetime.datetime.utcnow()
return cert_expired
def _validate_version(self):
# Version numbers in certs are off by one:
# v1: 0, v2: 1, v3: 2 ...
return self.existing_certificate.get_version() + 1
def _validate_key_usage(self):
found = False
for extension_idx in range(0, self.existing_certificate.get_extension_count()):
extension = self.existing_certificate.get_extension(extension_idx)
if extension.get_short_name() == b'keyUsage':
found = True
expected_extension = crypto.X509Extension(b"keyUsage", False, b', '.join(self.key_usage))
key_usage = [usage.strip() for usage in to_text(expected_extension, errors='surrogate_or_strict').split(',')]
current_ku = [usage.strip() for usage in to_text(extension, errors='surrogate_or_strict').split(',')]
if not compare_sets(key_usage, current_ku, self.key_usage_strict):
return self.key_usage, str(extension).split(', ')
if not found:
# This is only bad if the user specified a non-empty list
if self.key_usage:
return NO_EXTENSION
def _validate_extended_key_usage(self):
found = False
for extension_idx in range(0, self.existing_certificate.get_extension_count()):
extension = self.existing_certificate.get_extension(extension_idx)
if extension.get_short_name() == b'extendedKeyUsage':
found = True
extKeyUsage = [OpenSSL._util.lib.OBJ_txt2nid(keyUsage) for keyUsage in self.extended_key_usage]
current_xku = [OpenSSL._util.lib.OBJ_txt2nid(usage.strip()) for usage in
to_bytes(extension, errors='surrogate_or_strict').split(b',')]
if not compare_sets(extKeyUsage, current_xku, self.extended_key_usage_strict):
return self.extended_key_usage, str(extension).split(', ')
if not found:
# This is only bad if the user specified a non-empty list
if self.extended_key_usage:
return NO_EXTENSION
def _validate_subject_alt_name(self):
found = False
for extension_idx in range(0, self.existing_certificate.get_extension_count()):
extension = self.existing_certificate.get_extension(extension_idx)
if extension.get_short_name() == b'subjectAltName':
found = True
l_altnames = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
to_text(extension, errors='surrogate_or_strict').split(', ')]
sans = [pyopenssl_normalize_name_attribute(to_text(san, errors='surrogate_or_strict')) for san in self.subject_alt_name]
if not compare_sets(sans, l_altnames, self.subject_alt_name_strict):
return self.subject_alt_name, l_altnames
if not found:
# This is only bad if the user specified a non-empty list
if self.subject_alt_name:
return NO_EXTENSION
def _validate_not_before(self):
return self.existing_certificate.get_notBefore()
def _validate_not_after(self):
return self.existing_certificate.get_notAfter()
def _validate_valid_at(self):
rt = get_relative_time_option(self.valid_at, "valid_at", backend=self.backend)
rt = to_bytes(rt, errors='surrogate_or_strict')
return self.existing_certificate.get_notBefore(), rt, self.existing_certificate.get_notAfter()
def _validate_invalid_at(self):
rt = get_relative_time_option(self.invalid_at, "invalid_at", backend=self.backend)
rt = to_bytes(rt, errors='surrogate_or_strict')
return self.existing_certificate.get_notBefore(), rt, self.existing_certificate.get_notAfter()
def _validate_valid_in(self):
valid_in_asn1 = get_relative_time_option(self.valid_in, "valid_in", backend=self.backend)
valid_in_date = to_bytes(valid_in_asn1, errors='surrogate_or_strict')
return self.existing_certificate.get_notBefore(), valid_in_date, self.existing_certificate.get_notAfter()
class AssertOnlyCertificateProvider(CertificateProvider):
def validate_module_args(self, module):
module.deprecate("The 'assertonly' provider is deprecated; please see the examples of "
"the 'x509_certificate' module on how to replace it with other modules",
version='2.0.0', collection_name='community.crypto')
def needs_version_two_certs(self, module):
return False
def create_backend(self, module, backend):
if backend == 'cryptography':
return AssertOnlyCertificateBackendCryptography(module)
if backend == 'pyopenssl':
return AssertOnlyCertificateBackendPyOpenSSL(module)
def add_assertonly_provider_to_argument_spec(argument_spec):
argument_spec.argument_spec['provider']['choices'].append('assertonly')
argument_spec.argument_spec.update(dict(
signature_algorithms=dict(type='list', elements='str', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
subject=dict(type='dict', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
subject_strict=dict(type='bool', default=False, removed_in_version='2.0.0', removed_from_collection='community.crypto'),
issuer=dict(type='dict', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
issuer_strict=dict(type='bool', default=False, removed_in_version='2.0.0', removed_from_collection='community.crypto'),
has_expired=dict(type='bool', default=False, removed_in_version='2.0.0', removed_from_collection='community.crypto'),
version=dict(type='int', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
key_usage=dict(type='list', elements='str', aliases=['keyUsage'],
removed_in_version='2.0.0', removed_from_collection='community.crypto'),
key_usage_strict=dict(type='bool', default=False, aliases=['keyUsage_strict'],
removed_in_version='2.0.0', removed_from_collection='community.crypto'),
extended_key_usage=dict(type='list', elements='str', aliases=['extendedKeyUsage'],
removed_in_version='2.0.0', removed_from_collection='community.crypto'),
extended_key_usage_strict=dict(type='bool', default=False, aliases=['extendedKeyUsage_strict'],
removed_in_version='2.0.0', removed_from_collection='community.crypto'),
subject_alt_name=dict(type='list', elements='str', aliases=['subjectAltName'],
removed_in_version='2.0.0', removed_from_collection='community.crypto'),
subject_alt_name_strict=dict(type='bool', default=False, aliases=['subjectAltName_strict'],
removed_in_version='2.0.0', removed_from_collection='community.crypto'),
not_before=dict(type='str', aliases=['notBefore'], removed_in_version='2.0.0', removed_from_collection='community.crypto'),
not_after=dict(type='str', aliases=['notAfter'], removed_in_version='2.0.0', removed_from_collection='community.crypto'),
valid_at=dict(type='str', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
invalid_at=dict(type='str', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
valid_in=dict(type='str', removed_in_version='2.0.0', removed_from_collection='community.crypto'),
))

View File

@@ -12,7 +12,7 @@ import datetime
import time
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
@@ -58,18 +58,7 @@ class EntrustCertificateBackend(CertificateBackend):
# We want to always force behavior of trying to use the organization provided in the CSR.
# To that end we need to parse out the organization from the CSR.
self.csr_org = None
if self.backend == 'pyopenssl':
csr_subject = self.csr.get_subject()
csr_subject_components = csr_subject.get_components()
for k, v in csr_subject_components:
if k.upper() == 'O':
# Entrust does not support multiple validated organizations in a single certificate
if self.csr_org is not None:
self.module.fail_json(msg=("Entrust provider does not currently support multiple validated organizations. Multiple organizations "
"found in Subject DN: '{0}'. ".format(csr_subject)))
else:
self.csr_org = v
elif self.backend == 'cryptography':
if self.backend == 'cryptography':
csr_subject_orgs = self.csr.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)
if len(csr_subject_orgs) == 1:
self.csr_org = csr_subject_orgs[0].value
@@ -162,11 +151,7 @@ class EntrustCertificateBackend(CertificateBackend):
if self.existing_certificate:
serial_number = None
expiry = None
if self.backend == 'pyopenssl':
serial_number = "{0:X}".format(self.existing_certificate.get_serial_number())
time_string = to_native(self.existing_certificate.get_notAfter())
expiry = datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
elif self.backend == 'cryptography':
if self.backend == 'cryptography':
serial_number = "{0:X}".format(cryptography_serial_number_of_cert(self.existing_certificate))
expiry = self.existing_certificate.not_valid_after

View File

@@ -0,0 +1,394 @@
# -*- 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.module_backends.publickey_info import (
get_publickey_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
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)
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
def get_certificate_info(module, backend, content, prefer_one_fingerprint=False):
if backend == 'cryptography':
info = CertificateInfoRetrievalCryptography(module, content)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
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)
# Try cryptography
if can_use_cryptography:
backend = 'cryptography'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
return backend, CertificateInfoRetrievalCryptography(module, content)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))

View File

@@ -13,7 +13,7 @@ import os
from distutils.version import LooseVersion
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 (
OpenSSLBadPassphraseError,
@@ -169,7 +169,7 @@ class OwnCACertificateBackendCryptography(CertificateBackend):
return self.cert.public_bytes(Encoding.PEM)
def needs_regeneration(self):
if super(OwnCACertificateBackendCryptography, self).needs_regeneration():
if super(OwnCACertificateBackendCryptography, self).needs_regeneration(not_before=self.notBefore, not_after=self.notAfter):
return True
# Check AuthorityKeyIdentifier
@@ -227,100 +227,6 @@ def generate_serial_number():
return result
class OwnCACertificateBackendPyOpenSSL(CertificateBackend):
def __init__(self, module):
super(OwnCACertificateBackendPyOpenSSL, self).__init__(module, 'pyopenssl')
self.notBefore = get_relative_time_option(self.module.params['ownca_not_before'], 'ownca_not_before', backend=self.backend)
self.notAfter = get_relative_time_option(self.module.params['ownca_not_after'], 'ownca_not_after', backend=self.backend)
self.digest = self.module.params['ownca_digest']
self.version = self.module.params['ownca_version']
self.serial_number = generate_serial_number()
if self.module.params['ownca_create_subject_key_identifier'] != 'create_if_not_provided':
self.module.fail_json(msg='ownca_create_subject_key_identifier cannot be used with the pyOpenSSL backend!')
if self.module.params['ownca_create_authority_key_identifier']:
self.module.warn('ownca_create_authority_key_identifier is ignored by the pyOpenSSL backend!')
self.ca_cert_path = self.module.params['ownca_path']
self.ca_cert_content = self.module.params['ownca_content']
if self.ca_cert_content is not None:
self.ca_cert_content = self.ca_cert_content.encode('utf-8')
self.ca_privatekey_path = self.module.params['ownca_privatekey_path']
self.ca_privatekey_content = self.module.params['ownca_privatekey_content']
if self.ca_privatekey_content is not None:
self.ca_privatekey_content = self.ca_privatekey_content.encode('utf-8')
self.ca_privatekey_passphrase = self.module.params['ownca_privatekey_passphrase']
if self.csr_content is None and not os.path.exists(self.csr_path):
raise CertificateError(
'The certificate signing request file {0} does not exist'.format(self.csr_path)
)
if self.ca_cert_content is None and not os.path.exists(self.ca_cert_path):
raise CertificateError(
'The CA certificate file {0} does not exist'.format(self.ca_cert_path)
)
if self.ca_privatekey_content is None and not os.path.exists(self.ca_privatekey_path):
raise CertificateError(
'The CA private key file {0} does not exist'.format(self.ca_privatekey_path)
)
self._ensure_csr_loaded()
self.ca_cert = load_certificate(
path=self.ca_cert_path,
content=self.ca_cert_content,
)
try:
self.ca_privatekey = load_privatekey(
path=self.ca_privatekey_path,
content=self.ca_privatekey_content,
passphrase=self.ca_privatekey_passphrase
)
except OpenSSLBadPassphraseError as exc:
self.module.fail_json(msg=str(exc))
def generate_certificate(self):
"""(Re-)Generate certificate."""
cert = crypto.X509()
cert.set_serial_number(self.serial_number)
cert.set_notBefore(to_bytes(self.notBefore))
cert.set_notAfter(to_bytes(self.notAfter))
cert.set_subject(self.csr.get_subject())
cert.set_issuer(self.ca_cert.get_subject())
cert.set_version(self.version - 1)
cert.set_pubkey(self.csr.get_pubkey())
cert.add_extensions(self.csr.get_extensions())
cert.sign(self.ca_privatekey, self.digest)
self.cert = cert
def get_certificate_data(self):
"""Return bytes for self.cert."""
return crypto.dump_certificate(crypto.FILETYPE_PEM, self.cert)
def dump(self, include_certificate):
result = super(OwnCACertificateBackendPyOpenSSL, self).dump(include_certificate)
result.update({
'ca_cert': self.ca_cert_path,
'ca_privatekey': self.ca_privatekey_path,
})
if self.module.check_mode:
result.update({
'notBefore': self.notBefore,
'notAfter': self.notAfter,
'serial_number': self.serial_number,
})
else:
if self.cert is None:
self.cert = self.existing_certificate
result.update({
'notBefore': self.cert.get_notBefore(),
'notAfter': self.cert.get_notAfter(),
'serial_number': self.cert.get_serial_number(),
})
return result
class OwnCACertificateProvider(CertificateProvider):
def validate_module_args(self, module):
if module.params['ownca_path'] is None and module.params['ownca_content'] is None:
@@ -334,8 +240,6 @@ class OwnCACertificateProvider(CertificateProvider):
def create_backend(self, module, backend):
if backend == 'cryptography':
return OwnCACertificateBackendCryptography(module)
if backend == 'pyopenssl':
return OwnCACertificateBackendPyOpenSSL(module)
def add_ownca_provider_to_argument_spec(argument_spec):

View File

@@ -12,7 +12,7 @@ import os
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 (
get_relative_time_option,
@@ -134,6 +134,10 @@ class SelfSignedCertificateBackendCryptography(CertificateBackend):
"""Return bytes for self.cert."""
return self.cert.public_bytes(Encoding.PEM)
def needs_regeneration(self):
return super(SelfSignedCertificateBackendCryptography, self).needs_regeneration(
not_before=self.notBefore, not_after=self.notAfter)
def dump(self, include_certificate):
result = super(SelfSignedCertificateBackendCryptography, self).dump(include_certificate)
@@ -163,76 +167,6 @@ def generate_serial_number():
return result
class SelfSignedCertificateBackendPyOpenSSL(CertificateBackend):
def __init__(self, module):
super(SelfSignedCertificateBackendPyOpenSSL, self).__init__(module, 'pyopenssl')
if module.params['selfsigned_create_subject_key_identifier'] != 'create_if_not_provided':
module.fail_json(msg='selfsigned_create_subject_key_identifier cannot be used with the pyOpenSSL backend!')
self.notBefore = get_relative_time_option(module.params['selfsigned_not_before'], 'selfsigned_not_before', backend=self.backend)
self.notAfter = get_relative_time_option(module.params['selfsigned_not_after'], 'selfsigned_not_after', backend=self.backend)
self.digest = module.params['selfsigned_digest']
self.version = module.params['selfsigned_version']
self.serial_number = generate_serial_number()
if self.csr_path is not None and not os.path.exists(self.csr_path):
raise CertificateError(
'The certificate signing request file {0} does not exist'.format(self.csr_path)
)
if self.privatekey_content is None and not os.path.exists(self.privatekey_path):
raise CertificateError(
'The private key file {0} does not exist'.format(self.privatekey_path)
)
self._ensure_private_key_loaded()
self._ensure_csr_loaded()
if self.csr is None:
# Create empty CSR on the fly
self.csr = crypto.X509Req()
self.csr.set_pubkey(self.privatekey)
self.csr.sign(self.privatekey, self.digest)
def generate_certificate(self):
"""(Re-)Generate certificate."""
cert = crypto.X509()
cert.set_serial_number(self.serial_number)
cert.set_notBefore(to_bytes(self.notBefore))
cert.set_notAfter(to_bytes(self.notAfter))
cert.set_subject(self.csr.get_subject())
cert.set_issuer(self.csr.get_subject())
cert.set_version(self.version - 1)
cert.set_pubkey(self.csr.get_pubkey())
cert.add_extensions(self.csr.get_extensions())
cert.sign(self.privatekey, self.digest)
self.cert = cert
def get_certificate_data(self):
"""Return bytes for self.cert."""
return crypto.dump_certificate(crypto.FILETYPE_PEM, self.cert)
def dump(self, include_certificate):
result = super(SelfSignedCertificateBackendPyOpenSSL, self).dump(include_certificate)
if self.module.check_mode:
result.update({
'notBefore': self.notBefore,
'notAfter': self.notAfter,
'serial_number': self.serial_number,
})
else:
if self.cert is None:
self.cert = self.existing_certificate
result.update({
'notBefore': self.cert.get_notBefore(),
'notAfter': self.cert.get_notAfter(),
'serial_number': self.cert.get_serial_number(),
})
return result
class SelfSignedCertificateProvider(CertificateProvider):
def validate_module_args(self, module):
if module.params['privatekey_path'] is None and module.params['privatekey_content'] is None:
@@ -244,8 +178,6 @@ class SelfSignedCertificateProvider(CertificateProvider):
def create_backend(self, module, backend):
if backend == 'cryptography':
return SelfSignedCertificateBackendCryptography(module)
if backend == 'pyopenssl':
return SelfSignedCertificateBackendPyOpenSSL(module)
def add_selfsigned_provider_to_argument_spec(argument_spec):

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.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_native, to_text
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
OpenSSLObjectError,
@@ -27,6 +27,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
load_privatekey,
load_certificate_request,
parse_name_field,
parse_ordered_name_field,
select_message_digest,
)
@@ -36,38 +37,22 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp
cryptography_name_to_oid,
cryptography_key_needs_digest_for_signing,
cryptography_parse_key_usage_params,
cryptography_parse_relative_distinguished_name,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import (
pyopenssl_normalize_name_attribute,
pyopenssl_parse_name_constraints,
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_crl import (
REVOCATION_REASON_MAP,
)
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
MINIMAL_PYOPENSSL_VERSION = '0.15'
MINIMAL_CRYPTOGRAPHY_VERSION = '1.3'
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
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"
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
@@ -128,12 +113,14 @@ class CertificateSigningRequestBackend(object):
self.authority_key_identifier = module.params['authority_key_identifier']
self.authority_cert_issuer = module.params['authority_cert_issuer']
self.authority_cert_serial_number = module.params['authority_cert_serial_number']
self.crl_distribution_points = module.params['crl_distribution_points']
self.csr = None
self.privatekey = None
if self.create_subject_key_identifier and self.subject_key_identifier is not None:
module.fail_json(msg='subject_key_identifier cannot be specified if create_subject_key_identifier is true')
self.ordered_subject = False
self.subject = [
('C', module.params['country_name']),
('ST', module.params['state_or_province_name']),
@@ -143,11 +130,19 @@ class CertificateSigningRequestBackend(object):
('CN', module.params['common_name']),
('emailAddress', module.params['email_address']),
]
if module.params['subject']:
self.subject = self.subject + parse_name_field(module.params['subject'])
self.subject = [(entry[0], entry[1]) for entry in self.subject if entry[1]]
try:
if module.params['subject']:
self.subject = self.subject + parse_name_field(module.params['subject'], 'subject')
if module.params['subject_ordered']:
if self.subject:
raise CertificateSigningRequestError('subject_ordered cannot be combined with any other subject field')
self.subject = parse_ordered_name_field(module.params['subject_ordered'], 'subject_ordered')
self.ordered_subject = True
except ValueError as exc:
raise CertificateSigningRequestError(to_native(exc))
self.using_common_name_for_san = False
if not self.subjectAltName and module.params['use_common_name_for_san']:
for sub in self.subject:
@@ -171,6 +166,20 @@ class CertificateSigningRequestBackend(object):
self.existing_csr = 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
def generate_csr(self):
"""(Re-)Generate CSR."""
@@ -184,6 +193,7 @@ class CertificateSigningRequestBackend(object):
def set_existing(self, csr_bytes):
"""Set existing CSR bytes. None indicates that the CSR does not exist."""
self.existing_csr_bytes = csr_bytes
self.diff_after = self.diff_before = self._get_info(self.existing_csr_bytes)
def has_existing(self):
"""Query whether an existing CSR is/has been there."""
@@ -232,181 +242,53 @@ class CertificateSigningRequestBackend(object):
'name_constraints_permitted': self.name_constraints_permitted,
'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:
# 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
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
# Implementation with using pyOpenSSL
class CertificateSigningRequestPyOpenSSLBackend(CertificateSigningRequestBackend):
def __init__(self, module):
if module.params['create_subject_key_identifier']:
module.fail_json(msg='You cannot use create_subject_key_identifier with the pyOpenSSL backend!')
for o in ('subject_key_identifier', 'authority_key_identifier', 'authority_cert_issuer', 'authority_cert_serial_number'):
if module.params[o] is not None:
module.fail_json(msg='You cannot use {0} with the pyOpenSSL backend!'.format(o))
super(CertificateSigningRequestPyOpenSSLBackend, self).__init__(module, 'pyopenssl')
def generate_csr(self):
"""(Re-)Generate CSR."""
self._ensure_private_key_loaded()
req = crypto.X509Req()
req.set_version(self.version - 1)
subject = req.get_subject()
for entry in self.subject:
if entry[1] is not None:
# Workaround for https://github.com/pyca/pyopenssl/issues/165
nid = OpenSSL._util.lib.OBJ_txt2nid(to_bytes(entry[0]))
if nid == 0:
raise CertificateSigningRequestError('Unknown subject field identifier "{0}"'.format(entry[0]))
res = OpenSSL._util.lib.X509_NAME_add_entry_by_NID(subject._name, nid, OpenSSL._util.lib.MBSTRING_UTF8, to_bytes(entry[1]), -1, -1, 0)
if res == 0:
raise CertificateSigningRequestError('Invalid value for subject field identifier "{0}": {1}'.format(entry[0], entry[1]))
extensions = []
if self.subjectAltName:
altnames = ', '.join(self.subjectAltName)
try:
extensions.append(crypto.X509Extension(b"subjectAltName", self.subjectAltName_critical, altnames.encode('ascii')))
except OpenSSL.crypto.Error as e:
raise CertificateSigningRequestError(
'Error while parsing Subject Alternative Names {0} (check for missing type prefix, such as "DNS:"!): {1}'.format(
', '.join(["{0}".format(san) for san in self.subjectAltName]), str(e)
)
)
if self.keyUsage:
usages = ', '.join(self.keyUsage)
extensions.append(crypto.X509Extension(b"keyUsage", self.keyUsage_critical, usages.encode('ascii')))
if self.extendedKeyUsage:
usages = ', '.join(self.extendedKeyUsage)
extensions.append(crypto.X509Extension(b"extendedKeyUsage", self.extendedKeyUsage_critical, usages.encode('ascii')))
if self.basicConstraints:
usages = ', '.join(self.basicConstraints)
extensions.append(crypto.X509Extension(b"basicConstraints", self.basicConstraints_critical, usages.encode('ascii')))
if self.name_constraints_permitted or self.name_constraints_excluded:
usages = ', '.join(
['permitted;{0}'.format(name) for name in self.name_constraints_permitted] +
['excluded;{0}'.format(name) for name in self.name_constraints_excluded]
def parse_crl_distribution_points(module, crl_distribution_points):
result = []
for index, parse_crl_distribution_point in enumerate(crl_distribution_points):
try:
params = dict(
full_name=None,
relative_name=None,
crl_issuer=None,
reasons=None,
)
extensions.append(crypto.X509Extension(b"nameConstraints", self.name_constraints_critical, usages.encode('ascii')))
if self.ocspMustStaple:
extensions.append(crypto.X509Extension(OPENSSL_MUST_STAPLE_NAME, self.ocspMustStaple_critical, OPENSSL_MUST_STAPLE_VALUE))
if extensions:
req.add_extensions(extensions)
req.set_pubkey(self.privatekey)
req.sign(self.privatekey, self.digest)
self.csr = req
def get_csr_data(self):
"""Return bytes for self.csr."""
return crypto.dump_certificate_request(crypto.FILETYPE_PEM, self.csr)
def _check_csr(self):
def _check_subject(csr):
subject = [(OpenSSL._util.lib.OBJ_txt2nid(to_bytes(sub[0])), to_bytes(sub[1])) for sub in self.subject]
current_subject = [(OpenSSL._util.lib.OBJ_txt2nid(to_bytes(sub[0])), to_bytes(sub[1])) for sub in csr.get_subject().get_components()]
if not set(subject) == set(current_subject):
return False
return True
def _check_subjectAltName(extensions):
altnames_ext = next((ext for ext in extensions if ext.get_short_name() == b'subjectAltName'), '')
altnames = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
to_text(altnames_ext, errors='surrogate_or_strict').split(',') if altname.strip()]
if self.subjectAltName:
if (set(altnames) != set([pyopenssl_normalize_name_attribute(to_text(name)) for name in self.subjectAltName]) or
altnames_ext.get_critical() != self.subjectAltName_critical):
return False
else:
if altnames:
return False
return True
def _check_keyUsage_(extensions, extName, expected, critical):
usages_ext = [ext for ext in extensions if ext.get_short_name() == extName]
if (not usages_ext and expected) or (usages_ext and not expected):
return False
elif not usages_ext and not expected:
return True
else:
current = [OpenSSL._util.lib.OBJ_txt2nid(to_bytes(usage.strip())) for usage in str(usages_ext[0]).split(',')]
expected = [OpenSSL._util.lib.OBJ_txt2nid(to_bytes(usage)) for usage in expected]
return set(current) == set(expected) and usages_ext[0].get_critical() == critical
def _check_keyUsage(extensions):
usages_ext = [ext for ext in extensions if ext.get_short_name() == b'keyUsage']
if (not usages_ext and self.keyUsage) or (usages_ext and not self.keyUsage):
return False
elif not usages_ext and not self.keyUsage:
return True
else:
# OpenSSL._util.lib.OBJ_txt2nid() always returns 0 for all keyUsage values
# (since keyUsage has a fixed bitfield for these values and is not extensible).
# Therefore, we create an extension for the wanted values, and compare the
# data of the extensions (which is the serialized bitfield).
expected_ext = crypto.X509Extension(b"keyUsage", False, ', '.join(self.keyUsage).encode('ascii'))
return usages_ext[0].get_data() == expected_ext.get_data() and usages_ext[0].get_critical() == self.keyUsage_critical
def _check_extenededKeyUsage(extensions):
return _check_keyUsage_(extensions, b'extendedKeyUsage', self.extendedKeyUsage, self.extendedKeyUsage_critical)
def _check_basicConstraints(extensions):
return _check_keyUsage_(extensions, b'basicConstraints', self.basicConstraints, self.basicConstraints_critical)
def _check_nameConstraints(extensions):
nc_ext = next((ext for ext in extensions if ext.get_short_name() == b'nameConstraints'), '')
permitted, excluded = pyopenssl_parse_name_constraints(nc_ext)
if self.name_constraints_permitted or self.name_constraints_excluded:
if set(permitted) != set([pyopenssl_normalize_name_attribute(to_text(name)) for name in self.name_constraints_permitted]):
return False
if set(excluded) != set([pyopenssl_normalize_name_attribute(to_text(name)) for name in self.name_constraints_excluded]):
return False
if nc_ext.get_critical() != self.name_constraints_critical:
return False
else:
if permitted or excluded:
return False
return True
def _check_ocspMustStaple(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 self.ocspMustStaple:
return len(oms_ext) > 0 and oms_ext[0].get_critical() == self.ocspMustStaple_critical
else:
return len(oms_ext) == 0
def _check_extensions(csr):
extensions = csr.get_extensions()
return (_check_subjectAltName(extensions) and _check_keyUsage(extensions) and
_check_extenededKeyUsage(extensions) and _check_basicConstraints(extensions) and
_check_ocspMustStaple(extensions) and _check_nameConstraints(extensions))
def _check_signature(csr):
try:
return csr.verify(self.privatekey)
except crypto.Error:
return False
return _check_subject(self.existing_csr) and _check_extensions(self.existing_csr) and _check_signature(self.existing_csr)
if parse_crl_distribution_point['full_name'] is not None:
params['full_name'] = [cryptography_get_name(name, 'full name') for name in parse_crl_distribution_point['full_name']]
if parse_crl_distribution_point['relative_name'] is not None:
try:
params['relative_name'] = cryptography_parse_relative_distinguished_name(parse_crl_distribution_point['relative_name'])
except Exception:
# If cryptography's version is < 1.6, the error is probably caused by that
if CRYPTOGRAPHY_VERSION < LooseVersion('1.6'):
raise OpenSSLObjectError('Cannot specify relative_name for cryptography < 1.6')
raise
if parse_crl_distribution_point['crl_issuer'] is not None:
params['crl_issuer'] = [cryptography_get_name(name, 'CRL issuer') for name in parse_crl_distribution_point['crl_issuer']]
if parse_crl_distribution_point['reasons'] is not None:
reasons = []
for reason in parse_crl_distribution_point['reasons']:
reasons.append(REVOCATION_REASON_MAP[reason])
params['reasons'] = frozenset(reasons)
result.append(cryptography.x509.DistributionPoint(**params))
except OpenSSLObjectError as e:
raise OpenSSLObjectError('Error while parsing CRL distribution point #{index}: {error}'.format(index=index, error=e))
return result
# Implementation with using cryptography
@@ -417,6 +299,9 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
if self.version != 1:
module.warn('The cryptography backend only supports version 1. (The only valid value according to RFC 2986.)')
if self.crl_distribution_points:
self.crl_distribution_points = parse_crl_distribution_points(module, self.crl_distribution_points)
def generate_csr(self):
"""(Re-)Generate CSR."""
self._ensure_private_key_loaded()
@@ -460,8 +345,8 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
if self.name_constraints_permitted or self.name_constraints_excluded:
try:
csr = csr.add_extension(cryptography.x509.NameConstraints(
[cryptography_get_name(name) for name in self.name_constraints_permitted],
[cryptography_get_name(name) for name in self.name_constraints_excluded],
[cryptography_get_name(name, 'name constraints permitted') for name in self.name_constraints_permitted],
[cryptography_get_name(name, 'name constraints excluded') for name in self.name_constraints_excluded],
), critical=self.name_constraints_critical)
except TypeError as e:
raise OpenSSLObjectError('Error while parsing name constraint: {0}'.format(e))
@@ -477,12 +362,18 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
if self.authority_key_identifier is not None or self.authority_cert_issuer is not None or self.authority_cert_serial_number is not None:
issuers = None
if self.authority_cert_issuer is not None:
issuers = [cryptography_get_name(n) for n in self.authority_cert_issuer]
issuers = [cryptography_get_name(n, 'authority cert issuer') for n in self.authority_cert_issuer]
csr = csr.add_extension(
cryptography.x509.AuthorityKeyIdentifier(self.authority_key_identifier, issuers, self.authority_cert_serial_number),
critical=False
)
if self.crl_distribution_points:
csr = csr.add_extension(
cryptography.x509.CRLDistributionPoints(self.crl_distribution_points),
critical=False
)
digest = None
if cryptography_key_needs_digest_for_signing(self.privatekey):
digest = select_message_digest(self.digest)
@@ -518,9 +409,12 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def _check_csr(self):
"""Check whether provided parameters, assuming self.existing_csr and self.privatekey have been populated."""
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]
return set(subject) == set(current_subject)
if self.ordered_subject:
return subject == current_subject
else:
return set(subject) == set(current_subject)
def _find_extension(extensions, exttype):
return next(
@@ -530,8 +424,8 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def _check_subjectAltName(extensions):
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 []
altnames = [str(cryptography_get_name(altname)) for altname in self.subjectAltName] if self.subjectAltName else []
current_altnames = [to_text(altname) for altname in current_altnames_ext.value] if current_altnames_ext else []
altnames = [to_text(cryptography_get_name(altname)) for altname in self.subjectAltName] if self.subjectAltName else []
if set(altnames) != set(current_altnames):
return False
if altnames:
@@ -577,9 +471,9 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
return False
# Check criticality
if self.basicConstraints:
if bc_ext.critical != self.basicConstraints_critical:
return False
return True
return bc_ext is not None and bc_ext.critical == self.basicConstraints_critical
else:
return bc_ext is None
def _check_ocspMustStaple(extensions):
try:
@@ -604,10 +498,10 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def _check_nameConstraints(extensions):
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_excl = [str(altname) for altname in current_nc_ext.value.excluded_subtrees] if current_nc_ext else []
nc_perm = [str(cryptography_get_name(altname)) for altname in self.name_constraints_permitted]
nc_excl = [str(cryptography_get_name(altname)) for altname in self.name_constraints_excluded]
current_nc_perm = [to_text(altname) for altname in current_nc_ext.value.permitted_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 = [to_text(cryptography_get_name(altname, 'name constraints permitted')) for altname in self.name_constraints_permitted]
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):
return False
if nc_perm or nc_excl:
@@ -636,21 +530,30 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
aci = None
csr_aci = None
if self.authority_cert_issuer is not None:
aci = [str(cryptography_get_name(n)) 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:
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
and csr_aci == aci
and ext.value.authority_cert_serial_number == self.authority_cert_serial_number)
else:
return ext is None
def _check_crl_distribution_points(extensions):
ext = _find_extension(extensions, cryptography.x509.CRLDistributionPoints)
if self.crl_distribution_points is None:
return ext is None
if not ext:
return False
return list(ext.value) == self.crl_distribution_points
def _check_extensions(csr):
extensions = csr.extensions
return (_check_subjectAltName(extensions) and _check_keyUsage(extensions) and
_check_extenededKeyUsage(extensions) and _check_basicConstraints(extensions) and
_check_ocspMustStaple(extensions) and _check_subject_key_identifier(extensions) and
_check_authority_key_identifier(extensions) and _check_nameConstraints(extensions))
_check_authority_key_identifier(extensions) and _check_nameConstraints(extensions) and
_check_crl_distribution_points(extensions))
def _check_signature(csr):
if not csr.is_signature_valid:
@@ -671,42 +574,20 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
def select_backend(module, backend):
if module.params['version'] != 1:
module.deprecate('The version option will only support allowed values from community.crypto 2.0.0 on. '
'Currently, only the value 1 is allowed by RFC 2986',
version='2.0.0', collection_name='community.crypto')
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
# Try cryptography
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))
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_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, CertificateSigningRequestPyOpenSSLBackend(module)
elif backend == 'cryptography':
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
@@ -722,8 +603,9 @@ def get_csr_argument_spec():
privatekey_path=dict(type='path'),
privatekey_content=dict(type='str', no_log=True),
privatekey_passphrase=dict(type='str', no_log=True),
version=dict(type='int', default=1),
version=dict(type='int', default=1, choices=[1]),
subject=dict(type='dict'),
subject_ordered=dict(type='list', elements='dict'),
country_name=dict(type='str', aliases=['C', 'countryName']),
state_or_province_name=dict(type='str', aliases=['ST', 'stateOrProvinceName']),
locality_name=dict(type='str', aliases=['L', 'localityName']),
@@ -750,13 +632,34 @@ def get_csr_argument_spec():
authority_key_identifier=dict(type='str'),
authority_cert_issuer=dict(type='list', elements='str'),
authority_cert_serial_number=dict(type='int'),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
crl_distribution_points=dict(
type='list',
elements='dict',
options=dict(
full_name=dict(type='list', elements='str'),
relative_name=dict(type='list', elements='str'),
crl_issuer=dict(type='list', elements='str'),
reasons=dict(type='list', elements='str', choices=[
'key_compromise',
'ca_compromise',
'affiliation_changed',
'superseded',
'cessation_of_operation',
'certificate_hold',
'privilege_withdrawn',
'aa_compromise',
]),
),
mutually_exclusive=[('full_name', 'relative_name')]
),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']),
),
required_together=[
['authority_cert_issuer', 'authority_cert_serial_number'],
],
mutually_exclusive=[
['privatekey_path', 'privatekey_content'],
['subject', 'subject_ordered'],
],
required_one_of=[
['privatekey_path', 'privatekey_content'],

View File

@@ -0,0 +1,333 @@
# -*- 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.module_backends.publickey_info import (
get_publickey_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.3'
CRYPTOGRAPHY_IMP_ERR = None
try:
import cryptography
from cryptography import x509
from cryptography.hazmat.primitives import serialization
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
@six.add_metaclass(abc.ABCMeta)
class CSRInfoRetrieval(object):
def __init__(self, module, backend, content, validate_signature):
# content must be a bytes string
self.module = module
self.backend = backend
self.content = content
self.validate_signature = validate_signature
@abc.abstractmethod
def _get_subject_ordered(self):
pass
@abc.abstractmethod
def _get_key_usage(self):
pass
@abc.abstractmethod
def _get_extended_key_usage(self):
pass
@abc.abstractmethod
def _get_basic_constraints(self):
pass
@abc.abstractmethod
def _get_ocsp_must_staple(self):
pass
@abc.abstractmethod
def _get_subject_alt_name(self):
pass
@abc.abstractmethod
def _get_name_constraints(self):
pass
@abc.abstractmethod
def _get_public_key_pem(self):
pass
@abc.abstractmethod
def _get_public_key_object(self):
pass
@abc.abstractmethod
def _get_subject_key_identifier(self):
pass
@abc.abstractmethod
def _get_authority_key_identifier(self):
pass
@abc.abstractmethod
def _get_all_extensions(self):
pass
@abc.abstractmethod
def _is_signature_valid(self):
pass
def get_info(self, prefer_one_fingerprint=False):
result = dict()
self.csr = load_certificate_request(None, content=self.content, backend=self.backend)
subject = self._get_subject_ordered()
result['subject'] = dict()
for k, v in subject:
result['subject'][k] = v
result['subject_ordered'] = subject
result['key_usage'], result['key_usage_critical'] = self._get_key_usage()
result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage()
result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints()
result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple()
result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name()
(
result['name_constraints_permitted'],
result['name_constraints_excluded'],
result['name_constraints_critical'],
) = self._get_name_constraints()
result['public_key'] = self._get_public_key_pem()
public_key_info = get_publickey_info(
self.module,
self.backend,
key=self._get_public_key_object(),
prefer_one_fingerprint=prefer_one_fingerprint)
result.update({
'public_key_type': public_key_info['type'],
'public_key_data': public_key_info['public_data'],
'public_key_fingerprints': public_key_info['fingerprints'],
})
ski = self._get_subject_key_identifier()
if ski is not None:
ski = to_native(binascii.hexlify(ski))
ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)])
result['subject_key_identifier'] = ski
aki, aci, acsn = self._get_authority_key_identifier()
if aki is not None:
aki = to_native(binascii.hexlify(aki))
aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)])
result['authority_key_identifier'] = aki
result['authority_cert_issuer'] = aci
result['authority_cert_serial_number'] = acsn
result['extensions_by_oid'] = self._get_all_extensions()
result['signature_valid'] = self._is_signature_valid()
if self.validate_signature and not result['signature_valid']:
self.module.fail_json(
msg='CSR signature is invalid!',
**result
)
return result
class CSRInfoRetrievalCryptography(CSRInfoRetrieval):
"""Validate the supplied CSR, using the cryptography backend"""
def __init__(self, module, content, validate_signature):
super(CSRInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, validate_signature)
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
def get_csr_info(module, backend, content, validate_signature=True, prefer_one_fingerprint=False):
if backend == 'cryptography':
info = CSRInfoRetrievalCryptography(module, content, validate_signature=validate_signature)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(module, backend, content, validate_signature=True):
if backend == 'auto':
# Detection what is possible
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
# Try cryptography
if can_use_cryptography:
backend = 'cryptography'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
return backend, CSRInfoRetrievalCryptography(module, content, validate_signature=validate_signature)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))

View File

@@ -16,7 +16,7 @@ from distutils.version import LooseVersion
from ansible.module_utils import six
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 (
CRYPTOGRAPHY_HAS_X25519,
@@ -33,27 +33,21 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
get_fingerprint_of_privatekey,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.identify import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
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
MINIMAL_PYOPENSSL_VERSION = '0.6'
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
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
@@ -102,6 +96,25 @@ class PrivateKeyBackend:
self.existing_private_key = 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
def generate_private_key(self):
"""(Re-)Generate private key."""
@@ -125,6 +138,7 @@ class PrivateKeyBackend:
def set_existing(self, privatekey_bytes):
"""Set existing private key bytes. None indicates that the key does not exist."""
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):
"""Query whether an existing private key is/has been there."""
@@ -215,11 +229,12 @@ class PrivateKeyBackend:
}
if self.type == 'ECC':
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:
# 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
if pk_bytes:
if identify_private_key_format(pk_bytes) == 'raw':
@@ -229,64 +244,13 @@ class PrivateKeyBackend:
else:
result['privatekey'] = None
result['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result
# Implementation with using pyOpenSSL
class PrivateKeyPyOpenSSLBackend(PrivateKeyBackend):
def __init__(self, module):
super(PrivateKeyPyOpenSSLBackend, self).__init__(module=module, backend='pyopenssl')
if self.type == 'RSA':
self.openssl_type = crypto.TYPE_RSA
elif self.type == 'DSA':
self.openssl_type = crypto.TYPE_DSA
else:
self.module.fail_json(msg="PyOpenSSL backend only supports RSA and DSA keys.")
if self.format != 'auto_ignore':
self.module.fail_json(msg="PyOpenSSL backend only supports auto_ignore format.")
def generate_private_key(self):
"""(Re-)Generate private key."""
self.private_key = crypto.PKey()
try:
self.private_key.generate_key(self.openssl_type, self.size)
except (TypeError, ValueError) as exc:
raise PrivateKeyError(exc)
def _ensure_existing_private_key_loaded(self):
if self.existing_private_key is None and self.has_existing():
try:
self.existing_private_key = load_privatekey(
None, self.passphrase, content=self.existing_private_key_bytes, backend=self.backend)
except OpenSSLBadPassphraseError as exc:
raise PrivateKeyError(exc)
def get_private_key_data(self):
"""Return bytes for self.private_key"""
if self.cipher and self.passphrase:
return crypto.dump_privatekey(crypto.FILETYPE_PEM, self.private_key,
self.cipher, to_bytes(self.passphrase))
else:
return crypto.dump_privatekey(crypto.FILETYPE_PEM, self.private_key)
def _check_passphrase(self):
try:
load_privatekey(None, self.passphrase, content=self.existing_private_key_bytes, backend=self.backend)
return True
except Exception as dummy:
return False
def _check_size_and_type(self):
return self.size == self.existing_private_key.bits() and self.openssl_type == self.existing_private_key.type()
def _check_format(self):
# Not supported by this backend
return True
# Implementation with using cryptography
class PrivateKeyCryptographyBackend(PrivateKeyBackend):
@@ -519,36 +483,16 @@ 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)
# Decision
if module.params['cipher'] and module.params['passphrase'] and module.params['cipher'] != 'auto':
# First try pyOpenSSL, then cryptography
if can_use_pyopenssl:
backend = 'pyopenssl'
elif can_use_cryptography:
backend = 'cryptography'
else:
# First try cryptography, then pyOpenSSL
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
if can_use_cryptography:
backend = 'cryptography'
# 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, PrivateKeyPyOpenSSLBackend(module)
elif backend == 'cryptography':
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
@@ -574,7 +518,7 @@ def get_privatekey_argument_spec():
cipher=dict(type='str'),
format=dict(type='str', default='auto_ignore', choices=['pkcs1', 'pkcs8', 'raw', 'auto', 'auto_ignore']),
format_mismatch=dict(type='str', default='regenerate', choices=['regenerate', 'convert']),
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'),
regenerate=dict(
type='str',
default='full_idempotence',

View File

@@ -0,0 +1,286 @@
# -*- 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,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
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, need_private_key_data=False):
key_type, key_public_data = _get_cryptography_public_key_info(key.public_key())
key_private_data = dict()
if need_private_key_data:
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, check_consistency=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
self.check_consistency = check_consistency
@abc.abstractmethod
def _get_public_key(self, binary):
pass
@abc.abstractmethod
def _get_key_info(self, need_private_key_data=False):
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(
need_private_key_data=self.return_private_key_data or self.check_consistency)
result['type'] = key_type
result['public_data'] = key_public_data
if self.return_private_key_data:
result['private_data'] = key_private_data
if self.check_consistency:
result['key_is_consistent'] = self._is_key_consistent(key_public_data, key_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, need_private_key_data=False):
return _get_cryptography_private_key_info(self.key, need_private_key_data=need_private_key_data)
def _is_key_consistent(self, key_public_data, key_private_data):
return _is_cryptography_key_consistent(self.key, key_public_data, key_private_data)
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)
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
def select_backend(module, backend, content, passphrase=None, return_private_key_data=False, check_consistency=False):
if backend == 'auto':
# Detection what is possible
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
# Try cryptography
if can_use_cryptography:
backend = 'cryptography'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
return backend, PrivateKeyInfoRetrievalCryptography(
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data, check_consistency=check_consistency)
else:
raise ValueError('Unsupported value for backend: {0}'.format(backend))

View File

@@ -0,0 +1,167 @@
# -*- 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'
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
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)
def get_publickey_info(module, backend, content=None, key=None, prefer_one_fingerprint=False):
if backend == 'cryptography':
info = PublicKeyInfoRetrievalCryptography(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)
# Try cryptography
if can_use_cryptography:
backend = 'cryptography'
# Success?
if backend == 'auto':
module.fail_json(msg=("Can't detect any of the required Python libraries "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
return backend, 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
__metaclass__ = type
import re
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
# This import is only to maintain backwards compatibility
from ansible_collections.community.crypto.plugins.module_utils.openssh.utils import (
parse_openssh_version
)

View File

@@ -54,3 +54,31 @@ def identify_private_key_format(content):
except UnicodeDecodeError:
pass
return 'raw'
def split_pem_list(text, keep_inbetween=False):
'''
Split concatenated PEM objects into a list of strings, where each is one PEM object.
'''
result = []
current = [] if keep_inbetween else None
for line in text.splitlines(True):
if line.strip():
if not keep_inbetween and line.startswith('-----BEGIN '):
current = []
if current is not None:
current.append(line)
if line.startswith('-----END '):
result.append(''.join(current))
current = [] if keep_inbetween else None
return result
def extract_first_pem(text):
'''
Given one PEM or multiple concatenated PEM objects, return only the first one, or None if there is none.
'''
all_pems = split_pem_list(text)
if not all_pems:
return None
return all_pems[0]

View File

@@ -1,154 +0,0 @@
# -*- coding: utf-8 -*-
#
# (c) 2019, Felix Fontein <felix@fontein.de>
#
# 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 base64
from ansible.module_utils._text import to_bytes, to_text
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
try:
import OpenSSL
except ImportError:
# Error handled in the calling module.
pass
from ._objects import (
NORMALIZE_NAMES_SHORT,
NORMALIZE_NAMES,
)
from ._obj2txt import obj2txt
from .basic import (
OpenSSLObjectError,
)
def pyopenssl_normalize_name(name, short=False):
nid = OpenSSL._util.lib.OBJ_txt2nid(to_bytes(name))
if nid != 0:
b_name = OpenSSL._util.lib.OBJ_nid2ln(nid)
name = to_text(OpenSSL._util.ffi.string(b_name))
if short:
return NORMALIZE_NAMES_SHORT.get(name, name)
else:
return NORMALIZE_NAMES.get(name, name)
def pyopenssl_normalize_name_attribute(san):
# apparently openssl returns 'IP address' not 'IP' as specifier when converting the subjectAltName to string
# although it won't accept this specifier when generating the CSR. (https://github.com/openssl/openssl/issues/4004)
if san.startswith('IP Address:'):
san = 'IP:' + san[len('IP Address:'):]
if san.startswith('IP:'):
address = san[3:]
if '/' in address:
ip = compat_ipaddress.ip_network(address)
san = 'IP:{0}/{1}'.format(ip.network_address.compressed, ip.prefixlen)
else:
ip = compat_ipaddress.ip_address(address)
san = 'IP:{0}'.format(ip.compressed)
if san.startswith('Registered ID:'):
san = 'RID:' + san[len('Registered ID:'):]
# Some versions of OpenSSL apparently forgot the colon. Happens in CI with Ubuntu 16.04 and FreeBSD 11.1
if san.startswith('Registered ID'):
san = 'RID:' + san[len('Registered ID'):]
return san
def pyopenssl_get_extensions_from_cert(cert):
# While pyOpenSSL allows us to get an extension's DER value, it won't
# give us the dotted string for an OID. So we have to do some magic to
# get hold of it.
result = dict()
ext_count = cert.get_extension_count()
for i in range(0, ext_count):
ext = cert.get_extension(i)
entry = dict(
critical=bool(ext.get_critical()),
value=base64.b64encode(ext.get_data()),
)
oid = obj2txt(
OpenSSL._util.lib,
OpenSSL._util.ffi,
OpenSSL._util.lib.X509_EXTENSION_get_object(ext._extension)
)
# This could also be done a bit simpler:
#
# oid = obj2txt(OpenSSL._util.lib, OpenSSL._util.ffi, OpenSSL._util.lib.OBJ_nid2obj(ext._nid))
#
# Unfortunately this gives the wrong result in case the linked OpenSSL
# doesn't know the OID. That's why we have to get the OID dotted string
# similarly to how cryptography does it.
result[oid] = entry
return result
def pyopenssl_get_extensions_from_csr(csr):
# While pyOpenSSL allows us to get an extension's DER value, it won't
# give us the dotted string for an OID. So we have to do some magic to
# get hold of it.
result = dict()
for ext in csr.get_extensions():
entry = dict(
critical=bool(ext.get_critical()),
value=base64.b64encode(ext.get_data()),
)
oid = obj2txt(
OpenSSL._util.lib,
OpenSSL._util.ffi,
OpenSSL._util.lib.X509_EXTENSION_get_object(ext._extension)
)
# This could also be done a bit simpler:
#
# oid = obj2txt(OpenSSL._util.lib, OpenSSL._util.ffi, OpenSSL._util.lib.OBJ_nid2obj(ext._nid))
#
# Unfortunately this gives the wrong result in case the linked OpenSSL
# doesn't know the OID. That's why we have to get the OID dotted string
# similarly to how cryptography does it.
result[oid] = entry
return result
def pyopenssl_parse_name_constraints(name_constraints_extension):
lines = to_text(name_constraints_extension, errors='surrogate_or_strict').splitlines()
exclude = None
excluded = []
permitted = []
for line in lines:
if line.startswith(' ') or line.startswith('\t'):
name = pyopenssl_normalize_name_attribute(line.strip())
if exclude is True:
excluded.append(name)
elif exclude is False:
permitted.append(name)
else:
raise OpenSSLObjectError('Unexpected nameConstraint line: "{0}"'.format(line))
else:
line_lc = line.lower()
if line_lc.startswith('exclud'):
exclude = True
elif line_lc.startswith('includ') or line_lc.startswith('permitt'):
exclude = False
else:
raise OpenSSLObjectError('Cannot parse nameConstraint line: "{0}"'.format(line))
return permitted, excluded

View File

@@ -27,7 +27,7 @@ import os
import re
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:
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."""
fingerprint = {}
@@ -65,6 +72,12 @@ def get_fingerprint_of_bytes(source):
except AttributeError:
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:
f = getattr(hashlib, algo)
try:
@@ -79,46 +92,33 @@ def get_fingerprint_of_bytes(source):
except TypeError:
pubkey_digest = h.hexdigest(32)
fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2))
if prefer_one:
break
return fingerprint
def get_fingerprint_of_privatekey(privatekey, backend='pyopenssl'):
def get_fingerprint_of_privatekey(privatekey, backend='cryptography', prefer_one=False):
"""Generate the fingerprint of the public key. """
if backend == 'pyopenssl':
try:
publickey = crypto.dump_publickey(crypto.FILETYPE_ASN1, privatekey)
except AttributeError:
# If PyOpenSSL < 16.0 crypto.dump_publickey() will fail.
try:
bio = crypto._new_mem_buf()
rc = crypto._lib.i2d_PUBKEY_bio(bio, privatekey._pkey)
if rc != 1:
crypto._raise_current_error()
publickey = crypto._bio_to_string(bio)
except AttributeError:
# By doing this we prevent the code from raising an error
# yet we return no value in the fingerprint hash.
return None
elif backend == 'cryptography':
if backend == 'cryptography':
publickey = privatekey.public_key().public_bytes(
serialization.Encoding.DER,
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='cryptography', prefer_one=False):
"""Generate the fingerprint of the public key. """
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='cryptography'):
"""Load the specified OpenSSL private key.
The content can also be specified via content; in that case,
@@ -183,7 +183,24 @@ def load_privatekey(path, passphrase=None, check_passphrase=True, content=None,
return result
def load_certificate(path, content=None, backend='pyopenssl'):
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))
def load_certificate(path, content=None, backend='cryptography'):
"""Load the specified certificate."""
try:
@@ -203,7 +220,7 @@ def load_certificate(path, content=None, backend='pyopenssl'):
raise OpenSSLObjectError(exc)
def load_certificate_request(path, content=None, backend='pyopenssl'):
def load_certificate_request(path, content=None, backend='cryptography'):
"""Load the specified certificate signing request."""
try:
if content is None:
@@ -213,25 +230,50 @@ def load_certificate_request(path, content=None, backend='pyopenssl'):
csr_content = content
except (IOError, OSError) as exc:
raise OpenSSLObjectError(exc)
if backend == 'pyopenssl':
return crypto.load_certificate_request(crypto.FILETYPE_PEM, csr_content)
elif backend == 'cryptography':
if backend == 'cryptography':
try:
return x509.load_pem_x509_csr(csr_content, cryptography_backend())
except ValueError as exc:
raise OpenSSLObjectError(exc)
def parse_name_field(input_dict):
def parse_name_field(input_dict, name_field_name=None):
"""Take a dict with key: value or key: list_of_values mappings and return a list of tuples"""
error_str = '{key}' if name_field_name is None else '{key} in {name}'
result = []
for key, value in input_dict.items():
if isinstance(value, list):
for entry in value:
if not isinstance(entry, six.string_types):
raise TypeError(('Values %s must be strings' % error_str).format(key=key, name=name_field_name))
if not entry:
raise ValueError(('Values for %s must not be empty strings' % error_str).format(key=key))
result.append((key, entry))
elif isinstance(value, six.string_types):
if not value:
raise ValueError(('Value for %s must not be an empty string' % error_str).format(key=key))
result.append((key, value))
else:
raise TypeError(('Value for %s must be either a string or a list of strings' % error_str).format(key=key))
return result
def parse_ordered_name_field(input_list, name_field_name):
"""Take a dict with key: value or key: list_of_values mappings and return a list of tuples"""
result = []
for key in input_dict:
if isinstance(input_dict[key], list):
for entry in input_dict[key]:
result.append((key, entry))
else:
result.append((key, input_dict[key]))
for index, entry in enumerate(input_list):
if len(entry) != 1:
raise ValueError(
'Entry #{index} in {name} must be a dictionary with exactly one key-value pair'.format(
name=name_field_name, index=index + 1))
try:
result.extend(parse_name_field(entry, name_field_name=name_field_name))
except (TypeError, ValueError) as exc:
raise ValueError(
'Error while processing entry #{index} in {name}: {error}'.format(
name=name_field_name, index=index + 1, error=exc))
return result
@@ -285,9 +327,7 @@ def get_relative_time_option(input_string, input_name, backend='cryptography'):
elif backend == 'cryptography':
return result_datetime
# Absolute time
if backend == 'pyopenssl':
return input_string
elif backend == 'cryptography':
if backend == 'cryptography':
for date_fmt in ['%Y%m%d%H%M%SZ', '%Y%m%d%H%MZ', '%Y%m%d%H%M%S%z', '%Y%m%d%H%M%z']:
try:
return datetime.datetime.strptime(result, date_fmt)
@@ -334,6 +374,8 @@ class OpenSSLObject(object):
def _check_perms(module):
file_args = module.load_file_common_arguments(module.params)
if module.check_file_absent_if_check_mode(file_args['path']):
return False
return not module.set_fs_attributes_if_different(file_args, False)
if not perms_required:

View File

@@ -37,7 +37,7 @@ import re
import time
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.six.moves.urllib.parse import urlencode
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
module.atomic_move(tmp_name, file_args['path'])
# 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:
try:
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

@@ -31,6 +31,8 @@ seealso:
description: Retrieves facts about an ACME account.
- module: community.crypto.openssl_privatekey
description: Can be used to create a private account key.
- module: community.crypto.openssl_privatekey_pipe
description: Can be used to create a private account key without writing it to disk.
- module: community.crypto.acme_inspect
description: Allows to debug problems.
extends_documentation_fragment:
@@ -86,6 +88,12 @@ options:
- "Mutually exclusive with C(new_account_key_src)."
- "Required if C(new_account_key_src) is not used and state is C(changed_key)."
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:
description:
- Allows to provide external account binding data during account creation.
@@ -156,11 +164,19 @@ import base64
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
ACMEAccount,
handle_standard_module_arguments,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
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,
)
@@ -173,6 +189,7 @@ def main():
contact=dict(type='list', elements='str', default=[]),
new_account_key_src=dict(type='path'),
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(
kid=dict(type='str', required=True),
alg=dict(type='str', required=True, choices=['HS256', 'HS384', 'HS512']),
@@ -195,7 +212,7 @@ def main():
),
supports_check_mode=True,
)
handle_standard_module_arguments(module, needs_acme_v2=True)
backend = create_backend(module, True)
if module.params['external_account_binding']:
# Make sure padding is there
@@ -210,7 +227,8 @@ def main():
module.params['external_account_binding']['key'] = key
try:
account = ACMEAccount(module)
client = ACMEClient(module, backend)
account = ACMEAccount(client)
changed = False
state = module.params.get('state')
diff_before = {}
@@ -219,7 +237,7 @@ def main():
created, account_data = account.setup_account(allow_creation=False)
if 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:
raise AssertionError('Unwanted account creation')
if account_data is not None:
@@ -229,9 +247,8 @@ def main():
payload = {
'status': 'deactivated'
}
result, info = account.send_signed_request(account.uri, payload)
if info['status'] != 200:
raise ModuleFailException('Error deactivating account: {0} {1}'.format(info['status'], result))
result, info = client.send_signed_request(
client.account_uri, payload, error_msg='Failed to deactivate account', expected_status_codes=[200])
changed = True
elif state == 'present':
allow_creation = module.params.get('allow_creation')
@@ -250,21 +267,23 @@ def main():
diff_before = {}
else:
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
if not created:
updated, account_data = account.update_account(account_data, contact)
changed = created or updated
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':
# Parse new account key
error, new_key_data = account.parse_key(
module.params.get('new_account_key_src'),
module.params.get('new_account_key_content')
)
if error:
raise ModuleFailException("error while parsing account key: %s" % error)
try:
new_key_data = client.parse_key(
module.params.get('new_account_key_src'),
module.params.get('new_account_key_content'),
passphrase=module.params.get('new_account_key_passphrase'),
)
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
created, account_data = account.setup_account(allow_creation=False)
if created:
@@ -272,30 +291,29 @@ def main():
if account_data is None:
raise ModuleFailException(msg='Account does not exist or is deactivated.')
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
if not module.check_mode:
# Compose inner signed message
# https://tools.ietf.org/html/rfc8555#section-7.3.5
url = account.directory['keyChange']
url = client.directory['keyChange']
protected = {
"alg": new_key_data['alg'],
"jwk": new_key_data['jwk'],
"url": url,
}
payload = {
"account": account.uri,
"account": client.account_uri,
"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
result, info = account.send_signed_request(url, data)
if info['status'] != 200:
raise ModuleFailException('Error account key rollover: {0} {1}'.format(info['status'], result))
result, info = client.send_signed_request(
url, data, error_msg='Failed to rollover account key', expected_status_codes=[200])
if module._diff:
account.key_data = new_key_data
account.jws_header['alg'] = new_key_data['alg']
client.account_key_data = new_key_data
client.account_jws_header['alg'] = new_key_data['alg']
diff_after = account.get_account_data()
elif module._diff:
# Kind of fake diff_after
@@ -304,7 +322,7 @@ def main():
changed = True
result = {
'changed': changed,
'account_uri': account.uri,
'account_uri': client.account_uri,
}
if module._diff:
result['diff'] = {

View File

@@ -1 +0,0 @@
acme_account_info.py

View File

@@ -23,12 +23,16 @@ notes:
accounts."
- "This module was called C(acme_account_facts) before Ansible 2.8. The usage
did not change."
- Supports C(check_mode).
options:
retrieve_orders:
description:
- "Whether to retrieve the list of order URLs or order objects, if provided
by the ACME server."
- "A value of C(ignore) will not fetch the list of orders."
- "If the value is not C(ignore) and the ACME server supports orders, the C(order_uris)
return value is always populated. The C(orders) return value is only returned
if this option is set to C(object_list)."
- "Currently, Let's Encrypt does not return orders, so the C(orders) result
will always be empty."
type: str
@@ -55,9 +59,11 @@ EXAMPLES = '''
that:
- account_data.exists
- name: Print account URI
debug: var=account_data.account_uri
ansible.builtin.debug:
var: account_data.account_uri
- name: Print account contacts
debug: var=account_data.account.contact
ansible.builtin.debug:
var: account_data.account.contact
- name: Check whether the account exists and is accessible with the given account key
acme_account_info:
@@ -69,7 +75,8 @@ EXAMPLES = '''
that:
- account_data.exists
- name: Print account contacts
debug: var=account_data.account.contact
ansible.builtin.debug:
var: account_data.account.contact
'''
RETURN = '''
@@ -117,11 +124,9 @@ account:
orders:
description:
- "The list of orders."
- "If I(retrieve_orders) is C(url_list), this will be a list of URLs."
- "If I(retrieve_orders) is C(object_list), this will be a list of objects."
type: list
#elements: ... depends on retrieve_orders
returned: if account exists, I(retrieve_orders) is not C(ignore), and server supports order listing
elements: dict
returned: if account exists, I(retrieve_orders) is C(object_list), and server supports order listing
contains:
status:
description: The order's status.
@@ -190,27 +195,45 @@ orders:
- The URL for retrieving the certificate.
type: str
returned: when certificate was issued
order_uris:
description:
- "The list of orders."
- "If I(retrieve_orders) is C(url_list), this will be a list of URLs."
- "If I(retrieve_orders) is C(object_list), this will be a list of objects."
type: list
elements: str
returned: if account exists, I(retrieve_orders) is not C(ignore), and server supports order listing
version_added: 1.5.0
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
ACMEAccount,
handle_standard_module_arguments,
process_links,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
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).
'''
orders = []
while orders_url:
# 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 orders:
module.warn('When retrieving orders list part {0}, got empty result list'.format(orders_url))
@@ -233,11 +256,11 @@ def get_orders_list(module, account, orders_url):
return orders
def get_order(account, order_url):
def get_order(client, order_url):
'''
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():
@@ -255,13 +278,11 @@ def main():
),
supports_check_mode=True,
)
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'",
version='2.0.0', collection_name='community.crypto')
handle_standard_module_arguments(module, needs_acme_v2=True)
backend = create_backend(module, True)
try:
account = ACMEAccount(module)
client = ACMEClient(module, backend)
account = ACMEAccount(client)
# Check whether account exists
created, account_data = account.setup_account(
[],
@@ -272,22 +293,21 @@ def main():
raise AssertionError('Unwanted account creation')
result = {
'changed': False,
'exists': account.uri is not None,
'account_uri': account.uri,
'exists': client.account_uri is not None,
'account_uri': client.account_uri,
}
if account.uri is not None:
if client.account_uri is not None:
# Make sure promised data is there
if 'contact' not in account_data:
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
# Retrieve orders list
if account_data.get('orders') and module.params['retrieve_orders'] != 'ignore':
orders = get_orders_list(module, account, account_data['orders'])
if module.params['retrieve_orders'] == 'url_list':
result['orders'] = orders
else:
result['orders'] = [get_order(account, order) for order in orders]
orders = get_orders_list(module, client, account_data['orders'])
result['order_uris'] = orders
if module.params['retrieve_orders'] == 'object_list':
result['orders'] = [get_order(client, order) for order in orders]
module.exit_json(**result)
except ModuleFailException as e:
e.do_fail(module)

View File

@@ -62,8 +62,12 @@ seealso:
description: Helps preparing C(tls-alpn-01) challenges.
- module: community.crypto.openssl_privatekey
description: Can be used to create private keys (both for certificates and accounts).
- module: community.crypto.openssl_privatekey_pipe
description: Can be used to create private keys without writing it to disk (both for certificates and accounts).
- module: community.crypto.openssl_csr
description: Can be used to create a Certificate Signing Request (CSR).
- module: community.crypto.openssl_csr_pipe
description: Can be used to create a Certificate Signing Request (CSR) without writing it to disk.
- module: community.crypto.certificate_complete_chain
description: Allows to find the root certificate for the returned fullchain.
- module: community.crypto.acme_certificate_revoke
@@ -117,7 +121,7 @@ options:
csr:
description:
- "File containing the CSR for the new certificate."
- "Can be created with C(openssl req ...)."
- "Can be created with M(community.crypto.openssl_csr) or C(openssl req ...)."
- "The CSR may contain multiple Subject Alternate Names, but each one
will lead to an individual challenge that must be fulfilled for the
CSR to be signed."
@@ -131,7 +135,7 @@ options:
csr_content:
description:
- "Content of the CSR for the new certificate."
- "Can be created with C(openssl req ...)."
- "Can be created with M(community.crypto.openssl_csr_pipe) or C(openssl req ...)."
- "The CSR may contain multiple Subject Alternate Names, but each one
will lead to an individual challenge that must be fulfilled for the
CSR to be signed."
@@ -311,7 +315,7 @@ EXAMPLES = r'''
# - copy:
# dest: /var/www/{{ item.key }}/{{ item.value['http-01']['resource'] }}
# content: "{{ item.value['http-01']['resource_value'] }}"
# loop: "{{ sample_com_challenge.challenge_data | dictsort }}"
# loop: "{{ sample_com_challenge.challenge_data | dict2items }}"
# when: sample_com_challenge is changed
- name: Let the challenge be validated and retrieve the cert and intermediate certificate
@@ -363,7 +367,7 @@ EXAMPLES = r'''
# # Note: item.value is a list of TXT entries, and route53
# # requires every entry to be enclosed in quotes
# value: "{{ item.value | map('regex_replace', '^(.*)$', '\"\\1\"' ) | list }}"
# loop: "{{ sample_com_challenge.challenge_data_dns | dictsort }}"
# loop: "{{ sample_com_challenge.challenge_data_dns | dict2items }}"
# when: sample_com_challenge is changed
- name: Let the challenge be validated and retrieve the cert and intermediate certificate
@@ -504,88 +508,57 @@ all_chains:
returned: always
'''
import base64
import binascii
import hashlib
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.module_utils._text import to_bytes, to_native
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.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,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
get_default_argspec,
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
try:
import cryptography
import cryptography.hazmat.backends
import cryptography.x509
except ImportError:
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
CRYPTOGRAPHY_FOUND = False
else:
CRYPTOGRAPHY_FOUND = True
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
combine_identifier,
split_identifier,
Authorization,
)
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):
'''
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):
class ACMECertificateClient(object):
'''
ACME client class. Uses an ACME account object and a CSR to
start and validate ACME challenges and download the respective
certificates.
'''
def __init__(self, module):
def __init__(self, module, backend):
self.module = module
self.version = module.params['acme_version']
self.challenge = module.params['challenge']
@@ -594,13 +567,25 @@ class ACMEClient(object):
self.dest = module.params.get('dest')
self.fullchain_dest = module.params.get('fullchain_dest')
self.chain_dest = module.params.get('chain_dest')
self.account = ACMEAccount(module)
self.directory = self.account.directory
self.client = ACMEClient(module, backend)
self.account = ACMEAccount(self.client)
self.directory = self.client.directory
self.data = module.params['data']
self.authorizations = None
self.cert_days = -1
self.order = 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']):
try:
self.select_chain_matcher.append(
self.client.backend.create_chain_matcher(
Criterium(criterium, index=criterium_idx)))
except ValueError as exc:
self.module.warn('Error while parsing criterium: {error}. Ignoring criterium.'.format(error=exc))
# Make sure account exists
modify_account = module.params['modify_account']
@@ -631,287 +616,8 @@ class ACMEClient(object):
if self.csr is not None and not os.path.exists(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
self.identifiers = self._get_csr_identifiers()
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.
'''
for challenge in auth['challenges']:
if self.challenge != challenge['type']:
continue
uri = challenge['uri'] if self.version == 1 else challenge['url']
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))
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
lines = content.decode('utf-8').splitlines(True)
current = []
for line in lines:
if line.strip():
current.append(line)
if line.startswith('-----END CERTIFICATE-----'):
if cert is None:
cert = ''.join(current)
else:
chain.append(''.join(current))
current = []
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 or current:
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']
self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
def is_first_step(self):
'''
@@ -939,28 +645,33 @@ class ACMEClient(object):
if identifier_type != 'dns':
raise ModuleFailException('ACME v1 only supports DNS identifiers!')
for identifier_type, identifier in self.identifiers:
new_auth = self._new_authz_v1(identifier_type, identifier)
self._add_or_update_auth(identifier_type, identifier, new_auth)
authz = Authorization.create(self.client, identifier_type, identifier)
self.authorizations[authz.combined_identifier] = authz
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
def get_challenges_data(self):
def get_challenges_data(self, first_step):
'''
Get challenge details for the chosen challenge type.
Return a tuple of generic challenge details, and specialized DNS challenge details.
'''
# Get general challenge data
data = {}
for type_identifier, auth in self.authorizations.items():
identifier_type, identifier = type_identifier.split(':', 1)
auth = self.authorizations[type_identifier]
for type_identifier, authz in self.authorizations.items():
identifier_type, identifier = split_identifier(type_identifier)
# Skip valid authentications: their challenges are already valid
# and do not need to be returned
if auth['status'] == 'valid':
if authz.status == 'valid':
continue
# 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]:
raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}!".format(
self.challenge, type_identifier))
# Get DNS challenge data
data_dns = {}
if self.challenge == 'dns-01':
@@ -984,91 +695,40 @@ class ACMEClient(object):
# For ACME v1, we attempt to create new authzs. Existing ones
# will be returned instead.
for identifier_type, identifier in self.identifiers:
new_auth = self._new_authz_v1(identifier_type, identifier)
self._add_or_update_auth(identifier_type, identifier, new_auth)
authz = Authorization.create(self.client, identifier_type, identifier)
self.authorizations[combine_identifier(identifier_type, identifier)] = authz
else:
# For ACME v2, we obtain the order object by fetching the
# 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:
raise ModuleFailException("Cannot download order from {0}: {1} (headers: {2})".format(self.order_uri, result, info))
# Step 2: validate pending challenges
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]:
raise ModuleFailException("Error on downloading 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.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:
def download_alternate_chains(self, cert):
alternate_chains = []
for alternate in cert.alternates:
try:
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend())
matches = True
if criterium['subject']:
for k, v in parse_name_field(criterium['subject']):
oid = cryptography_name_to_oid(k)
value = to_native(v)
found = False
for attribute in x509.subject:
if attribute.oid == oid and value == to_native(attribute.value):
found = True
break
if not found:
matches = False
break
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
alt_cert = CertificateChain.download(self.client, alternate)
except ModuleFailException as e:
self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e))
continue
alternate_chains.append(alt_cert)
return alternate_chains
def find_matching_chain(self, chains):
for criterium_idx, matcher in enumerate(self.select_chain_matcher):
for chain in chains:
if matcher.match(chain):
self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
return chain
return None
def get_certificate(self):
'''
@@ -1077,81 +737,46 @@ class ACMEClient(object):
with an error.
'''
for identifier_type, identifier in self.identifiers:
auth = self.authorizations.get(identifier_type + ':' + identifier)
if auth is None:
raise ModuleFailException('Found no authorization information for "{0}"!'.format(identifier_type + ':' + identifier))
if 'status' not in auth:
self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned no status')
if auth['status'] != 'valid':
self._fail_challenge(identifier_type, identifier, auth, 'Authorization for {0} returned status ' + str(auth['status']))
authz = self.authorizations.get(combine_identifier(identifier_type, identifier))
if authz is None:
raise ModuleFailException('Found no authorization information for "{identifier}"!'.format(
identifier=combine_identifier(identifier_type, identifier)))
if authz.status != 'valid':
authz.raise_error('Status is "{status}" and not "valid"'.format(status=authz.status), module=self.module)
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:
cert_uri = self._finalize_cert()
cert = self._download_cert(cert_uri)
if self.module.params['retrieve_all_alternates'] or self.module.params['select_chain']:
self.order.finalize(self.client, pem_to_der(self.csr, self.csr_content))
cert = CertificateChain.download(self.client, self.order.certificate_uri)
if self.module.params['retrieve_all_alternates'] or self.select_chain_matcher:
# Retrieve alternate chains
alternate_chains = []
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)
alternate_chains = self.download_alternate_chains(cert)
# Prepare return value for all alternate chains
if self.module.params['retrieve_all_alternates']:
self.all_chains = []
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)
self.all_chains = [cert.to_json()]
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
if self.module.params['select_chain']:
matching_chain = None
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 self.select_chain_matcher:
matching_chain = self.find_matching_chain([cert] + alternate_chains)
if matching_chain:
cert.update(matching_chain)
cert = matching_chain
else:
self.module.debug('Found no matching alternative chain')
if cert['cert'] is not None:
pem_cert = cert['cert']
chain = [link for link in cert.get('chain', [])]
if cert.cert is not None:
pem_cert = cert.cert
chain = cert.chain
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
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
if self.chain_dest and write_file(self.module, self.chain_dest, ("\n".join(chain)).encode('utf8')):
@@ -1163,25 +788,14 @@ class ACMEClient(object):
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
'''
authz_deactivate = {
'status': 'deactivated'
}
if self.version == 1:
authz_deactivate['resource'] = 'authz'
if self.authorizations:
for identifier_type, identifier in self.identifiers:
auth = self.authorizations.get(identifier_type + ':' + identifier)
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']))
for authz in self.authorizations.values():
try:
authz.deactivate(self.client)
except Exception:
# ignore errors
pass
if authz.status != 'deactivated':
self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url))
def main():
@@ -1223,18 +837,13 @@ def main():
),
supports_check_mode=True,
)
backend = handle_standard_module_arguments(module)
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'))
backend = create_backend(module, False)
try:
if module.params.get('dest'):
cert_days = get_cert_days(module, module.params['dest'])
cert_days = backend.get_cert_days(module.params['dest'])
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 checkmode is active, base the changed state solely on the status
@@ -1244,10 +853,11 @@ def main():
if module.check_mode:
module.exit_json(changed=True, authorizations={}, challenge_data={}, cert_days=cert_days)
else:
client = ACMEClient(module)
client = ACMECertificateClient(module, backend)
client.cert_days = cert_days
other = dict()
if client.is_first_step():
is_first_step = client.is_first_step()
if is_first_step:
# First run: start challenges / start new order
client.start_challenges()
else:
@@ -1255,22 +865,22 @@ def main():
try:
client.finish_challenges()
client.get_certificate()
if module.params['retrieve_all_alternates']:
if client.all_chains is not None:
other['all_chains'] = client.all_chains
finally:
if module.params['deactivate_authzs']:
client.deactivate_authzs()
data, data_dns = client.get_challenges_data()
data, data_dns = client.get_challenges_data(first_step=is_first_step)
auths = dict()
for k, v in client.authorizations.items():
# Remove "type:" from key
auths[k.split(':', 1)[1]] = v
auths[split_identifier(k)[1]] = v.to_json()
module.exit_json(
changed=client.changed,
authorizations=auths,
finalize_uri=client.finalize_uri,
finalize_uri=client.order.finalize_uri if client.order else None,
order_uri=client.order_uri,
account_uri=client.account.uri,
account_uri=client.client.account_uri,
challenge_data=data,
challenge_data_dns=data_dns,
cert_days=client.cert_days,

View File

@@ -25,6 +25,7 @@ notes:
was different than the one specified here. Also, depending on the
server, it can happen that some other error is returned if the
certificate has already been revoked."
- Does not support C(check_mode).
seealso:
- name: The Let's Encrypt documentation
description: Documentation for the Let's Encrypt Certification Authority.
@@ -53,7 +54,6 @@ options:
private keys in PEM format can be used as well."
- "Mutually exclusive with C(account_key_content)."
- "Required if C(account_key_content) is not used."
type: path
account_key_content:
description:
- "Content of the ACME account RSA or Elliptic Curve key."
@@ -68,7 +68,6 @@ options:
temporary file. It can still happen that it is written to disk by
Ansible in the process of moving the module with its argument to
the node where it is executed."
type: str
private_key_src:
description:
- "Path to the certificate's private key."
@@ -90,6 +89,12 @@ options:
Ansible in the process of moving the module with its argument to
the node where it is executed."
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:
description:
- "One of the revocation reasonCodes defined in
@@ -98,7 +103,7 @@ options:
C(2) (cACompromise), C(3) (affiliationChanged), C(4) (superseded),
C(5) (cessationOfOperation), C(6) (certificateHold),
C(8) (removeFromCRL), C(9) (privilegeWithdrawn),
C(10) (aACompromise)"
C(10) (aACompromise)."
type: int
'''
@@ -114,18 +119,29 @@ EXAMPLES = '''
certificate: /etc/httpd/ssl/sample.com.crt
'''
RETURN = '''
'''
RETURN = '''#'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.crypto.plugins.module_utils.acme import (
ModuleFailException,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
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 (
ACMEProtocolException,
ModuleFailException,
KeyParsingError,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
nopad_b64,
pem_to_der,
handle_standard_module_arguments,
get_default_argspec,
)
@@ -134,6 +150,7 @@ def main():
argument_spec.update(dict(
private_key_src=dict(type='path'),
private_key_content=dict(type='str', no_log=True),
private_key_passphrase=dict(type='str', no_log=True),
certificate=dict(type='path', required=True),
revoke_reason=dict(type='int'),
))
@@ -147,10 +164,11 @@ def main():
),
supports_check_mode=False,
)
handle_standard_module_arguments(module)
backend = create_backend(module, False)
try:
account = ACMEAccount(module)
client = ACMEClient(module, backend)
account = ACMEAccount(client)
# Load certificate
certificate = pem_to_der(module.params.get('certificate'))
certificate = nopad_b64(certificate)
@@ -162,25 +180,28 @@ def main():
payload['reason'] = module.params.get('revoke_reason')
# Determine endpoint
if module.params.get('acme_version') == 1:
endpoint = account.directory['revoke-cert']
endpoint = client.directory['revoke-cert']
payload['resource'] = 'revoke-cert'
else:
endpoint = account.directory['revokeCert']
endpoint = client.directory['revokeCert']
# Get hold of private key (if available) and make sure it comes from disk
private_key = module.params.get('private_key_src')
private_key_content = module.params.get('private_key_content')
# Revoke certificate
if private_key or private_key_content:
passphrase = module.params['private_key_passphrase']
# Step 1: load and parse private key
error, private_key_data = account.parse_key(private_key, private_key_content)
if error:
raise ModuleFailException("error while parsing private key: %s" % error)
try:
private_key_data = client.parse_key(private_key, private_key_content, passphrase=passphrase)
except KeyParsingError as e:
raise ModuleFailException("Error while parsing private key: {msg}".format(msg=e.msg))
# Step 2: sign revokation request with private key
jws_header = {
"alg": private_key_data['alg'],
"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:
# Step 1: get hold of account URI
created, account_data = account.setup_account(allow_creation=False)
@@ -189,7 +210,7 @@ def main():
if account_data is None:
raise ModuleFailException(msg='Account does not exist or is deactivated.')
# 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:
already_revoked = False
# 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
if already_revoked:
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)
except ModuleFailException as e:
e.do_fail(module)

View File

@@ -52,6 +52,13 @@ options:
- "Content of the private key to use for this challenge certificate."
- "Mutually exclusive with C(private_key_src)."
type: str
private_key_passphrase:
description:
- Phassphrase to use to decode the private key.
type: str
version_added: 1.6.0
notes:
- Does not support C(check_mode).
'''
EXAMPLES = '''
@@ -136,10 +143,11 @@ import sys
import traceback
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 (
ModuleFailException,
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,
)
@@ -184,6 +192,7 @@ def main():
challenge_data=dict(type='dict', required=True),
private_key_src=dict(type='path'),
private_key_content=dict(type='str', no_log=True),
private_key_passphrase=dict(type='str', no_log=True),
),
required_one_of=(
['private_key_src', 'private_key_content'],
@@ -193,7 +202,10 @@ def main():
),
)
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:
# Get parameters
@@ -202,12 +214,16 @@ def main():
# Get hold of private key
private_key_content = module.params.get('private_key_content')
private_key_passphrase = module.params.get('private_key_passphrase')
if private_key_content is None:
private_key_content = read_file(module.params['private_key_src'])
else:
private_key_content = to_bytes(private_key_content)
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:
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._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 (
ModuleFailException,
ACMEAccount,
handle_standard_module_arguments,
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
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],
),
)
handle_standard_module_arguments(module)
backend = create_backend(module, False)
result = dict()
changed = False
try:
# Get hold of ACMEAccount object (includes directory)
account = ACMEAccount(module)
# Get hold of ACMEClient and ACMEAccount objects (includes directory)
client = ACMEClient(module, backend)
method = module.params['method']
result['directory'] = account.directory.directory
result['directory'] = client.directory.directory
# Do we have to do more requests?
if method != 'directory-only':
url = module.params['url']
fail_on_acme_error = module.params['fail_on_acme_error']
# Do request
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':
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
result.update(dict(
headers=info,
@@ -299,13 +302,12 @@ def main():
))
# See if we can parse the result as JSON
try:
# to_text() is needed only for Python 3.5 (and potentially 3.0 to 3.4 as well)
result['output_json'] = json.loads(to_text(data))
result['output_json'] = module.from_json(to_text(data))
except Exception as dummy:
pass
# Fail if error was returned
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!
module.exit_json(changed=changed, **result)
except ModuleFailException as e:

View File

@@ -122,7 +122,11 @@ import os
import traceback
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 (
split_pem_list,
)
CRYPTOGRAPHY_IMP_ERR = None
try:
@@ -194,27 +198,17 @@ def parse_PEM_list(module, text, source, fail_on_error=True):
Parse concatenated PEM certificates. Return list of ``Certificate`` objects.
'''
result = []
lines = text.splitlines(True)
current = None
for line in lines:
if line.strip():
if line.startswith('-----BEGIN '):
current = [line]
elif current is not None:
current.append(line)
if line.startswith('-----END '):
cert_pem = ''.join(current)
current = None
# Try to load PEM certificate
try:
cert = cryptography.x509.load_pem_x509_certificate(to_bytes(cert_pem), _cryptography_backend)
result.append(Certificate(cert_pem, cert))
except Exception as e:
msg = 'Cannot parse certificate #{0} from {1}: {2}'.format(len(result) + 1, source, e)
if fail_on_error:
module.fail_json(msg=msg)
else:
module.warn(msg)
for cert_pem in split_pem_list(text):
# Try to load PEM certificate
try:
cert = cryptography.x509.load_pem_x509_certificate(to_bytes(cert_pem), _cryptography_backend)
result.append(Certificate(cert_pem, cert))
except Exception as e:
msg = 'Cannot parse certificate #{0} from {1}: {2}'.format(len(result) + 1, source, e)
if fail_on_error:
module.fail_json(msg=msg)
else:
module.warn(msg)
return result

View File

@@ -522,7 +522,7 @@ 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.module_utils.common.text.converters import to_native, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.io import (
write_file,

View File

@@ -202,7 +202,7 @@ import datetime
import time
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 (
ecs_client_argument_spec,

View File

@@ -14,11 +14,8 @@ author: "John Westcott IV (@john-westcott-iv)"
short_description: Get a certificate from a host:port
description:
- Makes a secure connection and returns information about the presented certificate
- "The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available. This can be
overridden with the I(select_crypto_backend) option. Please note that the PyOpenSSL
backend was deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0."
- Support SNI only with python >= 2.7
- The module uses the cryptography Python library.
- Support SNI (L(Server Name Indication,https://en.wikipedia.org/wiki/Server_Name_Indication)) only with python >= 2.7.
options:
host:
description:
@@ -35,6 +32,12 @@ options:
- The port to connect to
type: int
required: true
server_name:
description:
- Server name used for SNI (L(Server Name Indication,https://en.wikipedia.org/wiki/Server_Name_Indication)) when hostname
is an IP or is different from server name.
type: str
version_added: 1.4.0
proxy_host:
description:
- Proxy host used when get a certificate.
@@ -44,6 +47,14 @@ options:
- Proxy port used when get a certificate.
type: int
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:
description:
- The timeout in seconds
@@ -52,19 +63,18 @@ options:
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
type: str
default: auto
choices: [ auto, cryptography, pyopenssl ]
choices: [ auto, cryptography ]
notes:
- When using ca_cert on OS X it has been reported that in some conditions the validate will always succeed.
requirements:
- "python >= 2.7 when using C(proxy_host)"
- "cryptography >= 1.6 or pyOpenSSL >= 0.15"
- "cryptography >= 1.6"
'''
RETURN = '''
@@ -89,7 +99,13 @@ extensions:
asn1_data:
returned: success
type: str
description: The Base64 encoded ASN.1 content of the extnesion.
description:
- The Base64 encoded ASN.1 content of the extension.
- B(Note) that depending on the C(cryptography) version used, it is
not possible to extract the ASN.1 content of the extension, but only
to provide the re-encoded content of the extension in case it was
parsed by C(cryptography). This should usually result in exactly the
same value, except if the original extension value was malformed.
name:
returned: success
type: str
@@ -159,14 +175,13 @@ from socket import create_connection, setdefaulttimeout, socket
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._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 (
cryptography_oid_to_name,
cryptography_get_extensions_from_cert,
)
MINIMAL_PYOPENSSL_VERSION = '0.15'
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
CREATE_DEFAULT_CONTEXT_IMP_ERR = None
@@ -178,17 +193,6 @@ except ImportError:
else:
HAS_CREATE_DEFAULT_CONTEXT = True
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
@@ -203,6 +207,20 @@ else:
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():
module = AnsibleModule(
argument_spec=dict(
@@ -211,8 +229,10 @@ def main():
port=dict(type='int', required=True),
proxy_host=dict(type='str'),
proxy_port=dict(type='int', default=8080),
server_name=dict(type='str'),
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', 'cryptography'], default='auto'),
starttls=dict(type='str', choices=['mysql']),
),
)
@@ -222,33 +242,24 @@ def main():
proxy_host = module.params.get('proxy_host')
proxy_port = module.params.get('proxy_port')
timeout = module.params.get('timeout')
server_name = module.params.get('server_name')
start_tls_server_type = module.params.get('starttls')
backend = module.params.get('select_crypto_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)
# First try cryptography, then pyOpenSSL
# Try cryptography
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))
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_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')
elif backend == 'cryptography':
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)
@@ -297,7 +308,10 @@ def main():
ctx.check_hostname = False
ctx.verify_mode = CERT_NONE
cert = ctx.wrap_socket(sock, server_hostname=host).getpeercert(True)
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 = DER_cert_to_PEM_cert(cert)
except Exception as e:
if proxy_host:
@@ -308,37 +322,7 @@ def main():
result['cert'] = cert
if backend == 'pyopenssl':
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
result['subject'] = {}
for component in x509.get_subject().get_components():
result['subject'][component[0]] = component[1]
result['expired'] = x509.has_expired()
result['extensions'] = []
extension_count = x509.get_extension_count()
for index in range(0, extension_count):
extension = x509.get_extension(index)
result['extensions'].append({
'critical': extension.get_critical(),
'asn1_data': extension.get_data(),
'name': extension.get_short_name(),
})
result['issuer'] = {}
for component in x509.get_issuer().get_components():
result['issuer'][component[0]] = component[1]
result['not_after'] = x509.get_notAfter()
result['not_before'] = x509.get_notBefore()
result['serial_number'] = x509.get_serial_number()
result['signature_algorithm'] = x509.get_signature_algorithm()
result['version'] = x509.get_version()
elif backend == 'cryptography':
if backend == 'cryptography':
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography_backend())
result['subject'] = {}
for attribute in x509.subject:

View File

@@ -81,9 +81,10 @@ options:
Needs I(keyfile) or I(passphrase) option for authorization.
LUKS container supports up to 8 keyslots. Parameter value
is the path to the keyfile with the passphrase."
- "NOTE that adding additional keys is *not idempotent*.
A new keyslot will be used even if another keyslot already
exists for this keyfile."
- "NOTE that adding additional keys is idempotent only since
community.crypto 1.4.0. For older versions, a new keyslot
will be used even if another keyslot already exists for this
keyfile."
- "BEWARE that working with keyfiles in plaintext is dangerous.
Make sure that they are protected."
type: path
@@ -93,9 +94,9 @@ options:
Needs I(keyfile) or I(passphrase) option for authorization. LUKS
container supports up to 8 keyslots. Parameter value is a string
with the new passphrase."
- "NOTE that adding additional passphrase is *not idempotent*. A
new keyslot will be used even if another keyslot already exists
for this passphrase."
- "NOTE that adding additional passphrase is idempotent only since
community.crypto 1.4.0. For older versions, a new keyslot will
be used even if another keyslot already exists for this passphrase."
type: str
version_added: '1.0.0'
remove_keyfile:
@@ -103,7 +104,8 @@ options:
- "Removes given key from the container on I(device). Does not
remove the keyfile from filesystem.
Parameter value is the path to the keyfile with the passphrase."
- "NOTE that removing keys is *not idempotent*. Trying to remove
- "NOTE that removing keys is idempotent only since
community.crypto 1.4.0. For older versions, trying to remove
a key which no longer exists results in an error."
- "NOTE that to remove the last key from a LUKS container, the
I(force_remove_last_key) option must be set to C(yes)."
@@ -114,9 +116,9 @@ options:
description:
- "Removes given passphrase from the container on I(device).
Parameter value is a string with the passphrase to remove."
- "NOTE that removing passphrases is I(not
idempotent). Trying to remove a passphrase which no longer
exists results in an error."
- "NOTE that removing passphrases is idempotent only since
community.crypto 1.4.0. For older versions, trying to remove
a passphrase which no longer exists results in an error."
- "NOTE that to remove the last keyslot from a LUKS
container, the I(force_remove_last_key) option must be set
to C(yes)."
@@ -168,6 +170,53 @@ options:
- "Will only be used on container creation."
type: str
version_added: '1.1.0'
pbkdf:
description:
- This option allows the user to configure the Password-Based Key Derivation
Function (PBKDF) used.
- Will only be used on container creation, and when adding keys to an existing
container.
type: dict
version_added: '1.4.0'
suboptions:
iteration_time:
description:
- Specify the iteration time used for the PBKDF.
- Note that this is in B(seconds), not in milliseconds as on the
command line.
- Mutually exclusive with I(iteration_count).
type: float
iteration_count:
description:
- Specify the iteration count used for the PBKDF.
- Mutually exclusive with I(iteration_time).
type: int
algorithm:
description:
- The algorithm to use.
- Only available for the LUKS 2 format.
choices:
- argon2i
- argon2id
- pbkdf2
type: str
memory:
description:
- The memory cost limit in kilobytes for the PBKDF.
- This is not used for PBKDF2, but only for the Argon PBKDFs.
type: int
parallel:
description:
- The parallel cost for the PBKDF. This is the number of threads that
run in parallel.
- This is not used for PBKDF2, but only for the Argon PBKDFs.
type: int
sector_size:
description:
- "This option allows the user to specify the sector size (in bytes) used for LUKS2 containers."
- "Will only be used on container creation."
type: int
version_added: '1.5.0'
requirements:
- "cryptsetup"
@@ -307,6 +356,34 @@ LUKS_NAME_REGEX = re.compile(r'\s*crypt\s+([^\s]*)\s*')
LUKS_DEVICE_REGEX = re.compile(r'\s*device:\s+([^\s]*)\s*')
# See https://gitlab.com/cryptsetup/cryptsetup/-/wikis/LUKS-standard/on-disk-format.pdf
LUKS_HEADER = b'LUKS\xba\xbe'
LUKS_HEADER_L = 6
# See https://gitlab.com/cryptsetup/LUKS2-docs/-/blob/master/luks2_doc_wip.pdf
LUKS2_HEADER_OFFSETS = [0x4000, 0x8000, 0x10000, 0x20000, 0x40000, 0x80000, 0x100000, 0x200000, 0x400000]
LUKS2_HEADER2 = b'SKUL\xba\xbe'
def wipe_luks_headers(device):
wipe_offsets = []
with open(device, 'rb') as f:
# f.seek(0)
data = f.read(LUKS_HEADER_L)
if data == LUKS_HEADER:
wipe_offsets.append(0)
for offset in LUKS2_HEADER_OFFSETS:
f.seek(offset)
data = f.read(LUKS_HEADER_L)
if data == LUKS2_HEADER2:
wipe_offsets.append(offset)
if wipe_offsets:
with open(device, 'wb') as f:
for offset in wipe_offsets:
f.seek(offset)
f.write(b'\x00\x00\x00\x00\x00\x00')
class Handler(object):
def __init__(self, module):
@@ -397,7 +474,19 @@ class CryptHandler(Handler):
result = self._run_command([self._cryptsetup_bin, 'isLuks', device])
return result[RETURN_CODE] == 0
def run_luks_create(self, device, keyfile, passphrase, keysize, cipher, hash_):
def _add_pbkdf_options(self, options, pbkdf):
if pbkdf['iteration_time'] is not None:
options.extend(['--iter-time', str(int(pbkdf['iteration_time'] * 1000))])
if pbkdf['iteration_count'] is not None:
options.extend(['--pbkdf-force-iterations', str(pbkdf['iteration_count'])])
if pbkdf['algorithm'] is not None:
options.extend(['--pbkdf', pbkdf['algorithm']])
if pbkdf['memory'] is not None:
options.extend(['--pbkdf-memory', str(pbkdf['memory'])])
if pbkdf['parallel'] is not None:
options.extend(['--pbkdf-parallel', str(pbkdf['parallel'])])
def run_luks_create(self, device, keyfile, passphrase, keysize, cipher, hash_, sector_size, pbkdf):
# create a new luks container; use batch mode to auto confirm
luks_type = self._module.params['type']
label = self._module.params['label']
@@ -414,6 +503,10 @@ class CryptHandler(Handler):
options.extend(['--cipher', cipher])
if hash_ is not None:
options.extend(['--hash', hash_])
if pbkdf is not None:
self._add_pbkdf_options(options, pbkdf)
if sector_size is not None:
options.extend(['--sector-size', str(sector_size)])
args = [self._cryptsetup_bin, 'luksFormat']
args.extend(options)
@@ -450,17 +543,27 @@ class CryptHandler(Handler):
self.run_luks_close(name)
result = self._run_command([wipefs_bin, '--all', device])
if result[RETURN_CODE] != 0:
raise ValueError('Error while wiping luks container %s: %s'
raise ValueError('Error while wiping LUKS container signatures for %s: %s'
% (device, result[STDERR]))
# For LUKS2, sometimes both `cryptsetup erase` and `wipefs` do **not**
# erase all LUKS signatures (they seem to miss the second header). That's
# why we do it ourselves here.
try:
wipe_luks_headers(device)
except Exception as exc:
raise ValueError('Error while wiping LUKS container signatures for %s: %s' % (device, exc))
def run_luks_add_key(self, device, keyfile, passphrase, new_keyfile,
new_passphrase):
new_passphrase, pbkdf):
''' Add new key from a keyfile or passphrase to given 'device';
authentication done using 'keyfile' or 'passphrase'.
Raises ValueError when command fails.
'''
data = []
args = [self._cryptsetup_bin, 'luksAddKey', device]
if pbkdf is not None:
self._add_pbkdf_options(args, pbkdf)
if keyfile:
args.extend(['--key-file', keyfile])
@@ -520,6 +623,28 @@ class CryptHandler(Handler):
raise ValueError('Error while removing LUKS key from %s: %s'
% (device, result[STDERR]))
def luks_test_key(self, device, keyfile, passphrase):
''' Check whether the keyfile or passphrase works.
Raises ValueError when command fails.
'''
data = None
args = [self._cryptsetup_bin, 'luksOpen', '--test-passphrase', device]
if keyfile:
args.extend(['--key-file', keyfile])
else:
data = passphrase
result = self._run_command(args, data=data)
if result[RETURN_CODE] == 0:
return True
for output in (STDOUT, STDERR):
if 'No key available with this passphrase' in result[output]:
return False
raise ValueError('Error while testing whether keyslot exists on %s: %s'
% (device, result[STDERR]))
class ConditionsHandler(Handler):
@@ -627,7 +752,7 @@ class ConditionsHandler(Handler):
self._module.fail_json(msg="Contradiction in setup: Asking to "
"add a key to absent LUKS.")
return True
return not self._crypthandler.luks_test_key(self.device, self._module.params['new_keyfile'], self._module.params['new_passphrase'])
def luks_remove_key(self):
if (self.device is None or
@@ -640,7 +765,7 @@ class ConditionsHandler(Handler):
self._module.fail_json(msg="Contradiction in setup: Asking to "
"remove a key from absent LUKS.")
return True
return self._crypthandler.luks_test_key(self.device, self._module.params['remove_keyfile'], self._module.params['remove_passphrase'])
def luks_remove(self):
return (self.device is not None and
@@ -667,6 +792,18 @@ def run_module():
type=dict(type='str', choices=['luks1', 'luks2']),
cipher=dict(type='str'),
hash=dict(type='str'),
pbkdf=dict(
type='dict',
options=dict(
iteration_time=dict(type='float'),
iteration_count=dict(type='int'),
algorithm=dict(type='str', choices=['argon2i', 'argon2id', 'pbkdf2']),
memory=dict(type='int'),
parallel=dict(type='int'),
),
mutually_exclusive=[('iteration_time', 'iteration_count')],
),
sector_size=dict(type='int'),
)
mutually_exclusive = [
@@ -714,6 +851,8 @@ def run_module():
module.params['keysize'],
module.params['cipher'],
module.params['hash'],
module.params['sector_size'],
module.params['pbkdf'],
)
except ValueError as e:
module.fail_json(msg="luks_device error: %s" % e)
@@ -775,7 +914,8 @@ def run_module():
module.params['keyfile'],
module.params['passphrase'],
module.params['new_keyfile'],
module.params['new_passphrase'])
module.params['new_passphrase'],
module.params['pbkdf'])
except ValueError as e:
module.fail_json(msg="luks_device error: %s" % e)
result['changed'] = True

View File

@@ -20,7 +20,8 @@ requirements:
options:
state:
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
default: "present"
choices: [ 'present', 'absent' ]
@@ -33,6 +34,7 @@ options:
force:
description:
- Should the certificate be regenerated even if it already exists and is valid.
- Equivalent to I(regenerate=always).
type: bool
default: false
path:
@@ -40,6 +42,46 @@ options:
- Path of the file containing the certificate.
type: path
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:
description:
- The path to the private openssh key that is used for signing the public key in order to generate the certificate.
@@ -100,7 +142,7 @@ options:
command specified by the user when the certificate is used for authentication."
- "C(no-agent-forwarding): Disable ssh-agent forwarding (permitted by default)."
- "C(no-port-forwarding): Disable port forwarding (permitted by default)."
- "C(no-pty Disable): PTY allocation (permitted by default)."
- "C(no-pty): Disable PTY allocation (permitted by default)."
- "C(no-user-rc): Disable execution of C(~/.ssh/rc) by sshd (permitted by default)."
- "C(no-x11-forwarding): Disable X11 forwarding (permitted by default)"
- "C(permit-agent-forwarding): Allows ssh-agent forwarding."
@@ -215,421 +257,292 @@ info:
'''
import errno
import os
import re
import tempfile
from datetime import datetime
from datetime import MINYEAR, MAXYEAR
from distutils.version import LooseVersion
from shutil import copy2, rmtree
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.crypto.openssh import parse_openssh_version
from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.common import (
KeygenCommand,
OpensshModule,
PrivateKey,
)
from ansible_collections.community.crypto.plugins.module_utils.openssh.certificate import (
OpensshCertificate,
OpensshCertificateTimeParameters,
parse_option_list,
)
class CertificateError(Exception):
pass
class Certificate(object):
class Certificate(OpensshModule):
def __init__(self, module):
self.state = module.params['state']
self.force = module.params['force']
self.type = module.params['type']
self.signing_key = module.params['signing_key']
self.use_agent = module.params['use_agent']
self.pkcs11_provider = module.params['pkcs11_provider']
self.public_key = module.params['public_key']
self.path = module.params['path']
self.identifier = module.params['identifier']
self.serial_number = module.params['serial_number']
self.valid_from = module.params['valid_from']
self.valid_to = module.params['valid_to']
self.valid_at = module.params['valid_at']
self.principals = module.params['principals']
self.options = module.params['options']
self.changed = False
self.check_mode = module.check_mode
self.cert_info = {}
super(Certificate, self).__init__(module)
self.ssh_keygen = KeygenCommand(self.module)
self.identifier = self.module.params['identifier'] or ""
self.options = self.module.params['options'] or []
self.path = self.module.params['path']
self.pkcs11_provider = self.module.params['pkcs11_provider']
self.principals = self.module.params['principals'] or []
self.public_key = self.module.params['public_key']
self.regenerate = self.module.params['regenerate'] if not self.module.params['force'] else 'always'
self.serial_number = self.module.params['serial_number']
self.signature_algorithm = self.module.params['signature_algorithm']
self.signing_key = self.module.params['signing_key']
self.state = self.module.params['state']
self.type = self.module.params['type']
self.use_agent = self.module.params['use_agent']
self.valid_at = self.module.params['valid_at']
self._check_if_base_dir(self.path)
if self.state == 'present':
self._validate_parameters()
if self.options and self.type == "host":
module.fail_json(msg="Options can only be used with user certificates.")
self.data = None
self.original_data = None
if self._exists():
self._load_certificate()
if self.valid_at:
self.valid_at = self.valid_at.lstrip()
self.time_parameters = None
if self.state == 'present':
self._set_time_parameters()
self.valid_from = self.valid_from.lstrip()
self.valid_to = self.valid_to.lstrip()
def _validate_parameters(self):
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:
args = [
self.ssh_keygen,
'-s', self.signing_key
]
def _use_agent_available(self):
ssh_version = self._get_ssh_version()
if not ssh_version:
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:
args.extend(['-D', self.pkcs11_provider])
def _exists(self):
return os.path.exists(self.path)
if self.use_agent:
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
def _load_certificate(self):
try:
return (datetime_one - datetime_two).total_seconds() == 0.0
except AttributeError:
return timedelta_total_seconds(datetime_one - datetime_two) == 0.0
self.original_data = OpensshCertificate.load(self.path)
except (TypeError, ValueError) as e:
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 _check_state():
return os.path.exists(self.path)
if _check_state():
proc = module.run_command([self.ssh_keygen, '-L', '-f', self.path], environ_update=dict(TZ="UTC"), check_rc=False)
if proc[0] != 0:
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 _set_time_parameters(self):
try:
self.time_parameters = OpensshCertificateTimeParameters(
valid_from=self.module.params['valid_from'],
valid_to=self.module.params['valid_to'],
)
except ValueError as e:
self.module.fail_json(msg=to_native(e))
def _execute(self):
if self.state == 'present':
result = {
'changed': self.changed,
'type': self.type,
'filename': self.path,
'info': format_cert_info(),
}
if self._should_generate():
self._generate()
self._update_permissions(self.path)
else:
result = {
'changed': self.changed,
}
if self._exists():
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):
"""Remove the resource from the filesystem."""
def _is_fully_valid(self):
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:
os.remove(self.path)
self.changed = True
except OSError as exc:
if exc.errno != errno.ENOENT:
raise CertificateError(exc)
else:
pass
except OSError as e:
self.module.fail_json(msg="Unable to remove existing certificate: %s" % to_native(e))
@property
def _result(self):
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():
module = AnsibleModule(
argument_spec=dict(
state=dict(type='str', default='present', choices=['absent', 'present']),
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'),
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'),
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_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,
add_file_common_args=True,
required_if=[('state', 'present', ['type', 'signing_key', 'public_key', 'valid_from', 'valid_to'])],
)
if module.params['use_agent']:
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)
Certificate(module).execute()
if __name__ == '__main__':

View File

@@ -7,18 +7,19 @@
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = '''
---
module: openssh_keypair
author: "David Kainz (@lolcube)"
short_description: Generate OpenSSH private and public keys.
short_description: Generate OpenSSH private and public keys
description:
- "This module allows one to (re)generate OpenSSH private and public keys. It uses
ssh-keygen to generate keys. One can generate C(rsa), C(dsa), C(rsa1), C(ed25519)
or C(ecdsa) private keys."
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:
state:
description:
@@ -55,11 +56,40 @@ options:
description:
- Provides a new comment to the public key.
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:
description:
- Allows to configure in which situations the module is allowed to regenerate private keys.
The module will always generate a new key if the destination file does not exist.
- By default, the key will be regenerated when it doesn't match the module's options,
- By default, the key will be regenerated when it does not match the module's options,
except when the key cannot be read or the passphrase does not match. Please note that
this B(changed) for Ansible 2.10. For Ansible 2.9, the behavior was as if C(full_idempotence)
is specified.
@@ -91,6 +121,8 @@ options:
notes:
- 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.
- 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
'''
@@ -100,6 +132,11 @@ EXAMPLES = '''
community.crypto.openssh_keypair:
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)
community.crypto.openssh_keypair:
path: /tmp/id_ssh_rsa
@@ -118,17 +155,17 @@ EXAMPLES = '''
RETURN = '''
size:
description: Size (in bits) of the SSH private key
description: Size (in bits) of the SSH private key.
returned: changed or success
type: int
sample: 4096
type:
description: Algorithm used to generate the SSH private key
description: Algorithm used to generate the SSH private key.
returned: changed or success
type: str
sample: rsa
filename:
description: Path to the generated SSH private key file
description: Path to the generated SSH private key file.
returned: changed or success
type: str
sample: /tmp/id_ssh_rsa
@@ -138,292 +175,26 @@ fingerprint:
type: str
sample: SHA256:r4YCZxihVjedH2OlfjVGI6Y5xAYtdCwk8VxKyzVyYfM
public_key:
description: The public key of the generated SSH private key
description: The public key of the generated SSH private key.
returned: changed or success
type: str
sample: ssh-rsa AAAAB3Nza(...omitted...)veL4E3Xcw== test_key
comment:
description: The comment of the generated key
description: The comment of the generated key.
returned: changed or success
type: str
sample: test@comment
'''
import errno
import os
import stat
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils._text import to_native
class KeypairError(Exception):
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
from ansible_collections.community.crypto.plugins.module_utils.openssh.backends.keypair_backend import (
select_backend
)
def main():
# Define Ansible Module
module = AnsibleModule(
argument_spec=dict(
state=dict(type='str', default='present', choices=['present', 'absent']),
@@ -437,49 +208,17 @@ def main():
default='partial_idempotence',
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,
add_file_common_args=True,
)
# Check if Path exists
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 = select_backend(module, module.params['backend'])[1]
keypair = Keypair(module)
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)
keypair.execute()
if __name__ == '__main__':

View File

@@ -1 +0,0 @@
x509_certificate.py

View File

@@ -1 +0,0 @@
x509_certificate_info.py

View File

@@ -142,6 +142,21 @@ EXAMPLES = r'''
extended_key_usage:
- clientAuth
subject_alt_name: otherName:1.3.6.1.4.1.311.20.2.3;UTF8:username@localhost
- name: Generate an OpenSSL Certificate Signing Request with a CRL distribution point
community.crypto.openssl_csr:
path: /etc/ssl/csr/www.ansible.com.csr
privatekey_path: /etc/ssl/private/ansible.com.pem
common_name: www.ansible.com
crl_distribution_points:
- full_name:
- "URI:https://ca.example.com/revocations.crl"
crl_issuer:
- "URI:https://ca.example.com/"
reasons:
- key_compromise
- ca_compromise
- cessation_of_operation
'''
RETURN = r'''
@@ -221,7 +236,7 @@ csr:
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 (
select_backend,
@@ -271,7 +286,10 @@ class CertificateSigningRequestModule(OpenSSLObject):
self.changed = True
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):
self.module_backend.set_existing(None)

View File

@@ -17,13 +17,9 @@ description:
- This module allows one to query information on OpenSSL Certificate Signing Requests (CSR).
- In case the CSR signature cannot be validated, the module will fail. In this case, all return
variables are still returned.
- 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.
- It uses the cryptography python library to interact with OpenSSL.
requirements:
- PyOpenSSL >= 0.15 or cryptography >= 1.3
- cryptography >= 1.3
author:
- Felix Fontein (@felixfontein)
- Yanis Guenane (@Spredzy)
@@ -42,14 +38,11 @@ options:
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- 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 ]
choices: [ auto, cryptography ]
seealso:
- module: community.crypto.openssl_csr
@@ -110,7 +103,13 @@ extensions_by_oid:
returned: success
type: bool
value:
description: The Base64 encoded value (in DER format) of the extension
description:
- The Base64 encoded value (in DER format) of the extension.
- B(Note) that depending on the C(cryptography) version used, it is
not possible to extract the ASN.1 content of the extension, but only
to provide the re-encoded content of the extension in case it was
parsed by C(cryptography). This should usually result in exactly the
same value, except if the original extension value was malformed.
returned: success
type: str
sample: "MAMCAQU="
@@ -183,6 +182,77 @@ public_key:
returned: success
type: str
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:
description:
- Fingerprints of CSR's public key.
@@ -196,7 +266,7 @@ subject_key_identifier:
- The CSR's subject key identifier.
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
- Is C(none) if the C(SubjectKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: str
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
authority_key_identifier:
@@ -204,14 +274,14 @@ authority_key_identifier:
- The CSR's authority key identifier.
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: str
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
authority_cert_issuer:
description:
- The CSR's authority cert issuer as a list of general names.
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: list
elements: str
sample: "[DNS:www.ansible.com, IP:1.2.3.4]"
@@ -219,441 +289,30 @@ authority_cert_serial_number:
description:
- The CSR's authority cert serial number.
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: int
sample: '12345'
'''
import abc
import binascii
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.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.support import (
OpenSSLObject,
load_certificate_request,
get_fingerprint_of_bytes,
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import (
select_backend,
)
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():
module = AnsibleModule(
argument_spec=dict(
path=dict(type='path'),
content=dict(type='str'),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']),
),
required_one_of=(
['path', 'content'],
@@ -664,53 +323,19 @@ def main():
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:
if module.params['path'] is not None:
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()
result = module_backend.get_info()
module.exit_json(**result)
except OpenSSLObjectError as exc:
module.fail_json(msg=to_native(exc))

View File

@@ -115,7 +115,7 @@ csr:
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 (
select_backend,

View File

@@ -15,7 +15,7 @@ short_description: Generate OpenSSL Diffie-Hellman Parameters
description:
- This module allows one to (re)generate OpenSSL DH-params.
- This module uses file common arguments to specify generated file permissions.
- "Please note that the module regenerates existing DH params if they don't
- "Please note that the module regenerates existing DH params if they do not
match the module's options. If you are concerned that this could overwrite
your existing DH params, consider using the I(backup) option."
- The module can use the cryptography Python library, or the C(openssl) executable.
@@ -71,6 +71,8 @@ options:
type: bool
default: no
version_added: "1.0.0"
notes:
- Supports C(check_mode).
extends_documentation_fragment:
- files
seealso:
@@ -129,7 +131,7 @@ 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
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.io import (
load_file_if_exists,
@@ -219,9 +221,9 @@ class DHParameterBase(object):
def _check_fs_attributes(self, module):
"""Checks (and changes if not in check mode!) fs attributes"""
file_args = module.load_file_common_arguments(module.params)
attrs_changed = module.set_fs_attributes_if_different(file_args, False)
return not attrs_changed
if module.check_file_absent_if_check_mode(file_args['path']):
return False
return not module.set_fs_attributes_if_different(file_args, False)
def dump(self):
"""Serialize the object into a dictionary."""

View File

@@ -16,8 +16,14 @@ author:
short_description: Generate OpenSSL PKCS#12 archive
description:
- 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:
- python-pyOpenSSL
- PyOpenSSL >= 0.15 or cryptography >= 3.0
options:
action:
description:
@@ -27,10 +33,19 @@ options:
choices: [ export, parse ]
other_certificates:
description:
- List of other certificates to include. Pre 2.8 this parameter was called C(ca_certificates)
- List of other certificates to include. Pre Ansible 2.8 this parameter was called I(ca_certificates).
- Assumes there is one PEM-encoded certificate per file. If a file contains multiple PEM certificates,
set I(other_certificates_parse_all) to C(true).
type: list
elements: path
aliases: [ ca_certificates ]
other_certificates_parse_all:
description:
- If set to C(true), assumes that the files mentioned in I(other_certificates) can contain more than one
certificate per file (or even none per file).
type: bool
default: false
version_added: 1.4.0
certificate_path:
description:
- The path to read certificates and private keys from.
@@ -49,16 +64,21 @@ options:
iter_size:
description:
- 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
default: 2048
maciter_size:
description:
- 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
default: 1
passphrase:
description:
- 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
path:
description:
@@ -96,6 +116,21 @@ options:
type: bool
default: no
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:
- files
seealso:
@@ -115,6 +150,27 @@ EXAMPLES = r'''
privatekey_path: /opt/certs/keys/key.pem
certificate_path: /opt/certs/cert.pem
other_certificates: /opt/certs/ca.pem
# Note that if /opt/certs/ca.pem contains multiple certificates,
# only the first one will be used. See the other_certificates_parse_all
# option for changing this behavior.
state: present
- name: Generate PKCS#12 file
community.crypto.openssl_pkcs12:
action: export
path: /opt/certs/ansible.p12
friendly_name: raclette
privatekey_path: /opt/certs/keys/key.pem
certificate_path: /opt/certs/cert.pem
other_certificates_parse_all: true
other_certificates:
- /opt/certs/ca_bundle.pem
# Since we set other_certificates_parse_all to true, all
# certificates in the CA bundle are included and not just
# the first one.
- /opt/certs/intermediate.pem
# In case this file has multiple certificates in it,
# all will be included as well.
state: present
- name: Change PKCS#12 file permission
@@ -177,13 +233,16 @@ pkcs12:
version_added: "1.0.0"
'''
import abc
import base64
import os
import stat
import traceback
from distutils.version import LooseVersion
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 (
load_file_if_exists,
@@ -195,20 +254,54 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.basic impo
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 (
OpenSSLObject,
load_privatekey,
load_certificate,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
split_pem_list,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '3.0'
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
PYOPENSSL_FOUND = False
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, backend):
'''
Load list of concatenated PEM files, and return a list of parsed certificates.
'''
with open(filename, 'rb') as f:
data = f.read().decode('utf-8')
return [load_certificate(None, content=cert.encode('utf-8'), backend=backend) for cert in split_pem_list(data)]
class PkcsError(OpenSSLObjectError):
@@ -216,20 +309,21 @@ class PkcsError(OpenSSLObjectError):
class Pkcs(OpenSSLObject):
def __init__(self, module):
def __init__(self, module, backend):
super(Pkcs, self).__init__(
module.params['path'],
module.params['state'],
module.params['force'],
module.check_mode
)
self.backend = backend
self.action = module.params['action']
self.other_certificates = module.params['other_certificates']
self.other_certificates_parse_all = module.params['other_certificates_parse_all']
self.certificate_path = module.params['certificate_path']
self.friendly_name = module.params['friendly_name']
self.iter_size = module.params['iter_size']
self.maciter_size = module.params['maciter_size']
self.iter_size = module.params['iter_size'] or 2048
self.maciter_size = module.params['maciter_size'] or 1
self.passphrase = module.params['passphrase']
self.pkcs12 = None
self.privatekey_passphrase = module.params['privatekey_passphrase']
@@ -244,6 +338,42 @@ class Pkcs(OpenSSLObject):
self.backup = module.params['backup']
self.backup_file = None
if self.other_certificates:
if self.other_certificates_parse_all:
filenames = list(self.other_certificates)
self.other_certificates = []
for other_cert_bundle in filenames:
self.other_certificates.extend(load_certificate_set(other_cert_bundle, self.backend))
else:
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):
"""Ensure the resource is in its desired state."""
@@ -252,10 +382,8 @@ class Pkcs(OpenSSLObject):
def _check_pkey_passphrase():
if self.privatekey_passphrase:
try:
load_privatekey(self.privatekey_path, self.privatekey_passphrase)
except crypto.Error:
return False
except OpenSSLBadPassphraseError:
load_privatekey(self.privatekey_path, self.privatekey_passphrase, backend=self.backend)
except OpenSSLObjectError:
return False
return True
@@ -263,32 +391,28 @@ class Pkcs(OpenSSLObject):
return state_and_perms
if os.path.exists(self.path) and module.params['action'] == 'export':
dummy = self.generate(module)
dummy = self.generate_bytes(module)
self.src = self.path
try:
pkcs12_privatekey, pkcs12_certificate, pkcs12_other_certificates, pkcs12_friendly_name = self.parse()
except crypto.Error:
except OpenSSLObjectError:
return False
if (pkcs12_privatekey is not None) and (self.privatekey_path is not None):
expected_pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM,
self.pkcs12.get_privatekey())
expected_pkey = self._dump_privatekey(self.pkcs12)
if pkcs12_privatekey != expected_pkey:
return False
elif bool(pkcs12_privatekey) != bool(self.privatekey_path):
return False
if (pkcs12_certificate is not None) and (self.certificate_path is not None):
expected_cert = crypto.dump_certificate(crypto.FILETYPE_PEM,
self.pkcs12.get_certificate())
expected_cert = self._dump_certificate(self.pkcs12)
if pkcs12_certificate != expected_cert:
return False
elif bool(pkcs12_certificate) != bool(self.certificate_path):
return False
if (pkcs12_other_certificates is not None) and (self.other_certificates is not None):
expected_other_certs = [crypto.dump_certificate(crypto.FILETYPE_PEM,
other_cert) for other_cert in self.pkcs12.get_ca_certificates()]
expected_other_certs = self._dump_other_certificates(self.pkcs12)
if set(pkcs12_other_certificates) != set(expected_other_certs):
return False
elif bool(pkcs12_other_certificates) != bool(self.other_certificates):
@@ -297,15 +421,16 @@ class Pkcs(OpenSSLObject):
if pkcs12_privatekey:
# This check is required because pyOpenSSL will not return a friendly name
# 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)):
if self.pkcs12.get_friendlyname() != pkcs12_friendly_name:
friendly_name = self._get_friendly_name(self.pkcs12)
if ((friendly_name is not None) and (pkcs12_friendly_name is not None)):
if friendly_name != pkcs12_friendly_name:
return False
elif bool(self.pkcs12.get_friendlyname()) != bool(pkcs12_friendly_name):
elif bool(friendly_name) != bool(pkcs12_friendly_name):
return False
elif module.params['action'] == 'parse' and os.path.exists(self.src) and os.path.exists(self.path):
try:
pkey, cert, other_certs, friendly_name = self.parse()
except crypto.Error:
except OpenSSLObjectError:
return False
expected_content = to_bytes(
''.join([to_native(pem) for pem in [pkey, cert] + other_certs if pem is not None])
@@ -335,29 +460,6 @@ class Pkcs(OpenSSLObject):
return result
def generate(self, module):
"""Generate PKCS#12 file archive."""
self.pkcs12 = crypto.PKCS12()
if self.other_certificates:
other_certs = [load_certificate(other_cert) for other_cert
in self.other_certificates]
self.pkcs12.set_ca_certificates(other_certs)
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):
if self.backup:
self.backup_file = module.backup_local(self.path)
@@ -369,8 +471,51 @@ class Pkcs(OpenSSLObject):
try:
with open(self.src, 'rb') as pkcs12_fh:
pkcs12_content = pkcs12_fh.read()
p12 = crypto.load_pkcs12(pkcs12_content,
self.passphrase)
return self.parse_bytes(pkcs12_content)
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()
if pkey is not None:
pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)
@@ -385,28 +530,155 @@ class Pkcs(OpenSSLObject):
friendly_name = p12.get_friendlyname()
return (pkey, crt, other_certs, friendly_name)
except IOError as exc:
except crypto.Error as exc:
raise PkcsError(exc)
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
def _dump_privatekey(self, pkcs12):
pk = pkcs12.get_privatekey()
return crypto.dump_privatekey(crypto.FILETYPE_PEM, pk) if pk else None
def _dump_certificate(self, pkcs12):
cert = pkcs12.get_certificate()
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():
argument_spec = dict(
action=dict(type='str', default='export', choices=['export', 'parse']),
other_certificates=dict(type='list', elements='path', aliases=['ca_certificates']),
other_certificates_parse_all=dict(type='bool', default=False),
certificate_path=dict(type='path'),
force=dict(type='bool', default=False),
friendly_name=dict(type='str', aliases=['name']),
iter_size=dict(type='int', default=2048),
maciter_size=dict(type='int', default=1),
iter_size=dict(type='int'),
maciter_size=dict(type='int'),
passphrase=dict(type='str', no_log=True),
path=dict(type='path', required=True),
privatekey_passphrase=dict(type='str', no_log=True),
@@ -415,6 +687,7 @@ def main():
src=dict(type='path'),
backup=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 = [
@@ -428,8 +701,7 @@ def main():
supports_check_mode=True,
)
if not pyopenssl_found:
module.fail_json(msg=missing_required_lib('pyOpenSSL'), exception=PYOPENSSL_IMP_ERR)
backend, pkcs12 = select_backend(module, module.params['select_crypto_backend'])
base_dir = os.path.dirname(module.params['path']) or '.'
if not os.path.isdir(base_dir):
@@ -439,7 +711,6 @@ def main():
)
try:
pkcs12 = Pkcs(module)
changed = False
if module.params['state'] == 'present':
@@ -452,7 +723,7 @@ def main():
if module.params['action'] == 'export':
if not module.params['friendly_name']:
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)
changed = True
else:
@@ -462,7 +733,9 @@ def main():
changed = True
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
else:
if module.check_mode:

View File

@@ -14,6 +14,7 @@ module: openssl_privatekey
short_description: Generate OpenSSL private keys
description:
- 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:
- Yanis Guenane (@Spredzy)
- Felix Fontein (@felixfontein)
@@ -116,7 +117,6 @@ filename:
fingerprint:
description:
- The fingerprint of the public key. Fingerprint will be generated for each C(hashlib.algorithms) available.
- The PyOpenSSL backend requires PyOpenSSL >= 16.0 for meaningful output.
returned: changed or success
type: dict
sample:
@@ -143,7 +143,7 @@ privatekey:
import os
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 (
load_file_if_exists,
@@ -213,7 +213,10 @@ class PrivateKeyModule(OpenSSLObject):
self.changed = True
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):
self.module_backend.set_existing(None)

View File

@@ -19,13 +19,9 @@ description:
private key. In this case, all return variables are still returned. Note that key consistency
checks are not available all key types; if none is available, C(none) is returned for
C(key_is_consistent).
- 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.
- It uses the cryptography python library to interact with OpenSSL.
requirements:
- PyOpenSSL >= 0.15 or cryptography >= 1.2.3
- cryptography >= 1.2.3
author:
- Felix Fontein (@felixfontein)
- Yanis Guenane (@Spredzy)
@@ -49,21 +45,32 @@ options:
- Whether to return private key data.
- Only set this to C(yes) when you want private information about this key to
leave the remote machine.
- "WARNING: you have to make sure that private key data isn't accidentally logged!"
- "B(WARNING:) you have to make sure that private key data isn't accidentally logged!"
type: bool
default: no
check_consistency:
description:
- Whether to check consistency of the private key.
- In community.crypto < 2.0.0, consistency was always checked.
- Since community.crypto 2.0.0, the consistency check has been disabled by default to
avoid private key material to be transported around and computed with, and only do
so when requested explicitly. This can potentially prevent
L(side-channel attacks,https://en.wikipedia.org/wiki/Side-channel_attack).
type: bool
default: false
version_added: 2.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 set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- 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 ]
choices: [ auto, cryptography ]
notes:
- Supports C(check_mode).
seealso:
- module: community.crypto.openssl_privatekey
@@ -81,28 +88,28 @@ EXAMPLES = r'''
register: result
- name: Dump information
debug:
ansible.builtin.debug:
var: result
'''
RETURN = r'''
can_load_key:
description: Whether the module was able to load the private key from disk
description: Whether the module was able to load the private key from disk.
returned: always
type: bool
can_parse_key:
description: Whether the module was able to parse the private key
description: Whether the module was able to parse the private key.
returned: always
type: bool
key_is_consistent:
description:
- Whether the key is consistent. Can also return C(none) next to C(yes) and
C(no), to indicate that consistency couldn't be checked.
C(no), to indicate that consistency could not be checked.
- In case the check returns C(no), the module will fail.
returned: always
returned: when I(check_consistency=true)
type: bool
public_key:
description: Private key's public key in PEM format
description: Private key's public key in PEM format.
returned: success
type: str
sample: "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A..."
@@ -127,6 +134,62 @@ public_data:
- 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)
private_data:
description:
- Private key data. Depends on key type.
@@ -135,450 +198,19 @@ private_data:
'''
import abc
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_bytes
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 (
CRYPTOGRAPHY_HAS_X25519,
CRYPTOGRAPHY_HAS_X448,
CRYPTOGRAPHY_HAS_ED25519,
CRYPTOGRAPHY_HAS_ED448,
OpenSSLObjectError,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
OpenSSLObject,
load_privatekey,
get_fingerprint_of_bytes,
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.privatekey_info import (
PrivateKeyConsistencyError,
PrivateKeyParseError,
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():
module = AnsibleModule(
@@ -587,7 +219,8 @@ def main():
content=dict(type='str', no_log=True),
passphrase=dict(type='str', no_log=True),
return_private_key_data=dict(type='bool', default=False),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
check_consistency=dict(type='bool', default=False),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']),
),
required_one_of=(
['path', 'content'],
@@ -598,49 +231,40 @@ def main():
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'],
check_consistency=module.params['check_consistency'])
try:
if module.params['path'] is not None:
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()
result.update(module_backend.get_info())
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:
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!
- name: Update encrypted key when openssl_privatekey_pipe reported a change
community.sops.encrypt_sops:
community.sops.sops_encrypt:
path: private_key.pem.sops
content_text: "{{ output.privatekey }}"
when: output is changed
@@ -97,7 +97,6 @@ curve:
fingerprint:
description:
- The fingerprint of the public key. Fingerprint will be generated for each C(hashlib.algorithms) available.
- The PyOpenSSL backend requires PyOpenSSL >= 16.0 for meaningful output.
returned: changed or success
type: dict
sample:

View File

@@ -15,14 +15,9 @@ short_description: Generate an OpenSSL public key from its private key.
description:
- This module allows one to (re)generate OpenSSL public keys from their private keys.
- Keys are generated in PEM or OpenSSH format.
- "The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available. This can be
overridden with the I(select_crypto_backend) option. When I(format) is C(OpenSSH),
the C(cryptography) backend has to be used. Please note that the PyOpenSSL backend
was deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0."
- The module uses the cryptography Python library.
requirements:
- Either cryptography >= 1.2.3 (older versions might work as well)
- Or pyOpenSSL >= 16.0.0
- cryptography >= 1.2.3 (older versions might work as well)
- Needs cryptography >= 1.4 if I(format) is C(OpenSSH)
author:
- Yanis Guenane (@Spredzy)
@@ -76,12 +71,11 @@ options:
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
type: str
default: auto
choices: [ auto, cryptography, pyopenssl ]
choices: [ auto, cryptography ]
return_content:
description:
- If set to C(yes), will return the (current or generated) public key's content as I(publickey).
@@ -157,7 +151,6 @@ filename:
fingerprint:
description:
- The fingerprint of the public key. Fingerprint will be generated for each hashlib.algorithms available.
- Requires PyOpenSSL >= 16.0 for meaningful output.
returned: changed or success
type: dict
sample:
@@ -185,7 +178,7 @@ 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
from ansible.module_utils.common.text.converters import to_native
from ansible_collections.community.crypto.plugins.module_utils.io import (
load_file_if_exists,
@@ -203,21 +196,14 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
get_fingerprint,
)
MINIMAL_PYOPENSSL_VERSION = '16.0.0'
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
PublicKeyParseError,
get_publickey_info,
)
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH = '1.4'
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
@@ -244,6 +230,7 @@ class PublicKey(OpenSSLObject):
module.params['force'],
module.check_mode
)
self.module = module
self.format = module.params['format']
self.privatekey_path = module.params['privatekey_path']
self.privatekey_content = module.params['privatekey_content']
@@ -259,6 +246,23 @@ class PublicKey(OpenSSLObject):
self.backup = module.params['backup']
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):
self.privatekey = load_privatekey(
path=self.privatekey_path,
@@ -277,11 +281,6 @@ class PublicKey(OpenSSLObject):
crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
)
else:
try:
return crypto.dump_publickey(crypto.FILETYPE_PEM, self.privatekey)
except AttributeError as dummy:
raise PublicKeyError('You need to have PyOpenSSL>=16.0.0 to generate public keys')
def generate(self, module):
"""Generate the public key."""
@@ -294,6 +293,7 @@ class PublicKey(OpenSSLObject):
if not self.check(module, perms_required=False) or self.force:
try:
publickey_content = self._create_publickey(module)
self.diff_after = self._get_info(publickey_content)
if self.return_content:
self.publickey_bytes = publickey_content
@@ -314,7 +314,9 @@ class PublicKey(OpenSSLObject):
backend=self.backend,
)
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
def check(self, module, perms_required=True):
@@ -329,6 +331,7 @@ class PublicKey(OpenSSLObject):
try:
with open(self.path, 'rb') as public_key_fh:
publickey_content = public_key_fh.read()
self.diff_before = self.diff_after = self._get_info(publickey_content)
if self.return_content:
self.publickey_bytes = publickey_content
if self.backend == 'cryptography':
@@ -345,11 +348,6 @@ class PublicKey(OpenSSLObject):
crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.SubjectPublicKeyInfo
)
else:
publickey_content = crypto.dump_publickey(
crypto.FILETYPE_PEM,
crypto.load_publickey(crypto.FILETYPE_PEM, publickey_content)
)
except Exception as dummy:
return False
@@ -387,6 +385,11 @@ class PublicKey(OpenSSLObject):
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['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result
@@ -402,7 +405,7 @@ def main():
format=dict(type='str', default='PEM', choices=['OpenSSH', 'PEM']),
privatekey_passphrase=dict(type='str', no_log=True),
backup=dict(type='bool', default=False),
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'),
return_content=dict(type='bool', default=False),
),
supports_check_mode=True,
@@ -421,36 +424,20 @@ def main():
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)
# Decision
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
if module.params['format'] == 'OpenSSH':
module.fail_json(
msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH)),
exception=CRYPTOGRAPHY_IMP_ERR
)
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))
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(minimal_cryptography_version))
if module.params['format'] == 'OpenSSH' and backend != 'cryptography':
module.fail_json(msg="Format OpenSSH requires the cryptography backend.")
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')
elif backend == 'cryptography':
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(minimal_cryptography_version)),
exception=CRYPTOGRAPHY_IMP_ERR)

View File

@@ -0,0 +1,212 @@
#!/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 cryptography python library to interact with OpenSSL.
version_added: 1.7.0
requirements:
- 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.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
type: str
default: auto
choices: [ auto, cryptography ]
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_publickey:
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']),
),
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

@@ -15,13 +15,9 @@ version_added: 1.1.0
short_description: Sign data with openssl
description:
- This module allows one to sign data using a private key.
- The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available. This can be
overridden with the I(select_crypto_backend) option. Please note that the PyOpenSSL backend
was deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0.
- The module uses the cryptography Python library.
requirements:
- Either cryptography >= 1.4 (some key types require newer versions)
- Or pyOpenSSL >= 0.11 (Ed25519 and Ed448 keys are not supported with this backend)
- cryptography >= 1.4 (some key types require newer versions)
author:
- Patrick Pichler (@aveexy)
- Markus Teufelberger (@MarkusTeufelberger)
@@ -50,12 +46,11 @@ options:
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
type: str
default: auto
choices: [ auto, cryptography, pyopenssl ]
choices: [ auto, cryptography ]
notes:
- |
When using the C(cryptography) backend, the following key types require at least the following C(cryptography) version:
@@ -99,20 +94,8 @@ import traceback
from distutils.version import LooseVersion
import base64
MINIMAL_PYOPENSSL_VERSION = '0.11'
MINIMAL_CRYPTOGRAPHY_VERSION = '1.4'
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
@@ -139,7 +122,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
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
@@ -170,34 +153,6 @@ class SignatureBase(OpenSSLObject):
pass
# Implementation with using pyOpenSSL
class SignaturePyOpenSSL(SignatureBase):
def __init__(self, module, backend):
super(SignaturePyOpenSSL, self).__init__(module, backend)
def run(self):
result = dict()
try:
with open(self.path, "rb") as f:
_in = f.read()
private_key = load_privatekey(
path=self.privatekey_path,
content=self.privatekey_content,
passphrase=self.privatekey_passphrase,
backend=self.backend,
)
signature = OpenSSL.crypto.sign(private_key, _in, "sha256")
result['signature'] = base64.b64encode(signature)
return result
except Exception as e:
raise OpenSSLObjectError(e)
# Implementation with using cryptography
class SignatureCryptography(SignatureBase):
@@ -262,7 +217,7 @@ def main():
privatekey_content=dict(type='str', no_log=True),
privatekey_passphrase=dict(type='str', no_log=True),
path=dict(type='path', required=True),
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'),
),
mutually_exclusive=(
['privatekey_path', 'privatekey_content'],
@@ -283,29 +238,17 @@ def main():
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)
# Decision
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))
module.fail_json(msg=("Can't detect the required Python library "
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
try:
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')
_sign = SignaturePyOpenSSL(module, backend)
elif backend == 'cryptography':
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)

View File

@@ -14,14 +14,10 @@ module: openssl_signature_info
version_added: 1.1.0
short_description: Verify signatures with openssl
description:
- This module allows one to verify a signature for a file via a certificate.
- The module can use the cryptography Python library, or the pyOpenSSL Python
library. By default, it tries to detect which one is available. This can be
overridden with the I(select_crypto_backend) option. Please note that the PyOpenSSL backend
was deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0.
- This module allows one to verify a signature for a file by a certificate.
- The module uses the cryptography Python library.
requirements:
- Either cryptography >= 1.4 (some key types require newer versions)
- Or pyOpenSSL >= 0.11 (Ed25519 and Ed448 keys are not supported with this backend)
- cryptography >= 1.4 (some key types require newer versions)
author:
- Patrick Pichler (@aveexy)
- Markus Teufelberger (@MarkusTeufelberger)
@@ -49,18 +45,18 @@ options:
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
type: str
default: auto
choices: [ auto, cryptography, pyopenssl ]
choices: [ auto, cryptography ]
notes:
- |
When using the C(cryptography) backend, the following key types require at least the following C(cryptography) version:
RSA keys: C(cryptography) >= 1.4
DSA and ECDSA keys: C(cryptography) >= 1.5
ed448 and ed25519 keys: C(cryptography) >= 2.6
- Supports C(check_mode).
seealso:
- module: community.crypto.openssl_signature
- module: community.crypto.x509_certificate
@@ -88,7 +84,7 @@ EXAMPLES = r'''
RETURN = r'''
valid:
description: C(true) means the signature was valid for the given file, C(false) means it wasn't.
description: C(true) means the signature was valid for the given file, C(false) means it was not.
returned: success
type: bool
'''
@@ -98,20 +94,8 @@ import traceback
from distutils.version import LooseVersion
import base64
MINIMAL_PYOPENSSL_VERSION = '0.11'
MINIMAL_CRYPTOGRAPHY_VERSION = '1.4'
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
@@ -138,7 +122,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
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
@@ -169,37 +153,6 @@ class SignatureInfoBase(OpenSSLObject):
pass
# Implementation with using pyOpenSSL
class SignatureInfoPyOpenSSL(SignatureInfoBase):
def __init__(self, module, backend):
super(SignatureInfoPyOpenSSL, self).__init__(module, backend)
def run(self):
result = dict()
try:
with open(self.path, "rb") as f:
_in = f.read()
_signature = base64.b64decode(self.signature)
certificate = load_certificate(
path=self.certificate_path,
content=self.certificate_content,
backend=self.backend,
)
try:
OpenSSL.crypto.verify(certificate, _signature, _in, 'sha256')
result['valid'] = True
except Exception:
result['valid'] = False
return result
except Exception as e:
raise OpenSSLObjectError(e)
# Implementation with using cryptography
class SignatureInfoCryptography(SignatureInfoBase):
@@ -294,7 +247,7 @@ def main():
certificate_content=dict(type='str'),
path=dict(type='path', required=True),
signature=dict(type='str', required=True),
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
select_crypto_backend=dict(type='str', choices=['auto', 'cryptography'], default='auto'),
),
mutually_exclusive=(
['certificate_path', 'certificate_content'],
@@ -315,29 +268,17 @@ def main():
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)
# Decision
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))
"cryptography (>= {0})").format(MINIMAL_CRYPTOGRAPHY_VERSION))
try:
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')
_sign = SignatureInfoPyOpenSSL(module, backend)
elif backend == 'cryptography':
if backend == 'cryptography':
if not CRYPTOGRAPHY_FOUND:
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
exception=CRYPTOGRAPHY_IMP_ERR)

View File

@@ -14,9 +14,9 @@ DOCUMENTATION = r'''
module: x509_certificate
short_description: Generate and/or check OpenSSL certificates
description:
- It implements a notion of provider (ie. C(selfsigned), C(ownca), C(acme), C(assertonly), C(entrust))
- It implements a notion of provider (one of C(selfsigned), C(ownca), C(acme), and C(entrust))
for your certificate.
- "Please note that the module regenerates existing certificate if it doesn't match the module's
- "Please note that the module regenerates existing certificate if it does not match the module's
options, or if it seems to be corrupt. If you are concerned that this could overwrite
your existing certificate, consider using the I(backup) option."
- Note that this module was called C(openssl_certificate) when included directly in Ansible up to version 2.9.
@@ -47,8 +47,6 @@ options:
provider:
description:
- Name of the provider to use to generate/retrieve the OpenSSL certificate.
- The C(assertonly) provider will not generate files and fail if the certificate file is missing.
- The C(assertonly) provider has been deprecated in Ansible 2.9 and will be removed in community.crypto 2.0.0.
Please see the examples on how to emulate it with
M(community.crypto.x509_certificate_info), M(community.crypto.openssl_csr_info),
M(community.crypto.openssl_privatekey_info) and M(ansible.builtin.assert).
@@ -56,7 +54,7 @@ options:
L(Entrust Certificate Services,https://www.entrustdatacard.com/products/categories/ssl-certificates) (ECS) API."
- Required if I(state) is C(present).
type: str
choices: [ acme, assertonly, entrust, ownca, selfsigned ]
choices: [ acme, entrust, ownca, selfsigned ]
return_content:
description:
@@ -69,9 +67,6 @@ options:
description:
- Create a backup file including a timestamp so you can get the original
certificate back if you overwrote it with a new one by accident.
- This is not used by the C(assertonly) provider.
- This option is deprecated since Ansible 2.9 and will be removed with the C(assertonly) provider in community.crypto 2.0.0.
For alternatives, see the example on replacing C(assertonly).
type: bool
default: no
@@ -86,6 +81,9 @@ options:
ownca_privatekey_content:
version_added: '1.0.0'
notes:
- Supports C(check_mode).
seealso:
- module: community.crypto.x509_certificate_pipe
@@ -93,7 +91,6 @@ extends_documentation_fragment:
- ansible.builtin.files
- community.crypto.module_certificate
- community.crypto.module_certificate.backend_acme_documentation
- community.crypto.module_certificate.backend_assertonly_documentation
- community.crypto.module_certificate.backend_entrust_documentation
- community.crypto.module_certificate.backend_ownca_documentation
- community.crypto.module_certificate.backend_selfsigned_documentation
@@ -147,42 +144,12 @@ EXAMPLES = r'''
entrust_api_client_cert_key_path: /etc/ssl/entrust/ecs-key.crt
entrust_api_specification_path: /etc/ssl/entrust/api-docs/cms-api-2.1.0.yaml
# The following example shows one assertonly usage using all existing options for
# assertonly, and shows how to emulate the behavior with the x509_certificate_info,
# openssl_csr_info, openssl_privatekey_info and assert modules:
# The following example shows how to emulate the behavior of the removed
# "assertonly" provider with the x509_certificate_info, openssl_csr_info,
# openssl_privatekey_info and assert modules:
- community.crypto.x509_certificate:
provider: assertonly
path: /etc/ssl/crt/ansible.com.crt
csr_path: /etc/ssl/csr/ansible.com.csr
privatekey_path: /etc/ssl/csr/ansible.com.key
signature_algorithms:
- sha256WithRSAEncryption
- sha512WithRSAEncryption
subject:
commonName: ansible.com
subject_strict: yes
issuer:
commonName: ansible.com
issuer_strict: yes
has_expired: no
version: 3
key_usage:
- Data Encipherment
key_usage_strict: yes
extended_key_usage:
- DVCS
extended_key_usage_strict: yes
subject_alt_name:
- dns:ansible.com
subject_alt_name_strict: yes
not_before: 20190331202428Z
not_after: 20190413202428Z
valid_at: "+1d10h"
invalid_at: 20200331202428Z
valid_in: 10 # in ten seconds
- community.crypto.x509_certificate_info:
- name: Get certificate information
community.crypto.x509_certificate_info:
path: /etc/ssl/crt/ansible.com.crt
# for valid_at, invalid_at and valid_in
valid_at:
@@ -191,20 +158,22 @@ EXAMPLES = r'''
ten_seconds: "+10"
register: result
- community.crypto.openssl_csr_info:
- name: Get CSR information
community.crypto.openssl_csr_info:
# Verifies that the CSR signature is valid; module will fail if not
path: /etc/ssl/csr/ansible.com.csr
register: result_csr
- community.crypto.openssl_privatekey_info:
- name: Get private key information
community.crypto.openssl_privatekey_info:
path: /etc/ssl/csr/ansible.com.key
register: result_privatekey
- assert:
that:
# When private key is specified for assertonly, this will be checked:
# When private key was specified for assertonly, this was checked:
- result.public_key == result_privatekey.public_key
# When CSR is specified for assertonly, this will be checked:
# When CSR was specified for assertonly, this was checked:
- result.public_key == result_csr.public_key
- result.subject_ordered == result_csr.subject_ordered
- result.extensions_by_oid == result_csr.extensions_by_oid
@@ -236,103 +205,6 @@ EXAMPLES = r'''
- "result.valid_at.one_day_ten_hours" # for valid_at
- "not result.valid_at.fixed_timestamp" # for invalid_at
- "result.valid_at.ten_seconds" # for valid_in
# Examples for some checks one could use the assertonly provider for:
# (Please note that assertonly has been deprecated!)
# How to use the assertonly provider to implement and trigger your own custom certificate generation workflow:
- name: Check if a certificate is currently still valid, ignoring failures
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
has_expired: no
ignore_errors: yes
register: validity_check
- name: Run custom task(s) to get a new, valid certificate in case the initial check failed
command: superspecialSSL recreate /etc/ssl/crt/example.com.crt
when: validity_check.failed
- name: Check the new certificate again for validity with the same parameters, this time failing the play if it is still invalid
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
has_expired: no
when: validity_check.failed
# Some other checks that assertonly could be used for:
- name: Verify that an existing certificate was issued by the Let's Encrypt CA and is currently still valid
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
issuer:
O: Let's Encrypt
has_expired: no
- name: Ensure that a certificate uses a modern signature algorithm (no SHA1, MD5 or DSA)
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
signature_algorithms:
- sha224WithRSAEncryption
- sha256WithRSAEncryption
- sha384WithRSAEncryption
- sha512WithRSAEncryption
- sha224WithECDSAEncryption
- sha256WithECDSAEncryption
- sha384WithECDSAEncryption
- sha512WithECDSAEncryption
- name: Ensure that the existing certificate belongs to the specified private key
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
privatekey_path: /etc/ssl/private/example.com.pem
provider: assertonly
- name: Ensure that the existing certificate is still valid at the winter solstice 2017
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
valid_at: 20171221162800Z
- name: Ensure that the existing certificate is still valid 2 weeks (1209600 seconds) from now
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
valid_in: 1209600
- name: Ensure that the existing certificate is only used for digital signatures and encrypting other keys
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
key_usage:
- digitalSignature
- keyEncipherment
key_usage_strict: true
- name: Ensure that the existing certificate can be used for client authentication
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
extended_key_usage:
- clientAuth
- name: Ensure that the existing certificate can only be used for client authentication and time stamping
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
extended_key_usage:
- clientAuth
- 1.3.6.1.5.5.7.3.8
extended_key_usage_strict: true
- name: Ensure that the existing certificate has a certain domain in its subjectAltName
community.crypto.x509_certificate:
path: /etc/ssl/crt/example.com.crt
provider: assertonly
subject_alt_name:
- www.example.com
- test.example.com
'''
RETURN = r'''
@@ -356,7 +228,7 @@ certificate:
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 (
select_backend,
@@ -368,11 +240,6 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.module_bac
add_acme_provider_to_argument_spec,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_assertonly import (
AssertOnlyCertificateProvider,
add_assertonly_provider_to_argument_spec,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_entrust import (
EntrustCertificateProvider,
add_entrust_provider_to_argument_spec,
@@ -466,7 +333,10 @@ class GenericCertificate(OpenSSLObject):
self.changed = True
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):
"""Ensure the resource is in its desired state."""
@@ -486,7 +356,6 @@ class GenericCertificate(OpenSSLObject):
def main():
argument_spec = get_certificate_argument_spec()
add_acme_provider_to_argument_spec(argument_spec)
add_assertonly_provider_to_argument_spec(argument_spec)
add_entrust_provider_to_argument_spec(argument_spec)
add_ownca_provider_to_argument_spec(argument_spec)
add_selfsigned_provider_to_argument_spec(argument_spec)
@@ -502,10 +371,6 @@ def main():
supports_check_mode=True,
)
if module._name == 'community.crypto.openssl_certificate':
module.deprecate("The 'community.crypto.openssl_certificate' module has been renamed to 'community.crypto.x509_certificate'",
version='2.0.0', collection_name='community.crypto')
try:
if module.params['state'] == 'absent':
certificate = CertificateAbsent(module)
@@ -528,7 +393,6 @@ def main():
provider = module.params['provider']
provider_map = {
'acme': AcmeCertificateProvider,
'assertonly': AssertOnlyCertificateProvider,
'entrust': EntrustCertificateProvider,
'ownca': OwnCACertificateProvider,
'selfsigned': SelfSignedCertificateProvider,

View File

@@ -15,11 +15,7 @@ module: x509_certificate_info
short_description: Provide information of OpenSSL X.509 certificates
description:
- This module allows one to query information on OpenSSL certificates.
- 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.
- It uses the cryptography python library to interact with OpenSSL.
- Note that this module was called C(openssl_certificate_info) when included directly in Ansible
up to version 2.9. When moved to the collection C(community.crypto), it was renamed to
M(community.crypto.x509_certificate_info). From Ansible 2.10 on, it can still be used by the
@@ -29,7 +25,7 @@ description:
keyword, the new name M(community.crypto.x509_certificate_info) should be used to avoid
a deprecation warning.
requirements:
- PyOpenSSL >= 0.15 or cryptography >= 1.6
- cryptography >= 1.6
author:
- Felix Fontein (@felixfontein)
- Yanis Guenane (@Spredzy)
@@ -54,24 +50,22 @@ options:
- Time can be specified either as relative time or as absolute timestamp.
- Time will always be interpreted as UTC.
- Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h), and ASN.1 TIME (i.e. pattern C(YYYYMMDDHHMMSSZ)).
+ C([w | d | h | m | s]) (e.g. C(+32w1d2h), and ASN.1 TIME (in other words, pattern C(YYYYMMDDHHMMSSZ)).
Note that all timestamps will be treated as being in UTC.
type: dict
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.
- The default choice is C(auto), which tries to use C(cryptography) if available.
- 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 ]
choices: [ auto, cryptography ]
notes:
- All timestamp values are provided in ASN.1 TIME format, i.e. following the C(YYYYMMDDHHMMSSZ) pattern.
- All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern.
They are all in UTC.
- Supports C(check_mode).
seealso:
- module: community.crypto.x509_certificate
- module: community.crypto.x509_certificate_pipe
@@ -94,7 +88,7 @@ EXAMPLES = r'''
register: result
- name: Dump information
debug:
ansible.builtin.debug:
var: result
@@ -120,7 +114,7 @@ EXAMPLES = r'''
RETURN = r'''
expired:
description: Whether the certificate is expired (i.e. C(notAfter) is in the past)
description: Whether the certificate is expired (in other words, C(notAfter) is in the past).
returned: success
type: bool
basic_constraints:
@@ -144,7 +138,7 @@ extended_key_usage_critical:
returned: success
type: bool
extensions_by_oid:
description: Returns a dictionary for every extension OID
description: Returns a dictionary for every extension OID.
returned: success
type: dict
contains:
@@ -153,7 +147,13 @@ extensions_by_oid:
returned: success
type: bool
value:
description: The Base64 encoded value (in DER format) of the extension
description:
- The Base64 encoded value (in DER format) of the extension.
- B(Note) that depending on the C(cryptography) version used, it is
not possible to extract the ASN.1 content of the extension, but only
to provide the re-encoded content of the extension in case it was
parsed by C(cryptography). This should usually result in exactly the
same value, except if the original extension value was malformed.
returned: success
type: str
sample: "MAMCAQU="
@@ -212,20 +212,91 @@ subject_ordered:
elements: list
sample: '[["commonName", "www.example.com"], ["emailAddress": "test@example.com"]]'
not_after:
description: C(notAfter) date as ASN.1 TIME
description: C(notAfter) date as ASN.1 TIME.
returned: success
type: str
sample: 20190413202428Z
not_before:
description: C(notBefore) date as ASN.1 TIME
description: C(notBefore) date as ASN.1 TIME.
returned: success
type: str
sample: 20190331202428Z
public_key:
description: Certificate's public key in PEM format
description: Certificate's public key in PEM format.
returned: success
type: str
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:
description:
- Fingerprints of certificate's public key.
@@ -269,7 +340,7 @@ subject_key_identifier:
- The certificate's subject key identifier.
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
- Is C(none) if the C(SubjectKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: str
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
authority_key_identifier:
@@ -277,14 +348,14 @@ authority_key_identifier:
- The certificate's authority key identifier.
- The identifier is returned in hexadecimal, with C(:) used to separate bytes.
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: str
sample: '00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33'
authority_cert_issuer:
description:
- The certificate's authority cert issuer as a list of general names.
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: list
elements: str
sample: "[DNS:www.ansible.com, IP:1.2.3.4]"
@@ -292,7 +363,7 @@ authority_cert_serial_number:
description:
- The certificate's authority cert serial number.
- Is C(none) if the C(AuthorityKeyIdentifier) extension is not present.
returned: success and if the pyOpenSSL backend is I(not) used
returned: success
type: int
sample: '12345'
ocsp_uri:
@@ -303,529 +374,22 @@ ocsp_uri:
'''
import abc
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.basic import AnsibleModule
from ansible.module_utils.six import string_types
from ansible.module_utils._text import to_native, to_text, to_bytes
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
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.support import (
OpenSSLObject,
get_relative_time_option,
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.module_backends.certificate_info import (
select_backend,
)
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():
module = AnsibleModule(
@@ -833,7 +397,7 @@ def main():
path=dict(type='path'),
content=dict(type='str'),
valid_at=dict(type='dict'),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography']),
),
required_one_of=(
['path', 'content'],
@@ -843,57 +407,38 @@ def main():
),
supports_check_mode=True,
)
if module._name == 'community.crypto.openssl_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')
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 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(
msg='The value for valid_at.{0} must be of type string (got {1})'.format(k, type(v))
)
valid_at[k] = get_relative_time_option(v, 'valid_at.{0}'.format(k))
try:
if module.params['path'] is not None:
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
)
result = module_backend.get_info()
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)
not_before = module_backend.get_not_before()
not_after = module_backend.get_not_after()
# If cryptography is available we'll use it
if can_use_cryptography:
backend = 'cryptography'
elif can_use_pyopenssl:
backend = 'pyopenssl'
result['valid_at'] = dict()
if valid_at:
for k, v in valid_at.items():
result['valid_at'][k] = not_before <= v <= not_after
# 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 = 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)
except OpenSSLObjectError as exc:
module.fail_json(msg=to_native(exc))

View File

@@ -18,7 +18,7 @@ version_added: 1.3.0
description:
- It implements a notion of provider (ie. C(selfsigned), C(ownca), C(entrust))
for your certificate.
- "Please note that the module regenerates an existing certificate if it doesn't match the module's
- "Please note that the module regenerates an existing certificate if it does not match the module's
options, or if it seems to be corrupt. If you are concerned that this could overwrite
your existing certificate, consider using the I(backup) option."
author:
@@ -43,6 +43,9 @@ options:
seealso:
- module: community.crypto.x509_certificate
notes:
- Supports C(check_mode).
extends_documentation_fragment:
- community.crypto.module_certificate
- community.crypto.module_certificate.backend_entrust_documentation
@@ -57,7 +60,8 @@ EXAMPLES = r'''
privatekey_path: /etc/ssl/private/ansible.com.pem
csr_path: /etc/ssl/csr/ansible.com.csr
register: result
- ansible.builtin.debug:
- name: Print the certificate
ansible.builtin.debug:
var: result.certificate
# In the following example, both CSR and certificate file are stored on the
@@ -121,7 +125,7 @@ certificate:
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 (
select_backend,

View File

@@ -15,7 +15,7 @@ version_added: '1.0.0'
short_description: Generate Certificate Revocation Lists (CRLs)
description:
- This module allows one to (re)generate or update Certificate Revocation Lists (CRLs).
- Certificates on the revocation list can be either specified via serial number and (optionally) their issuer,
- Certificates on the revocation list can be either specified by serial number and (optionally) their issuer,
or as a path to a certificate file in PEM format.
requirements:
- cryptography >= 1.2
@@ -92,8 +92,21 @@ options:
description:
- Key/value pairs that will be present in the issuer name field of the CRL.
- If you need to specify more than one value with the same key, use a list as value.
- Required if I(state) is C(present).
- If the order of the components is important, use I(issuer_ordered).
- One of I(issuer) and I(issuer_ordered) is required if I(state) is C(present).
- Mutually exclusive with I(issuer_ordered).
type: dict
issuer_ordered:
description:
- A list of dictionaries, where every dictionary must contain one key/value pair.
This key/value pair will be present in the issuer name field of the CRL.
- If you want to specify more than one value with the same key in a row, you can
use a list as value.
- One of I(issuer) and I(issuer_ordered) is required if I(state) is C(present).
- Mutually exclusive with I(issuer).
type: list
elements: dict
version_added: 2.0.0
last_update:
description:
@@ -233,6 +246,7 @@ extends_documentation_fragment:
notes:
- All ASN.1 TIME values should be specified following the YYYYMMDDHHMMSSZ pattern.
- Date specified should be UTC. Minutes and seconds are mandatory.
- Supports C(check_mode).
'''
EXAMPLES = r'''
@@ -259,7 +273,7 @@ EXAMPLES = r'''
RETURN = r'''
filename:
description: Path to the generated CRL
description: Path to the generated CRL.
returned: changed or success
type: str
sample: /path/to/my-ca.crl
@@ -269,7 +283,7 @@ backup_file:
type: str
sample: /path/to/my-ca.crl.2019-03-09@11:22~
privatekey:
description: Path to the private CA key
description: Path to the private CA key.
returned: changed or success
type: str
sample: /path/to/my-ca.pem
@@ -369,7 +383,7 @@ 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
from ansible.module_utils.common.text.converters import to_native, to_text
from ansible_collections.community.crypto.plugins.module_utils.io import (
write_file,
@@ -385,6 +399,7 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
load_privatekey,
load_certificate,
parse_name_field,
parse_ordered_name_field,
get_relative_time_option,
select_message_digest,
)
@@ -404,10 +419,14 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp
cryptography_get_signature_algorithm_oid_from_crl,
)
from ansible_collections.community.crypto.plugins.module_utils.crypto.identify import (
from ansible_collections.community.crypto.plugins.module_utils.crypto.pem import (
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'
CRYPTOGRAPHY_IMP_ERR = None
@@ -457,8 +476,15 @@ class CRL(OpenSSLObject):
self.privatekey_content = self.privatekey_content.encode('utf-8')
self.privatekey_passphrase = module.params['privatekey_passphrase']
self.issuer = parse_name_field(module.params['issuer'])
self.issuer = [(entry[0], entry[1]) for entry in self.issuer if entry[1]]
try:
if module.params['issuer_ordered']:
self.issuer_ordered = True
self.issuer = parse_ordered_name_field(module.params['issuer_ordered'], 'issuer_ordered')
else:
self.issuer_ordered = False
self.issuer = parse_name_field(module.params['issuer'], 'issuer')
except (TypeError, ValueError) as exc:
module.fail_json(msg=to_native(exc))
self.last_update = get_relative_time_option(module.params['last_update'], 'last_update')
self.next_update = get_relative_time_option(module.params['next_update'], 'next_update')
@@ -501,7 +527,7 @@ class CRL(OpenSSLObject):
result['serial_number'] = rc['serial_number']
# All other options
if rc['issuer']:
result['issuer'] = [cryptography_get_name(issuer) for issuer in rc['issuer']]
result['issuer'] = [cryptography_get_name(issuer, 'issuer') for issuer in rc['issuer']]
result['issuer_critical'] = rc['issuer_critical']
result['revocation_date'] = get_relative_time_option(
rc['revocation_date'],
@@ -549,6 +575,19 @@ class CRL(OpenSSLObject):
except Exception as dummy:
self.crl_content = None
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):
if self.backup:
@@ -579,7 +618,7 @@ class CRL(OpenSSLObject):
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."""
state_and_perms = super(CRL, self).check(self.module, perms_required)
@@ -598,7 +637,11 @@ class CRL(OpenSSLObject):
return False
want_issuer = [(cryptography_name_to_oid(entry[0]), entry[1]) for entry in self.issuer]
if want_issuer != [(sub.oid, sub.value) for sub in self.crl.issuer]:
is_issuer = [(sub.oid, sub.value) for sub in self.crl.issuer]
if not self.issuer_ordered:
want_issuer = set(want_issuer)
is_issuer = set(is_issuer)
if want_issuer != is_issuer:
return False
old_entries = [self._compress_entry(cryptography_decode_revoked_certificate(cert)) for cert in self.crl]
@@ -647,7 +690,7 @@ class CRL(OpenSSLObject):
if entry['issuer'] is not None:
revoked_cert = revoked_cert.add_extension(
x509.CertificateIssuer([
cryptography_get_name(name) for name in entry['issuer']
cryptography_get_name(name, 'issuer') for name in entry['issuer']
]),
entry['issuer_critical']
)
@@ -671,15 +714,16 @@ class CRL(OpenSSLObject):
def generate(self):
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()
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':
result = self.crl.public_bytes(Encoding.PEM)
else:
result = self.crl.public_bytes(Encoding.DER)
if result is not None:
self.diff_after = self._get_info(result)
if self.return_content:
if self.format == 'pem':
self.crl_content = result
@@ -691,7 +735,9 @@ class CRL(OpenSSLObject):
self.changed = True
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
def dump(self, check_mode=False):
@@ -741,6 +787,10 @@ class CRL(OpenSSLObject):
if self.return_content:
result['crl'] = self.crl_content
result['diff'] = dict(
before=self.diff_before,
after=self.diff_after,
)
return result
@@ -757,6 +807,7 @@ def main():
privatekey_content=dict(type='str', no_log=True),
privatekey_passphrase=dict(type='str', no_log=True),
issuer=dict(type='dict'),
issuer_ordered=dict(type='list', elements='dict'),
last_update=dict(type='str', default='+0s'),
next_update=dict(type='str'),
digest=dict(type='str', default='sha256'),
@@ -790,10 +841,12 @@ def main():
),
required_if=[
('state', 'present', ['privatekey_path', 'privatekey_content'], True),
('state', 'present', ['issuer', 'next_update', 'revoked_certificates'], False),
('state', 'present', ['issuer', 'issuer_ordered'], True),
('state', 'present', ['next_update', 'revoked_certificates'], False),
],
mutually_exclusive=(
['privatekey_path', 'privatekey_content'],
['issuer', 'issuer_ordered'],
),
supports_check_mode=True,
add_file_common_args=True,
@@ -809,7 +862,7 @@ def main():
if module.params['state'] == 'present':
if module.check_mode:
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)
crl.generate()

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