mirror of
https://github.com/ansible-collections/community.crypto.git
synced 2026-05-06 13:22:58 +00:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d784e0a52b | ||
|
|
d73a2942a2 | ||
|
|
8af4847373 | ||
|
|
44f7367e21 | ||
|
|
0733b0d521 | ||
|
|
771a9eebcf | ||
|
|
0fdede5d7a | ||
|
|
56b2130c6e | ||
|
|
6c018b94da | ||
|
|
63f4598737 | ||
|
|
598cdf0a21 | ||
|
|
eea7bfc6bf | ||
|
|
8521c96e8a | ||
|
|
d90cc5142b | ||
|
|
37aab65396 | ||
|
|
baff003ea8 | ||
|
|
03427e35a7 | ||
|
|
170fa40014 | ||
|
|
330b30d5d2 | ||
|
|
67b8274faf | ||
|
|
02ee3fb974 | ||
|
|
93ced1956c | ||
|
|
a9e358ea57 | ||
|
|
ffcdbc5d0c | ||
|
|
6740cae10f | ||
|
|
915379459d | ||
|
|
a4a12bae27 | ||
|
|
94fc356338 | ||
|
|
08ada24a53 | ||
|
|
b59846b9fa | ||
|
|
c9ec463893 | ||
|
|
38ce150f80 | ||
|
|
408b538a45 | ||
|
|
1e82465559 | ||
|
|
85ac60e2c3 | ||
|
|
aaba87ac57 | ||
|
|
d6403ace6e | ||
|
|
6c989de994 | ||
|
|
4908f1a8ec | ||
|
|
f3c6c1172e | ||
|
|
9658a34605 | ||
|
|
5d153e05ef | ||
|
|
2ba77e015c | ||
|
|
9d958033a5 | ||
|
|
4adad5d98a | ||
|
|
b9737101cd | ||
|
|
9f27e28a45 | ||
|
|
9a7b2f1d0d | ||
|
|
0df33de73e | ||
|
|
cda2edf92c | ||
|
|
d38f59c18c | ||
|
|
4a7150204c | ||
|
|
bfb8e5df82 | ||
|
|
376d7cde12 | ||
|
|
a466df9c52 | ||
|
|
117438cff0 | ||
|
|
c6483751b5 | ||
|
|
2bf0bb5fb3 | ||
|
|
e9bc7c7163 | ||
|
|
0a0d0f2bdf | ||
|
|
3293b77f18 | ||
|
|
69aeb2d86f | ||
|
|
7298c1f49a | ||
|
|
ba03580659 | ||
|
|
a93f07c651 | ||
|
|
c0edfb46bb | ||
|
|
6c5a0c6df1 | ||
|
|
80d64e7b64 | ||
|
|
3e7362200a | ||
|
|
c400744040 | ||
|
|
91552d5fd2 | ||
|
|
6100d9b4df | ||
|
|
37c1540ff4 | ||
|
|
3239701ba4 | ||
|
|
81408bb853 | ||
|
|
6414301936 | ||
|
|
db513d1b27 | ||
|
|
0ecdf2ccbd | ||
|
|
c05e20cf1e | ||
|
|
f4334d7307 | ||
|
|
201920d161 | ||
|
|
e809ee19ee | ||
|
|
4684f36c38 | ||
|
|
bb3ddf1961 | ||
|
|
0e1f0fd730 | ||
|
|
7b1d4770e9 | ||
|
|
befa690d9e | ||
|
|
bcf2a17257 | ||
|
|
8c6b28cd81 | ||
|
|
b916f95d4d | ||
|
|
42d94dd44b | ||
|
|
f5fd5fdf5b | ||
|
|
e85554827f | ||
|
|
5d32937321 | ||
|
|
8de9376a10 | ||
|
|
35a78dbc4e | ||
|
|
2e69113688 |
@@ -36,7 +36,7 @@ variables:
|
||||
resources:
|
||||
containers:
|
||||
- container: default
|
||||
image: quay.io/ansible/azure-pipelines-test-container:1.8.0
|
||||
image: quay.io/ansible/azure-pipelines-test-container:1.9.0
|
||||
|
||||
pool: Standard
|
||||
|
||||
@@ -55,6 +55,28 @@ stages:
|
||||
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: []
|
||||
@@ -92,10 +114,10 @@ stages:
|
||||
test: centos7
|
||||
- name: CentOS 8
|
||||
test: centos8
|
||||
- name: Fedora 32
|
||||
test: fedora32
|
||||
- name: Fedora 33
|
||||
test: fedora33
|
||||
- name: Fedora 34
|
||||
test: fedora34
|
||||
- name: openSUSE 15 py2
|
||||
test: opensuse15py2
|
||||
- name: openSUSE 15 py3
|
||||
@@ -104,6 +126,40 @@ stages:
|
||||
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 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: []
|
||||
@@ -114,24 +170,10 @@ stages:
|
||||
targets:
|
||||
- name: CentOS 6
|
||||
test: centos6
|
||||
- name: CentOS 7
|
||||
test: centos7
|
||||
- name: CentOS 8
|
||||
test: centos8
|
||||
- name: Fedora 30
|
||||
test: fedora30
|
||||
- name: Fedora 31
|
||||
test: fedora31
|
||||
- name: Fedora 32
|
||||
test: fedora32
|
||||
- name: openSUSE 15 py2
|
||||
test: opensuse15py2
|
||||
- name: openSUSE 15 py3
|
||||
test: opensuse15
|
||||
- name: Ubuntu 16.04
|
||||
test: ubuntu1604
|
||||
- name: Ubuntu 18.04
|
||||
test: ubuntu1804
|
||||
- stage: Docker_2_9
|
||||
displayName: Docker 2.9
|
||||
dependsOn: []
|
||||
@@ -144,16 +186,8 @@ stages:
|
||||
test: centos6
|
||||
- name: CentOS 7
|
||||
test: centos7
|
||||
- name: CentOS 8
|
||||
test: centos8
|
||||
- name: Fedora 30
|
||||
test: fedora30
|
||||
- name: Fedora 31
|
||||
test: fedora31
|
||||
- name: openSUSE 15 py2
|
||||
test: opensuse15py2
|
||||
- name: openSUSE 15 py3
|
||||
test: opensuse15
|
||||
- name: Ubuntu 16.04
|
||||
test: ubuntu1604
|
||||
- name: Ubuntu 18.04
|
||||
@@ -170,12 +204,40 @@ stages:
|
||||
targets:
|
||||
- name: macOS 11.1
|
||||
test: macos/11.1
|
||||
- name: RHEL 7.9
|
||||
test: rhel/7.9
|
||||
- name: RHEL 8.4
|
||||
test: rhel/8.4
|
||||
- name: FreeBSD 12.2
|
||||
test: freebsd/12.2
|
||||
- name: FreeBSD 13.0
|
||||
test: freebsd/13.0
|
||||
- stage: Remote_2_12
|
||||
displayName: Remote 2.12
|
||||
dependsOn: []
|
||||
jobs:
|
||||
- template: templates/matrix.yml
|
||||
parameters:
|
||||
testFormat: 2.12/{0}/1
|
||||
targets:
|
||||
- name: macOS 11.1
|
||||
test: macos/11.1
|
||||
- name: RHEL 8.4
|
||||
test: rhel/8.4
|
||||
- name: FreeBSD 13.0
|
||||
test: freebsd/13.0
|
||||
- stage: Remote_2_11
|
||||
displayName: Remote 2.11
|
||||
dependsOn: []
|
||||
jobs:
|
||||
- template: templates/matrix.yml
|
||||
parameters:
|
||||
testFormat: 2.11/{0}/1
|
||||
targets:
|
||||
- name: RHEL 7.9
|
||||
test: rhel/7.9
|
||||
- name: RHEL 8.3
|
||||
test: rhel/8.3
|
||||
- name: FreeBSD 11.4
|
||||
test: freebsd/11.4
|
||||
- name: FreeBSD 12.2
|
||||
test: freebsd/12.2
|
||||
- stage: Remote_2_10
|
||||
@@ -186,8 +248,6 @@ stages:
|
||||
parameters:
|
||||
testFormat: 2.10/{0}/1
|
||||
targets:
|
||||
- name: RHEL 7.8
|
||||
test: rhel/7.8
|
||||
- name: OS X 10.11
|
||||
test: osx/10.11
|
||||
- name: macOS 10.15
|
||||
@@ -221,6 +281,27 @@ stages:
|
||||
- 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: 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: []
|
||||
@@ -248,16 +329,24 @@ stages:
|
||||
condition: succeededOrFailed()
|
||||
dependsOn:
|
||||
- Ansible_devel
|
||||
- Ansible_2_12
|
||||
- Ansible_2_11
|
||||
- Ansible_2_10
|
||||
- Ansible_2_9
|
||||
- Remote_devel
|
||||
- Docker_devel
|
||||
- Cloud_devel
|
||||
- Remote_2_12
|
||||
- Remote_2_11
|
||||
- Remote_2_10
|
||||
- Docker_2_10
|
||||
- Cloud_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
|
||||
|
||||
@@ -7,7 +7,7 @@ set -o pipefail -eu
|
||||
|
||||
output_path="$1"
|
||||
|
||||
curl --silent --show-error https://codecov.io/bash > codecov.sh
|
||||
curl --silent --show-error https://ansible-ci-files.s3.us-east-1.amazonaws.com/codecov/codecov.sh > codecov.sh
|
||||
|
||||
for file in "${output_path}"/reports/coverage*.xml; do
|
||||
name="${file}"
|
||||
|
||||
197
CHANGELOG.rst
197
CHANGELOG.rst
@@ -5,6 +5,203 @@ Community Crypto Release Notes
|
||||
.. contents:: Topics
|
||||
|
||||
|
||||
v1.9.4
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Regular bugfix release.
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- acme_* modules - fix commands composed for OpenSSL backend to retrieve information on CSRs and certificates from stdin to use ``/dev/stdin`` instead of ``-``. This is needed for OpenSSL 1.0.1 and 1.0.2, apparently (https://github.com/ansible-collections/community.crypto/pull/279).
|
||||
- acme_challenge_cert_helper - only return exception when cryptography is not installed, not when a too old version of it is installed. This prevents Ansible's callback to crash (https://github.com/ansible-collections/community.crypto/pull/281).
|
||||
|
||||
v1.9.3
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Regular bugfix release.
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- openssl_csr and openssl_csr_pipe - make sure that Unicode strings are used to compare strings with the cryptography backend. This fixes idempotency problems with non-ASCII letters on Python 2 (https://github.com/ansible-collections/community.crypto/issues/270, https://github.com/ansible-collections/community.crypto/pull/271).
|
||||
|
||||
v1.9.2
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Bugfix release to fix the changelog. No other change compared to 1.9.0.
|
||||
|
||||
v1.9.1
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Accidental 1.9.1 release. Identical to 1.9.0.
|
||||
|
||||
v1.9.0
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Regular feature release.
|
||||
|
||||
Minor Changes
|
||||
-------------
|
||||
|
||||
- get_certificate - added ``starttls`` option to retrieve certificates from servers which require clients to request an encrypted connection (https://github.com/ansible-collections/community.crypto/pull/264).
|
||||
- openssh_keypair - added ``diff`` support (https://github.com/ansible-collections/community.crypto/pull/260).
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- keypair_backend module utils - simplify code to pass sanity tests (https://github.com/ansible-collections/community.crypto/pull/263).
|
||||
- openssh_keypair - fixed ``cryptography`` backend to preserve original file permissions when regenerating a keypair requires existing files to be overwritten (https://github.com/ansible-collections/community.crypto/pull/260).
|
||||
- openssh_keypair - fixed error handling to restore original keypair if regeneration fails (https://github.com/ansible-collections/community.crypto/pull/260).
|
||||
- x509_crl - restore inherited function signature to pass sanity tests (https://github.com/ansible-collections/community.crypto/pull/263).
|
||||
|
||||
v1.8.0
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Regular bugfix and feature release.
|
||||
|
||||
Minor Changes
|
||||
-------------
|
||||
|
||||
- Avoid internal ansible-core module_utils in favor of equivalent public API available since at least Ansible 2.9 (https://github.com/ansible-collections/community.crypto/pull/253).
|
||||
- openssh certificate module utils - new module_utils for parsing OpenSSH certificates (https://github.com/ansible-collections/community.crypto/pull/246).
|
||||
- openssh_cert - added ``regenerate`` option to validate additional certificate parameters which trigger regeneration of an existing certificate (https://github.com/ansible-collections/community.crypto/pull/256).
|
||||
- openssh_cert - adding ``diff`` support (https://github.com/ansible-collections/community.crypto/pull/255).
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- openssh_cert - fixed certificate generation to restore original certificate if an error is encountered (https://github.com/ansible-collections/community.crypto/pull/255).
|
||||
- openssh_keypair - fixed a bug that prevented custom file attributes being applied to public keys (https://github.com/ansible-collections/community.crypto/pull/257).
|
||||
|
||||
v1.7.1
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Bugfix release.
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- openssl_pkcs12 - fix crash when loading passphrase-protected PKCS#12 files with ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/issues/247, https://github.com/ansible-collections/community.crypto/pull/248).
|
||||
|
||||
v1.7.0
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Regular feature and bugfix release.
|
||||
|
||||
Minor Changes
|
||||
-------------
|
||||
|
||||
- cryptography_openssh module utils - new module_utils for managing asymmetric keypairs and OpenSSH formatted/encoded asymmetric keypairs (https://github.com/ansible-collections/community.crypto/pull/213).
|
||||
- openssh_keypair - added ``backend`` parameter for selecting between the cryptography library or the OpenSSH binary for the execution of actions performed by ``openssh_keypair`` (https://github.com/ansible-collections/community.crypto/pull/236).
|
||||
- openssh_keypair - added ``passphrase`` parameter for encrypting/decrypting OpenSSH private keys (https://github.com/ansible-collections/community.crypto/pull/225).
|
||||
- openssl_csr - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- openssl_csr_info - now returns ``public_key_type`` and ``public_key_data`` (https://github.com/ansible-collections/community.crypto/pull/233).
|
||||
- openssl_csr_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/204).
|
||||
- openssl_csr_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- openssl_pkcs12 - added option ``select_crypto_backend`` and a ``cryptography`` backend. This requires cryptography 3.0 or newer, and does not support the ``iter_size`` and ``maciter_size`` options (https://github.com/ansible-collections/community.crypto/pull/234).
|
||||
- openssl_privatekey - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- openssl_privatekey_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/205).
|
||||
- openssl_privatekey_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- openssl_publickey - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- x509_certificate - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- x509_certificate_info - now returns ``public_key_type`` and ``public_key_data`` (https://github.com/ansible-collections/community.crypto/pull/233).
|
||||
- x509_certificate_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/206).
|
||||
- x509_certificate_pipe - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- x509_crl - add diff mode (https://github.com/ansible-collections/community.crypto/issues/38, https://github.com/ansible-collections/community.crypto/pull/150).
|
||||
- x509_crl_info - add ``list_revoked_certificates`` option to avoid enumerating all revoked certificates (https://github.com/ansible-collections/community.crypto/pull/232).
|
||||
- x509_crl_info - refactor module to allow code re-use for diff mode (https://github.com/ansible-collections/community.crypto/pull/203).
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- openssh_keypair - fix ``check_mode`` to populate return values for existing keypairs (https://github.com/ansible-collections/community.crypto/issues/113, https://github.com/ansible-collections/community.crypto/pull/230).
|
||||
- various modules - prevent crashes when modules try to set attributes on not yet existing files in check mode. This will be fixed in ansible-core 2.12, but it is not backported to every Ansible version we support (https://github.com/ansible-collections/community.crypto/issue/242, https://github.com/ansible-collections/community.crypto/pull/243).
|
||||
- x509_certificate - fix crash when ``assertonly`` provider is used and some error conditions should be reported (https://github.com/ansible-collections/community.crypto/issues/240, https://github.com/ansible-collections/community.crypto/pull/241).
|
||||
|
||||
New Modules
|
||||
-----------
|
||||
|
||||
- openssl_publickey_info - Provide information for OpenSSL public keys
|
||||
|
||||
v1.6.2
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Bugfix release. Fixes compatibility issue of ACME modules with step-ca.
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- acme_* modules - avoid crashing for ACME servers where the ``meta`` directory key is not present (https://github.com/ansible-collections/community.crypto/issues/220, https://github.com/ansible-collections/community.crypto/pull/221).
|
||||
|
||||
v1.6.1
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Bugfix release.
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- acme_* modules - fix wrong usages of ``ACMEProtocolException`` (https://github.com/ansible-collections/community.crypto/pull/216, https://github.com/ansible-collections/community.crypto/pull/217).
|
||||
|
||||
v1.6.0
|
||||
======
|
||||
|
||||
Release Summary
|
||||
---------------
|
||||
|
||||
Fixes compatibility issues with the latest ansible-core 2.11 beta, and contains a lot of internal refactoring for the ACME modules and support for private key passphrases for them.
|
||||
|
||||
Minor Changes
|
||||
-------------
|
||||
|
||||
- acme module_utils - the ``acme`` module_utils has been split up into several Python modules (https://github.com/ansible-collections/community.crypto/pull/184).
|
||||
- acme_* modules - codebase refactor which should not be visible to end-users (https://github.com/ansible-collections/community.crypto/pull/184).
|
||||
- acme_* modules - support account key passphrases for ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/issues/197, https://github.com/ansible-collections/community.crypto/pull/207).
|
||||
- acme_certificate_revoke - support revoking by private keys that are passphrase protected for ``cryptography`` backend (https://github.com/ansible-collections/community.crypto/pull/207).
|
||||
- acme_challenge_cert_helper - add ``private_key_passphrase`` parameter (https://github.com/ansible-collections/community.crypto/pull/207).
|
||||
|
||||
Deprecated Features
|
||||
-------------------
|
||||
|
||||
- acme module_utils - the ``acme`` module_utils (``ansible_collections.community.crypto.plugins.module_utils.acme``) is deprecated and will be removed in community.crypto 2.0.0. Use the new Python modules in the ``acme`` package instead (``ansible_collections.community.crypto.plugins.module_utils.acme.xxx``) (https://github.com/ansible-collections/community.crypto/pull/184).
|
||||
|
||||
Bugfixes
|
||||
--------
|
||||
|
||||
- action_module plugin helper - make compatible with latest changes in ansible-core 2.11.0b3 (https://github.com/ansible-collections/community.crypto/pull/202).
|
||||
- openssl_privatekey_pipe - make compatible with latest changes in ansible-core 2.11.0b3 (https://github.com/ansible-collections/community.crypto/pull/202).
|
||||
|
||||
v1.5.0
|
||||
======
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ Provides modules for [Ansible](https://www.ansible.com/community) for various cr
|
||||
|
||||
You can find [documentation for this collection on the Ansible docs site](https://docs.ansible.com/ansible/latest/collections/community/crypto/).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -341,3 +341,224 @@ releases:
|
||||
- 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'
|
||||
|
||||
6
docs/docsite/extra-docs.yml
Normal file
6
docs/docsite/extra-docs.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
sections:
|
||||
- title: Scenario Guides
|
||||
toctree:
|
||||
- guide_selfsigned
|
||||
- guide_ownca
|
||||
148
docs/docsite/rst/guide_ownca.rst
Normal file
148
docs/docsite/rst/guide_ownca.rst
Normal file
@@ -0,0 +1,148 @@
|
||||
.. _ansible_collections.community.crypto.docsite.guide_ownca:
|
||||
|
||||
How to create a small CA
|
||||
========================
|
||||
|
||||
The `community.crypto collection <https://galaxy.ansible.com/community/crypto>`_ offers multiple modules that create private keys, certificate signing requests, and certificates. This guide shows how to create your own small CA and how to use it to sign certificates.
|
||||
|
||||
In all examples, we assume that the CA's private key is password protected, where the password is provided in the ``secret_ca_passphrase`` variable.
|
||||
|
||||
Set up the CA
|
||||
-------------
|
||||
|
||||
Any certificate can be used as a CA certificate. You can create a self-signed certificate (see :ref:`ansible_collections.community.crypto.docsite.guide_selfsigned`), use another CA certificate to sign a new certificate (using the instructions below for signing a certificate), ask (and pay) a commercial CA to sign your CA certificate, etc.
|
||||
|
||||
The following instructions show how to set up a simple self-signed CA certificate.
|
||||
|
||||
.. code-block:: yaml+jinja
|
||||
|
||||
- name: Create private key with password protection
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /path/to/ca-certificate.key
|
||||
passphrase: "{{ secret_ca_passphrase }}"
|
||||
|
||||
- name: Create certificate signing request (CSR) for CA certificate
|
||||
community.crypto.openssl_csr_pipe:
|
||||
privatekey_path: /path/to/ca-certificate.key
|
||||
privatekey_passphrase: "{{ secret_ca_passphrase }}"
|
||||
common_name: Ansible CA
|
||||
use_common_name_for_san: false # since we do not specify SANs, don't use CN as a SAN
|
||||
basic_constraints:
|
||||
- 'CA:TRUE'
|
||||
basic_constraints_critical: yes
|
||||
key_usage:
|
||||
- keyCertSign
|
||||
key_usage_critical: true
|
||||
register: ca_csr
|
||||
|
||||
- name: Create self-signed CA certificate from CSR
|
||||
community.crypto.x509_certificate:
|
||||
path: /path/to/ca-certificate.pem
|
||||
csr_content: "{{ ca_csr.csr }}"
|
||||
privatekey_path: /path/to/ca-certificate.key
|
||||
privatekey_passphrase: "{{ secret_ca_passphrase }}"
|
||||
provider: selfsigned
|
||||
|
||||
Use the CA to sign a certificate
|
||||
--------------------------------
|
||||
|
||||
To sign a certificate, you must pass a CSR to the :ref:`community.crypto.x509_certificate module <ansible_collections.community.crypto.x509_certificate_module>` or :ref:`community.crypto.x509_certificate_pipe module <ansible_collections.community.crypto.x509_certificate_pipe_module>`.
|
||||
|
||||
In the following example, we assume that the certificate to sign (including its private key) are on ``server_1``, while our CA certificate is on ``server_2``. We do not want any key material to leave each respective server.
|
||||
|
||||
.. code-block:: yaml+jinja
|
||||
|
||||
- name: Create private key for new certificate on server_1
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /path/to/certificate.key
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
|
||||
- name: Create certificate signing request (CSR) for new certificate
|
||||
community.crypto.openssl_csr_pipe:
|
||||
privatekey_path: /path/to/certificate.key
|
||||
subject_alt_name:
|
||||
- "DNS:ansible.com"
|
||||
- "DNS:www.ansible.com"
|
||||
- "DNS:docs.ansible.com"
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
register: csr
|
||||
|
||||
- name: Sign certificate with our CA
|
||||
community.crypto.x509_certificate_pipe:
|
||||
csr_content: "{{ ca_csr.csr }}"
|
||||
provider: ownca
|
||||
ownca_path: /path/to/ca-certificate.pem
|
||||
ownca_privatekey_path: /path/to/ca-certificate.key
|
||||
ownca_privatekey_passphrase: "{{ secret_ca_passphrase }}"
|
||||
ownca_not_after: +365d # valid for one year
|
||||
ownca_not_before: "-1d" # valid since yesterday
|
||||
delegate_to: server_2
|
||||
run_once: true
|
||||
register: certificate
|
||||
|
||||
- name: Write certificate file on server_1
|
||||
copy:
|
||||
dest: /path/to/certificate.pem
|
||||
content: "{{ certificate.certificate }}"
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
|
||||
Please note that the above procedure is **not idempotent**. The following extended example reads the existing certificate from ``server_1`` (if exists) and provides it to the :ref:`community.crypto.x509_certificate_pipe module <ansible_collections.community.crypto.x509_certificate_pipe_module>`, and only writes the result back if it was changed:
|
||||
|
||||
.. code-block:: yaml+jinja
|
||||
|
||||
- name: Create private key for new certificate on server_1
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /path/to/certificate.key
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
|
||||
- name: Create certificate signing request (CSR) for new certificate
|
||||
community.crypto.openssl_csr_pipe:
|
||||
privatekey_path: /path/to/certificate.key
|
||||
subject_alt_name:
|
||||
- "DNS:ansible.com"
|
||||
- "DNS:www.ansible.com"
|
||||
- "DNS:docs.ansible.com"
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
register: csr
|
||||
|
||||
- name: Check whether certificate exists
|
||||
stat:
|
||||
path: /path/to/certificate.pem
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
register: certificate_exists
|
||||
|
||||
- name: Read existing certificate if exists
|
||||
slurp:
|
||||
src: /path/to/certificate.pem
|
||||
when: certificate_exists.stat.exists
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
register: certificate
|
||||
|
||||
- name: Sign certificate with our CA
|
||||
community.crypto.x509_certificate_pipe:
|
||||
content: "{{ (certificate.content | b64decode) if certificate_exists.stat.exists else omit }}"
|
||||
csr_content: "{{ ca_csr.csr }}"
|
||||
provider: ownca
|
||||
ownca_path: /path/to/ca-certificate.pem
|
||||
ownca_privatekey_path: /path/to/ca-certificate.key
|
||||
ownca_privatekey_passphrase: "{{ secret_ca_passphrase }}"
|
||||
ownca_not_after: +365d # valid for one year
|
||||
ownca_not_before: "-1d" # valid since yesterday
|
||||
delegate_to: server_2
|
||||
run_once: true
|
||||
register: certificate
|
||||
|
||||
- name: Write certificate file on server_1
|
||||
copy:
|
||||
dest: /path/to/certificate.pem
|
||||
content: "{{ certificate.certificate }}"
|
||||
delegate_to: server_1
|
||||
run_once: true
|
||||
when: certificate is changed
|
||||
60
docs/docsite/rst/guide_selfsigned.rst
Normal file
60
docs/docsite/rst/guide_selfsigned.rst
Normal 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
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace: community
|
||||
name: crypto
|
||||
version: 1.5.0
|
||||
version: 1.9.4
|
||||
readme: README.md
|
||||
authors:
|
||||
- Ansible (github.com/ansible)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -227,12 +227,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,23 +240,24 @@ options:
|
||||
- Values must be prefixed by their options. (i.e., C(email), C(URI), C(DNS), C(RID), C(IP), C(dirName),
|
||||
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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
90
plugins/module_utils/acme/__init__.py
Normal file
90
plugins/module_utils/acme/__init__.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import copy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.urls import fetch_url
|
||||
from ansible.module_utils.six.moves.urllib.parse import unquote
|
||||
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
get_default_argspec,
|
||||
ACMEDirectory,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
CryptographyBackend,
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import (
|
||||
handle_standard_module_arguments,
|
||||
set_crypto_backend,
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme._compatibility import ACMELegacyAccount as ACMEAccount
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import ModuleFailException
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
|
||||
read_file,
|
||||
write_file,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
process_links,
|
||||
)
|
||||
|
||||
|
||||
def openssl_get_csr_identifiers(openssl_binary, module, csr_filename, csr_content=None):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
return OpenSSLCLIBackend(module, openssl_binary=openssl_binary).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
|
||||
|
||||
|
||||
def cryptography_get_csr_identifiers(module, csr_filename, csr_content=None):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
return CryptographyBackend(module).get_csr_identifiers(csr_filename=csr_filename, csr_content=csr_content)
|
||||
|
||||
|
||||
def cryptography_get_cert_days(module, cert_file, now=None):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
return CryptographyBackend(module).get_cert_days(cert_filename=cert_file, now=now)
|
||||
267
plugins/module_utils/acme/_compatibility.py
Normal file
267
plugins/module_utils/acme/_compatibility.py
Normal file
@@ -0,0 +1,267 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import locale
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import HAS_CURRENT_CRYPTOGRAPHY as _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
CryptographyBackend,
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
|
||||
ACMEClient,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
|
||||
ACMEAccount,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
|
||||
create_key_authorization,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
|
||||
HAS_CURRENT_CRYPTOGRAPHY = _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY
|
||||
|
||||
|
||||
def set_crypto_backend(module):
|
||||
'''
|
||||
Sets which crypto backend to use (default: auto detection).
|
||||
|
||||
Does not care whether a new enough cryptoraphy is available or not. Must
|
||||
be called before any real stuff is done which might evaluate
|
||||
``HAS_CURRENT_CRYPTOGRAPHY``.
|
||||
'''
|
||||
global HAS_CURRENT_CRYPTOGRAPHY
|
||||
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
# Choose backend
|
||||
backend = module.params['select_crypto_backend']
|
||||
if backend == 'auto':
|
||||
pass
|
||||
elif backend == 'openssl':
|
||||
HAS_CURRENT_CRYPTOGRAPHY = False
|
||||
elif backend == 'cryptography':
|
||||
if not _ORIGINAL_HAS_CURRENT_CRYPTOGRAPHY:
|
||||
module.fail_json(msg=missing_required_lib('cryptography'))
|
||||
HAS_CURRENT_CRYPTOGRAPHY = True
|
||||
else:
|
||||
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend))
|
||||
# Inform about choices
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION))
|
||||
return 'cryptography'
|
||||
else:
|
||||
module.debug('Using OpenSSL binary backend')
|
||||
return 'openssl'
|
||||
|
||||
|
||||
def handle_standard_module_arguments(module, needs_acme_v2=False):
|
||||
'''
|
||||
Do standard module setup, argument handling and warning emitting.
|
||||
'''
|
||||
backend = set_crypto_backend(module)
|
||||
|
||||
if not module.params['validate_certs']:
|
||||
module.warn(
|
||||
'Disabling certificate validation for communications with ACME endpoint. '
|
||||
'This should only be done for testing against a local ACME server for '
|
||||
'development purposes, but *never* for production purposes.'
|
||||
)
|
||||
|
||||
if module.params['acme_version'] is None:
|
||||
module.params['acme_version'] = 1
|
||||
module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if module.params['acme_directory'] is None:
|
||||
module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory'
|
||||
module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if needs_acme_v2 and module.params['acme_version'] < 2:
|
||||
module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name))
|
||||
|
||||
# AnsibleModule() changes the locale, so change it back to C because we rely on time.strptime() when parsing certificate dates.
|
||||
module.run_command_environ_update = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
|
||||
locale.setlocale(locale.LC_ALL, 'C')
|
||||
|
||||
return backend
|
||||
|
||||
|
||||
def get_compatibility_backend(module):
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
return CryptographyBackend(module)
|
||||
else:
|
||||
return OpenSSLCLIBackend(module)
|
||||
|
||||
|
||||
class ACMELegacyAccount(object):
|
||||
'''
|
||||
ACME account object. Handles the authorized communication with the
|
||||
ACME server. Provides access to account bound information like
|
||||
the currently active authorizations and valid certificates
|
||||
'''
|
||||
|
||||
def __init__(self, module):
|
||||
module.deprecate(
|
||||
'Please adjust your custom module/plugin to the ACME module_utils refactor '
|
||||
'(https://github.com/ansible-collections/community.crypto/pull/184). The '
|
||||
'compatibility layer will be removed in community.crypto 2.0.0, thus breaking '
|
||||
'your code', version='2.0.0', collection_name='community.crypto')
|
||||
backend = get_compatibility_backend(module)
|
||||
self.client = ACMEClient(module, backend)
|
||||
self.account = ACMEAccount(self.client)
|
||||
self.key = self.client.account_key_file
|
||||
self.key_content = self.client.account_key_content
|
||||
self.uri = self.client.account_uri
|
||||
self.key_data = self.client.account_key_data
|
||||
self.jwk = self.client.account_jwk
|
||||
self.jws_header = self.client.account_jws_header
|
||||
self.directory = self.client.directory
|
||||
|
||||
def get_keyauthorization(self, token):
|
||||
'''
|
||||
Returns the key authorization for the given token
|
||||
https://tools.ietf.org/html/rfc8555#section-8.1
|
||||
'''
|
||||
return create_key_authorization(self.client, token)
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns a pair
|
||||
(error, key_data).
|
||||
'''
|
||||
try:
|
||||
return None, self.client.parse_key(key_file=key_file, key_content=key_content)
|
||||
except KeyParsingError as e:
|
||||
return e.msg, {}
|
||||
|
||||
def sign_request(self, protected, payload, key_data, encode_payload=True):
|
||||
return self.client.sign_request(protected, payload, key_data, encode_payload=encode_payload)
|
||||
|
||||
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True, encode_payload=True):
|
||||
'''
|
||||
Sends a JWS signed HTTP POST request to the ACME server and returns
|
||||
the response as dictionary
|
||||
https://tools.ietf.org/html/rfc8555#section-6.2
|
||||
|
||||
If payload is None, a POST-as-GET is performed.
|
||||
(https://tools.ietf.org/html/rfc8555#section-6.3)
|
||||
'''
|
||||
return self.client.send_signed_request(
|
||||
url,
|
||||
payload,
|
||||
key_data=key_data,
|
||||
jws_header=jws_header,
|
||||
parse_json_result=parse_json_result,
|
||||
encode_payload=encode_payload,
|
||||
fail_on_error=False,
|
||||
)
|
||||
|
||||
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False, fail_on_error=True):
|
||||
'''
|
||||
Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback
|
||||
to GET if server replies with a status code of 405.
|
||||
'''
|
||||
return self.client.get_request(
|
||||
uri,
|
||||
parse_json_result=parse_json_result,
|
||||
headers=headers,
|
||||
get_only=get_only,
|
||||
fail_on_error=fail_on_error,
|
||||
)
|
||||
|
||||
def set_account_uri(self, uri):
|
||||
'''
|
||||
Set account URI. For ACME v2, it needs to be used to sending signed
|
||||
requests.
|
||||
'''
|
||||
self.client.set_account_uri(uri)
|
||||
self.uri = self.client.account_uri
|
||||
|
||||
def get_account_data(self):
|
||||
'''
|
||||
Retrieve account information. Can only be called when the account
|
||||
URI is already known (such as after calling setup_account).
|
||||
Return None if the account was deactivated, or a dict otherwise.
|
||||
'''
|
||||
return self.account.get_account_data()
|
||||
|
||||
def setup_account(self, contact=None, agreement=None, terms_agreed=False,
|
||||
allow_creation=True, remove_account_uri_if_not_exists=False,
|
||||
external_account_binding=None):
|
||||
'''
|
||||
Detect or create an account on the ACME server. For ACME v1,
|
||||
as the only way (without knowing an account URI) to test if an
|
||||
account exists is to try and create one with the provided account
|
||||
key, this method will always result in an account being present
|
||||
(except on error situations). For ACME v2, a new account will
|
||||
only be created if ``allow_creation`` is set to True.
|
||||
|
||||
For ACME v2, ``check_mode`` is fully respected. For ACME v1, the
|
||||
account might be created if it does not yet exist.
|
||||
|
||||
Return a pair ``(created, account_data)``. Here, ``created`` will
|
||||
be ``True`` in case the account was created or would be created
|
||||
(check mode). ``account_data`` will be the current account data,
|
||||
or ``None`` if the account does not exist.
|
||||
|
||||
The account URI will be stored in ``self.uri``; if it is ``None``,
|
||||
the account does not exist.
|
||||
|
||||
If specified, ``external_account_binding`` should be a dictionary
|
||||
with keys ``kid``, ``alg`` and ``key``
|
||||
(https://tools.ietf.org/html/rfc8555#section-7.3.4).
|
||||
|
||||
https://tools.ietf.org/html/rfc8555#section-7.3
|
||||
'''
|
||||
result = self.account.setup_account(
|
||||
contact=contact,
|
||||
agreement=agreement,
|
||||
terms_agreed=terms_agreed,
|
||||
allow_creation=allow_creation,
|
||||
remove_account_uri_if_not_exists=remove_account_uri_if_not_exists,
|
||||
external_account_binding=external_account_binding,
|
||||
)
|
||||
self.uri = self.client.account_uri
|
||||
return result
|
||||
|
||||
def update_account(self, account_data, contact=None):
|
||||
'''
|
||||
Update an account on the ACME server. Check mode is fully respected.
|
||||
|
||||
The current account data must be provided as ``account_data``.
|
||||
|
||||
Return a pair ``(updated, account_data)``, where ``updated`` is
|
||||
``True`` in case something changed (contact info updated) or
|
||||
would be changed (check mode), and ``account_data`` the updated
|
||||
account data.
|
||||
|
||||
https://tools.ietf.org/html/rfc8555#section-7.3.2
|
||||
'''
|
||||
return self.account.update_account(account_data, contact=contact)
|
||||
251
plugins/module_utils/acme/account.py
Normal file
251
plugins/module_utils/acme/account.py
Normal 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
|
||||
373
plugins/module_utils/acme/acme.py
Normal file
373
plugins/module_utils/acme/acme.py
Normal file
@@ -0,0 +1,373 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import locale
|
||||
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.urls import fetch_url
|
||||
from ansible.module_utils.common.text.converters import to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_openssl_cli import (
|
||||
OpenSSLCLIBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backend_cryptography import (
|
||||
CryptographyBackend,
|
||||
CRYPTOGRAPHY_VERSION,
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
ACMEProtocolException,
|
||||
NetworkException,
|
||||
ModuleFailException,
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
)
|
||||
|
||||
|
||||
def _assert_fetch_url_success(module, response, info, allow_redirect=False, allow_client_error=True, allow_server_error=True):
|
||||
if info['status'] < 0:
|
||||
raise NetworkException(msg="Failure downloading %s, %s" % (info['url'], info['msg']))
|
||||
|
||||
if (300 <= info['status'] < 400 and not allow_redirect) or \
|
||||
(400 <= info['status'] < 500 and not allow_client_error) or \
|
||||
(info['status'] >= 500 and not allow_server_error):
|
||||
raise ACMEProtocolException(module, info=info, response=response)
|
||||
|
||||
|
||||
def _is_failed(info, expected_status_codes=None):
|
||||
if info['status'] < 200 or info['status'] >= 400:
|
||||
return True
|
||||
if expected_status_codes is not None and info['status'] not in expected_status_codes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ACMEDirectory(object):
|
||||
'''
|
||||
The ACME server directory. Gives access to the available resources,
|
||||
and allows to obtain a Replay-Nonce. The acme_directory URL
|
||||
needs to support unauthenticated GET requests; ACME endpoints
|
||||
requiring authentication are not supported.
|
||||
https://tools.ietf.org/html/rfc8555#section-7.1.1
|
||||
'''
|
||||
|
||||
def __init__(self, module, account):
|
||||
self.module = module
|
||||
self.directory_root = module.params['acme_directory']
|
||||
self.version = module.params['acme_version']
|
||||
|
||||
self.directory, dummy = account.get_request(self.directory_root, get_only=True)
|
||||
|
||||
# Check whether self.version matches what we expect
|
||||
if self.version == 1:
|
||||
for key in ('new-reg', 'new-authz', 'new-cert'):
|
||||
if key not in self.directory:
|
||||
raise ModuleFailException("ACME directory does not seem to follow protocol ACME v1")
|
||||
if self.version == 2:
|
||||
for key in ('newNonce', 'newAccount', 'newOrder'):
|
||||
if key not in self.directory:
|
||||
raise ModuleFailException("ACME directory does not seem to follow protocol ACME v2")
|
||||
# Make sure that 'meta' is always available
|
||||
if 'meta' not in self.directory:
|
||||
self.directory['meta'] = {}
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.directory[key]
|
||||
|
||||
def get_nonce(self, resource=None):
|
||||
url = self.directory_root if self.version == 1 else self.directory['newNonce']
|
||||
if resource is not None:
|
||||
url = resource
|
||||
dummy, info = fetch_url(self.module, url, method='HEAD')
|
||||
if info['status'] not in (200, 204):
|
||||
raise NetworkException("Failed to get replay-nonce, got status {0}".format(info['status']))
|
||||
return info['replay-nonce']
|
||||
|
||||
|
||||
class ACMEClient(object):
|
||||
'''
|
||||
ACME client object. Handles the authorized communication with the
|
||||
ACME server.
|
||||
'''
|
||||
|
||||
def __init__(self, module, backend):
|
||||
# Set to true to enable logging of all signed requests
|
||||
self._debug = False
|
||||
|
||||
self.module = module
|
||||
self.backend = backend
|
||||
self.version = module.params['acme_version']
|
||||
# account_key path and content are mutually exclusive
|
||||
self.account_key_file = module.params['account_key_src']
|
||||
self.account_key_content = module.params['account_key_content']
|
||||
self.account_key_passphrase = module.params['account_key_passphrase']
|
||||
|
||||
# Grab account URI from module parameters.
|
||||
# Make sure empty string is treated as None.
|
||||
self.account_uri = module.params.get('account_uri') or None
|
||||
|
||||
self.account_key_data = None
|
||||
self.account_jwk = None
|
||||
self.account_jws_header = None
|
||||
if self.account_key_file is not None or self.account_key_content is not None:
|
||||
try:
|
||||
self.account_key_data = self.parse_key(
|
||||
key_file=self.account_key_file,
|
||||
key_content=self.account_key_content,
|
||||
passphrase=self.account_key_passphrase)
|
||||
except KeyParsingError as e:
|
||||
raise ModuleFailException("Error while parsing account key: {msg}".format(msg=e.msg))
|
||||
self.account_jwk = self.account_key_data['jwk']
|
||||
self.account_jws_header = {
|
||||
"alg": self.account_key_data['alg'],
|
||||
"jwk": self.account_jwk,
|
||||
}
|
||||
if self.account_uri:
|
||||
# Make sure self.account_jws_header is updated
|
||||
self.set_account_uri(self.account_uri)
|
||||
|
||||
self.directory = ACMEDirectory(module, self)
|
||||
|
||||
def set_account_uri(self, uri):
|
||||
'''
|
||||
Set account URI. For ACME v2, it needs to be used to sending signed
|
||||
requests.
|
||||
'''
|
||||
self.account_uri = uri
|
||||
if self.version != 1:
|
||||
self.account_jws_header.pop('jwk')
|
||||
self.account_jws_header['kid'] = self.account_uri
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None, passphrase=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
|
||||
In case of an error, raises KeyParsingError.
|
||||
'''
|
||||
if key_file is None and key_content is None:
|
||||
raise AssertionError('One of key_file and key_content must be specified!')
|
||||
return self.backend.parse_key(key_file, key_content, passphrase=passphrase)
|
||||
|
||||
def sign_request(self, protected, payload, key_data, encode_payload=True):
|
||||
'''
|
||||
Signs an ACME request.
|
||||
'''
|
||||
try:
|
||||
if payload is None:
|
||||
# POST-as-GET
|
||||
payload64 = ''
|
||||
else:
|
||||
# POST
|
||||
if encode_payload:
|
||||
payload = self.module.jsonify(payload).encode('utf8')
|
||||
payload64 = nopad_b64(to_bytes(payload))
|
||||
protected64 = nopad_b64(self.module.jsonify(protected).encode('utf8'))
|
||||
except Exception as e:
|
||||
raise ModuleFailException("Failed to encode payload / headers as JSON: {0}".format(e))
|
||||
|
||||
return self.backend.sign(payload64, protected64, key_data)
|
||||
|
||||
def _log(self, msg, data=None):
|
||||
'''
|
||||
Write arguments to acme.log when logging is enabled.
|
||||
'''
|
||||
if self._debug:
|
||||
with open('acme.log', 'ab') as f:
|
||||
f.write('[{0}] {1}\n'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%s'), msg).encode('utf-8'))
|
||||
if data is not None:
|
||||
f.write('{0}\n\n'.format(json.dumps(data, indent=2, sort_keys=True)).encode('utf-8'))
|
||||
|
||||
def send_signed_request(self, url, payload, key_data=None, jws_header=None, parse_json_result=True,
|
||||
encode_payload=True, fail_on_error=True, error_msg=None, expected_status_codes=None):
|
||||
'''
|
||||
Sends a JWS signed HTTP POST request to the ACME server and returns
|
||||
the response as dictionary (if parse_json_result is True) or in raw form
|
||||
(if parse_json_result is False).
|
||||
https://tools.ietf.org/html/rfc8555#section-6.2
|
||||
|
||||
If payload is None, a POST-as-GET is performed.
|
||||
(https://tools.ietf.org/html/rfc8555#section-6.3)
|
||||
'''
|
||||
key_data = key_data or self.account_key_data
|
||||
jws_header = jws_header or self.account_jws_header
|
||||
failed_tries = 0
|
||||
while True:
|
||||
protected = copy.deepcopy(jws_header)
|
||||
protected["nonce"] = self.directory.get_nonce()
|
||||
if self.version != 1:
|
||||
protected["url"] = url
|
||||
|
||||
self._log('URL', url)
|
||||
self._log('protected', protected)
|
||||
self._log('payload', payload)
|
||||
data = self.sign_request(protected, payload, key_data, encode_payload=encode_payload)
|
||||
if self.version == 1:
|
||||
data["header"] = jws_header.copy()
|
||||
for k, v in protected.items():
|
||||
dummy = data["header"].pop(k, None)
|
||||
self._log('signed request', data)
|
||||
data = self.module.jsonify(data)
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/jose+json',
|
||||
}
|
||||
resp, info = fetch_url(self.module, url, data=data, headers=headers, method='POST')
|
||||
_assert_fetch_url_success(self.module, resp, info)
|
||||
result = {}
|
||||
try:
|
||||
content = resp.read()
|
||||
except AttributeError:
|
||||
content = info.pop('body', None)
|
||||
|
||||
if content or not parse_json_result:
|
||||
if (parse_json_result and info['content-type'].startswith('application/json')) or 400 <= info['status'] < 600:
|
||||
try:
|
||||
decoded_result = self.module.from_json(content.decode('utf8'))
|
||||
self._log('parsed result', decoded_result)
|
||||
# In case of badNonce error, try again (up to 5 times)
|
||||
# (https://tools.ietf.org/html/rfc8555#section-6.7)
|
||||
if all((
|
||||
400 <= info['status'] < 600,
|
||||
decoded_result.get('type') == 'urn:ietf:params:acme:error:badNonce',
|
||||
failed_tries <= 5,
|
||||
)):
|
||||
failed_tries += 1
|
||||
continue
|
||||
if parse_json_result:
|
||||
result = decoded_result
|
||||
else:
|
||||
result = content
|
||||
except ValueError:
|
||||
raise NetworkException("Failed to parse the ACME response: {0} {1}".format(url, content))
|
||||
else:
|
||||
result = content
|
||||
|
||||
if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes):
|
||||
raise ACMEProtocolException(
|
||||
self.module, msg=error_msg, info=info, content=content, content_json=result if parse_json_result else None)
|
||||
return result, info
|
||||
|
||||
def get_request(self, uri, parse_json_result=True, headers=None, get_only=False,
|
||||
fail_on_error=True, error_msg=None, expected_status_codes=None):
|
||||
'''
|
||||
Perform a GET-like request. Will try POST-as-GET for ACMEv2, with fallback
|
||||
to GET if server replies with a status code of 405.
|
||||
'''
|
||||
if not get_only and self.version != 1:
|
||||
# Try POST-as-GET
|
||||
content, info = self.send_signed_request(uri, None, parse_json_result=False, fail_on_error=False)
|
||||
if info['status'] == 405:
|
||||
# Instead, do unauthenticated GET
|
||||
get_only = True
|
||||
else:
|
||||
# Do unauthenticated GET
|
||||
get_only = True
|
||||
|
||||
if get_only:
|
||||
# Perform unauthenticated GET
|
||||
resp, info = fetch_url(self.module, uri, method='GET', headers=headers)
|
||||
|
||||
_assert_fetch_url_success(self.module, resp, info)
|
||||
|
||||
try:
|
||||
content = resp.read()
|
||||
except AttributeError:
|
||||
content = info.pop('body', None)
|
||||
|
||||
# Process result
|
||||
parsed_json_result = False
|
||||
if parse_json_result:
|
||||
result = {}
|
||||
if content:
|
||||
if info['content-type'].startswith('application/json'):
|
||||
try:
|
||||
result = self.module.from_json(content.decode('utf8'))
|
||||
parsed_json_result = True
|
||||
except ValueError:
|
||||
raise NetworkException("Failed to parse the ACME response: {0} {1}".format(uri, content))
|
||||
else:
|
||||
result = content
|
||||
else:
|
||||
result = content
|
||||
|
||||
if fail_on_error and _is_failed(info, expected_status_codes=expected_status_codes):
|
||||
raise ACMEProtocolException(
|
||||
self.module, msg=error_msg, info=info, content=content, content_json=result if parsed_json_result else None)
|
||||
return result, info
|
||||
|
||||
|
||||
def get_default_argspec():
|
||||
'''
|
||||
Provides default argument spec for the options documented in the acme doc fragment.
|
||||
'''
|
||||
return dict(
|
||||
account_key_src=dict(type='path', aliases=['account_key']),
|
||||
account_key_content=dict(type='str', no_log=True),
|
||||
account_key_passphrase=dict(type='str', no_log=True),
|
||||
account_uri=dict(type='str'),
|
||||
acme_directory=dict(type='str'),
|
||||
acme_version=dict(type='int', choices=[1, 2]),
|
||||
validate_certs=dict(type='bool', default=True),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'openssl', 'cryptography']),
|
||||
)
|
||||
|
||||
|
||||
def create_backend(module, needs_acme_v2):
|
||||
backend = module.params['select_crypto_backend']
|
||||
|
||||
# Backend autodetect
|
||||
if backend == 'auto':
|
||||
backend = 'cryptography' if HAS_CURRENT_CRYPTOGRAPHY else 'openssl'
|
||||
|
||||
# Create backend object
|
||||
if backend == 'cryptography':
|
||||
if not HAS_CURRENT_CRYPTOGRAPHY:
|
||||
module.fail_json(msg=missing_required_lib('cryptography'))
|
||||
module.debug('Using cryptography backend (library version {0})'.format(CRYPTOGRAPHY_VERSION))
|
||||
module_backend = CryptographyBackend(module)
|
||||
elif backend == 'openssl':
|
||||
module.debug('Using OpenSSL binary backend')
|
||||
module_backend = OpenSSLCLIBackend(module)
|
||||
else:
|
||||
module.fail_json(msg='Unknown crypto backend "{0}"!'.format(backend))
|
||||
|
||||
# Check common module parameters
|
||||
if not module.params['validate_certs']:
|
||||
module.warn(
|
||||
'Disabling certificate validation for communications with ACME endpoint. '
|
||||
'This should only be done for testing against a local ACME server for '
|
||||
'development purposes, but *never* for production purposes.'
|
||||
)
|
||||
|
||||
if module.params['acme_version'] is None:
|
||||
module.params['acme_version'] = 1
|
||||
module.deprecate("The option 'acme_version' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if module.params['acme_directory'] is None:
|
||||
module.params['acme_directory'] = 'https://acme-staging.api.letsencrypt.org/directory'
|
||||
module.deprecate("The option 'acme_directory' will be required from community.crypto 2.0.0 on",
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
|
||||
if needs_acme_v2 and module.params['acme_version'] < 2:
|
||||
module.fail_json(msg='The {0} module requires the ACME v2 protocol!'.format(module._name))
|
||||
|
||||
# AnsibleModule() changes the locale, so change it back to C because we rely
|
||||
# on datetime.datetime.strptime() when parsing certificate dates.
|
||||
locale.setlocale(locale.LC_ALL, 'C')
|
||||
|
||||
return module_backend
|
||||
375
plugins/module_utils/acme/backend_cryptography.py
Normal file
375
plugins/module_utils/acme/backend_cryptography.py
Normal file
@@ -0,0 +1,375 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ansible.module_utils.common.text.converters import to_bytes, to_native
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import (
|
||||
CryptoBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
|
||||
ChainMatcher,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
BackendException,
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.io import read_file
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
parse_name_field,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
|
||||
cryptography_name_to_oid,
|
||||
)
|
||||
|
||||
try:
|
||||
import cryptography
|
||||
import cryptography.hazmat.backends
|
||||
import cryptography.hazmat.primitives.hashes
|
||||
import cryptography.hazmat.primitives.hmac
|
||||
import cryptography.hazmat.primitives.asymmetric.ec
|
||||
import cryptography.hazmat.primitives.asymmetric.padding
|
||||
import cryptography.hazmat.primitives.asymmetric.rsa
|
||||
import cryptography.hazmat.primitives.asymmetric.utils
|
||||
import cryptography.hazmat.primitives.serialization
|
||||
import cryptography.x509
|
||||
import cryptography.x509.oid
|
||||
from distutils.version import LooseVersion
|
||||
CRYPTOGRAPHY_VERSION = cryptography.__version__
|
||||
HAS_CURRENT_CRYPTOGRAPHY = (LooseVersion(CRYPTOGRAPHY_VERSION) >= LooseVersion('1.5'))
|
||||
if HAS_CURRENT_CRYPTOGRAPHY:
|
||||
_cryptography_backend = cryptography.hazmat.backends.default_backend()
|
||||
except Exception as dummy:
|
||||
HAS_CURRENT_CRYPTOGRAPHY = False
|
||||
CRYPTOGRAPHY_VERSION = None
|
||||
|
||||
|
||||
if sys.version_info[0] >= 3:
|
||||
# Python 3 (and newer)
|
||||
def _count_bytes(n):
|
||||
return (n.bit_length() + 7) // 8 if n > 0 else 0
|
||||
|
||||
def _convert_int_to_bytes(count, no):
|
||||
return no.to_bytes(count, byteorder='big')
|
||||
|
||||
def _pad_hex(n, digits):
|
||||
res = hex(n)[2:]
|
||||
if len(res) < digits:
|
||||
res = '0' * (digits - len(res)) + res
|
||||
return res
|
||||
else:
|
||||
# Python 2
|
||||
def _count_bytes(n):
|
||||
if n <= 0:
|
||||
return 0
|
||||
h = '%x' % n
|
||||
return (len(h) + 1) // 2
|
||||
|
||||
def _convert_int_to_bytes(count, n):
|
||||
h = '%x' % n
|
||||
if len(h) > 2 * count:
|
||||
raise Exception('Number {1} needs more than {0} bytes!'.format(count, n))
|
||||
return ('0' * (2 * count - len(h)) + h).decode('hex')
|
||||
|
||||
def _pad_hex(n, digits):
|
||||
h = '%x' % n
|
||||
if len(h) < digits:
|
||||
h = '0' * (digits - len(h)) + h
|
||||
return h
|
||||
|
||||
|
||||
class CryptographyChainMatcher(ChainMatcher):
|
||||
@staticmethod
|
||||
def _parse_key_identifier(key_identifier, name, criterium_idx, module):
|
||||
if key_identifier:
|
||||
try:
|
||||
return binascii.unhexlify(key_identifier.replace(':', ''))
|
||||
except Exception:
|
||||
if criterium_idx is None:
|
||||
module.warn('Criterium has invalid {0} value. Ignoring criterium.'.format(name))
|
||||
else:
|
||||
module.warn('Criterium {0} in select_chain has invalid {1} value. '
|
||||
'Ignoring criterium.'.format(criterium_idx, name))
|
||||
return None
|
||||
|
||||
def __init__(self, criterium, module):
|
||||
self.criterium = criterium
|
||||
self.test_certificates = criterium.test_certificates
|
||||
self.subject = []
|
||||
self.issuer = []
|
||||
if criterium.subject:
|
||||
self.subject = [
|
||||
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.subject)
|
||||
]
|
||||
if criterium.issuer:
|
||||
self.issuer = [
|
||||
(cryptography_name_to_oid(k), to_native(v)) for k, v in parse_name_field(criterium.issuer)
|
||||
]
|
||||
self.subject_key_identifier = CryptographyChainMatcher._parse_key_identifier(
|
||||
criterium.subject_key_identifier, 'subject_key_identifier', criterium.index, module)
|
||||
self.authority_key_identifier = CryptographyChainMatcher._parse_key_identifier(
|
||||
criterium.authority_key_identifier, 'authority_key_identifier', criterium.index, module)
|
||||
|
||||
def _match_subject(self, x509_subject, match_subject):
|
||||
for oid, value in match_subject:
|
||||
found = False
|
||||
for attribute in x509_subject:
|
||||
if attribute.oid == oid and value == to_native(attribute.value):
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
return True
|
||||
|
||||
def match(self, certificate):
|
||||
'''
|
||||
Check whether an alternate chain matches the specified criterium.
|
||||
'''
|
||||
chain = certificate.chain
|
||||
if self.test_certificates == 'last':
|
||||
chain = chain[-1:]
|
||||
elif self.test_certificates == 'first':
|
||||
chain = chain[:1]
|
||||
for cert in chain:
|
||||
try:
|
||||
x509 = cryptography.x509.load_pem_x509_certificate(to_bytes(cert), cryptography.hazmat.backends.default_backend())
|
||||
matches = True
|
||||
if not self._match_subject(x509.subject, self.subject):
|
||||
matches = False
|
||||
if not self._match_subject(x509.issuer, self.issuer):
|
||||
matches = False
|
||||
if self.subject_key_identifier:
|
||||
try:
|
||||
ext = x509.extensions.get_extension_for_class(cryptography.x509.SubjectKeyIdentifier)
|
||||
if self.subject_key_identifier != ext.value.digest:
|
||||
matches = False
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
matches = False
|
||||
if self.authority_key_identifier:
|
||||
try:
|
||||
ext = x509.extensions.get_extension_for_class(cryptography.x509.AuthorityKeyIdentifier)
|
||||
if self.authority_key_identifier != ext.value.key_identifier:
|
||||
matches = False
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
matches = False
|
||||
if matches:
|
||||
return True
|
||||
except Exception as e:
|
||||
self.module.warn('Error while loading certificate {0}: {1}'.format(cert, e))
|
||||
return False
|
||||
|
||||
|
||||
class CryptographyBackend(CryptoBackend):
|
||||
def __init__(self, module):
|
||||
super(CryptographyBackend, self).__init__(module)
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None, passphrase=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
|
||||
Raises KeyParsingError in case of errors.
|
||||
'''
|
||||
# If key_content isn't given, read key_file
|
||||
if key_content is None:
|
||||
key_content = read_file(key_file)
|
||||
else:
|
||||
key_content = to_bytes(key_content)
|
||||
# Parse key
|
||||
try:
|
||||
key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
key_content,
|
||||
password=to_bytes(passphrase) if passphrase is not None else None,
|
||||
backend=_cryptography_backend)
|
||||
except Exception as e:
|
||||
raise KeyParsingError('error while loading key: {0}'.format(e))
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
pk = key.public_key().public_numbers()
|
||||
return {
|
||||
'key_obj': key,
|
||||
'type': 'rsa',
|
||||
'alg': 'RS256',
|
||||
'jwk': {
|
||||
"kty": "RSA",
|
||||
"e": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.e), pk.e)),
|
||||
"n": nopad_b64(_convert_int_to_bytes(_count_bytes(pk.n), pk.n)),
|
||||
},
|
||||
'hash': 'sha256',
|
||||
}
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
pk = key.public_key().public_numbers()
|
||||
if pk.curve.name == 'secp256r1':
|
||||
bits = 256
|
||||
alg = 'ES256'
|
||||
hashalg = 'sha256'
|
||||
point_size = 32
|
||||
curve = 'P-256'
|
||||
elif pk.curve.name == 'secp384r1':
|
||||
bits = 384
|
||||
alg = 'ES384'
|
||||
hashalg = 'sha384'
|
||||
point_size = 48
|
||||
curve = 'P-384'
|
||||
elif pk.curve.name == 'secp521r1':
|
||||
# Not yet supported on Let's Encrypt side, see
|
||||
# https://github.com/letsencrypt/boulder/issues/2217
|
||||
bits = 521
|
||||
alg = 'ES512'
|
||||
hashalg = 'sha512'
|
||||
point_size = 66
|
||||
curve = 'P-521'
|
||||
else:
|
||||
raise KeyParsingError('unknown elliptic curve: {0}'.format(pk.curve.name))
|
||||
num_bytes = (bits + 7) // 8
|
||||
return {
|
||||
'key_obj': key,
|
||||
'type': 'ec',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
"kty": "EC",
|
||||
"crv": curve,
|
||||
"x": nopad_b64(_convert_int_to_bytes(num_bytes, pk.x)),
|
||||
"y": nopad_b64(_convert_int_to_bytes(num_bytes, pk.y)),
|
||||
},
|
||||
'hash': hashalg,
|
||||
'point_size': point_size,
|
||||
}
|
||||
else:
|
||||
raise KeyParsingError('unknown key type "{0}"'.format(type(key)))
|
||||
|
||||
def sign(self, payload64, protected64, key_data):
|
||||
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
|
||||
if 'mac_obj' in key_data:
|
||||
mac = key_data['mac_obj']()
|
||||
mac.update(sign_payload)
|
||||
signature = mac.finalize()
|
||||
elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
padding = cryptography.hazmat.primitives.asymmetric.padding.PKCS1v15()
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA256
|
||||
signature = key_data['key_obj'].sign(sign_payload, padding, hashalg())
|
||||
elif isinstance(key_data['key_obj'], cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
if key_data['hash'] == 'sha256':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA256
|
||||
elif key_data['hash'] == 'sha384':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA384
|
||||
elif key_data['hash'] == 'sha512':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA512
|
||||
ecdsa = cryptography.hazmat.primitives.asymmetric.ec.ECDSA(hashalg())
|
||||
r, s = cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature(key_data['key_obj'].sign(sign_payload, ecdsa))
|
||||
rr = _pad_hex(r, 2 * key_data['point_size'])
|
||||
ss = _pad_hex(s, 2 * key_data['point_size'])
|
||||
signature = binascii.unhexlify(rr) + binascii.unhexlify(ss)
|
||||
|
||||
return {
|
||||
"protected": protected64,
|
||||
"payload": payload64,
|
||||
"signature": nopad_b64(signature),
|
||||
}
|
||||
|
||||
def create_mac_key(self, alg, key):
|
||||
'''Create a MAC key.'''
|
||||
if alg == 'HS256':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA256
|
||||
hashbytes = 32
|
||||
elif alg == 'HS384':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA384
|
||||
hashbytes = 48
|
||||
elif alg == 'HS512':
|
||||
hashalg = cryptography.hazmat.primitives.hashes.SHA512
|
||||
hashbytes = 64
|
||||
else:
|
||||
raise BackendException('Unsupported MAC key algorithm for cryptography backend: {0}'.format(alg))
|
||||
key_bytes = base64.urlsafe_b64decode(key)
|
||||
if len(key_bytes) < hashbytes:
|
||||
raise BackendException(
|
||||
'{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes))
|
||||
return {
|
||||
'mac_obj': lambda: cryptography.hazmat.primitives.hmac.HMAC(
|
||||
key_bytes,
|
||||
hashalg(),
|
||||
_cryptography_backend),
|
||||
'type': 'hmac',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
'kty': 'oct',
|
||||
'k': key,
|
||||
},
|
||||
}
|
||||
|
||||
def get_csr_identifiers(self, csr_filename=None, csr_content=None):
|
||||
'''
|
||||
Return a set of requested identifiers (CN and SANs) for the CSR.
|
||||
Each identifier is a pair (type, identifier), where type is either
|
||||
'dns' or 'ip'.
|
||||
'''
|
||||
identifiers = set([])
|
||||
if csr_content is None:
|
||||
csr_content = read_file(csr_filename)
|
||||
else:
|
||||
csr_content = to_bytes(csr_content)
|
||||
csr = cryptography.x509.load_pem_x509_csr(csr_content, _cryptography_backend)
|
||||
for sub in csr.subject:
|
||||
if sub.oid == cryptography.x509.oid.NameOID.COMMON_NAME:
|
||||
identifiers.add(('dns', sub.value))
|
||||
for extension in csr.extensions:
|
||||
if extension.oid == cryptography.x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
|
||||
for name in extension.value:
|
||||
if isinstance(name, cryptography.x509.DNSName):
|
||||
identifiers.add(('dns', name.value))
|
||||
elif isinstance(name, cryptography.x509.IPAddress):
|
||||
identifiers.add(('ip', name.value.compressed))
|
||||
else:
|
||||
raise BackendException('Found unsupported SAN identifier {0}'.format(name))
|
||||
return identifiers
|
||||
|
||||
def get_cert_days(self, cert_filename=None, cert_content=None, now=None):
|
||||
'''
|
||||
Return the days the certificate in cert_filename remains valid and -1
|
||||
if the file was not found. If cert_filename contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
|
||||
If now is not specified, datetime.datetime.now() is used.
|
||||
'''
|
||||
if cert_filename is not None:
|
||||
cert_content = None
|
||||
if os.path.exists(cert_filename):
|
||||
cert_content = read_file(cert_filename)
|
||||
else:
|
||||
cert_content = to_bytes(cert_content)
|
||||
|
||||
if cert_content is None:
|
||||
return -1
|
||||
|
||||
try:
|
||||
cert = cryptography.x509.load_pem_x509_certificate(cert_content, _cryptography_backend)
|
||||
except Exception as e:
|
||||
if cert_filename is None:
|
||||
raise BackendException('Cannot parse certificate: {0}'.format(e))
|
||||
raise BackendException('Cannot parse certificate {0}: {1}'.format(cert_filename, e))
|
||||
|
||||
if now is None:
|
||||
now = datetime.datetime.now()
|
||||
return (cert.not_valid_after - now).days
|
||||
|
||||
def create_chain_matcher(self, criterium):
|
||||
'''
|
||||
Given a Criterium object, creates a ChainMatcher object.
|
||||
'''
|
||||
return CryptographyChainMatcher(criterium, self.module)
|
||||
298
plugins/module_utils/acme/backend_openssl_cli.py
Normal file
298
plugins/module_utils/acme/backend_openssl_cli.py
Normal file
@@ -0,0 +1,298 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.backends import (
|
||||
CryptoBackend,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
BackendException,
|
||||
KeyParsingError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import nopad_b64
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
|
||||
_OPENSSL_ENVIRONMENT_UPDATE = dict(LANG='C', LC_ALL='C', LC_MESSAGES='C', LC_CTYPE='C')
|
||||
|
||||
|
||||
class OpenSSLCLIBackend(CryptoBackend):
|
||||
def __init__(self, module, openssl_binary=None):
|
||||
super(OpenSSLCLIBackend, self).__init__(module)
|
||||
if openssl_binary is None:
|
||||
openssl_binary = module.get_bin_path('openssl', True)
|
||||
self.openssl_binary = openssl_binary
|
||||
|
||||
def parse_key(self, key_file=None, key_content=None, passphrase=None):
|
||||
'''
|
||||
Parses an RSA or Elliptic Curve key file in PEM format and returns key_data.
|
||||
Raises KeyParsingError in case of errors.
|
||||
'''
|
||||
if passphrase is not None:
|
||||
raise KeyParsingError('openssl backend does not support key passphrases')
|
||||
# If key_file isn't given, but key_content, write that to a temporary file
|
||||
if key_file is None:
|
||||
fd, tmpsrc = tempfile.mkstemp()
|
||||
self.module.add_cleanup_file(tmpsrc) # Ansible will delete the file on exit
|
||||
f = os.fdopen(fd, 'wb')
|
||||
try:
|
||||
f.write(key_content.encode('utf-8'))
|
||||
key_file = tmpsrc
|
||||
except Exception as err:
|
||||
try:
|
||||
f.close()
|
||||
except Exception as dummy:
|
||||
pass
|
||||
raise KeyParsingError("failed to create temporary content file: %s" % to_native(err), exception=traceback.format_exc())
|
||||
f.close()
|
||||
# Parse key
|
||||
account_key_type = None
|
||||
with open(key_file, "rt") as f:
|
||||
for line in f:
|
||||
m = re.match(r"^\s*-{5,}BEGIN\s+(EC|RSA)\s+PRIVATE\s+KEY-{5,}\s*$", line)
|
||||
if m is not None:
|
||||
account_key_type = m.group(1).lower()
|
||||
break
|
||||
if account_key_type is None:
|
||||
# This happens for example if openssl_privatekey created this key
|
||||
# (as opposed to the OpenSSL binary). For now, we assume this is
|
||||
# an RSA key.
|
||||
# FIXME: add some kind of auto-detection
|
||||
account_key_type = "rsa"
|
||||
if account_key_type not in ("rsa", "ec"):
|
||||
raise KeyParsingError('unknown key type "%s"' % account_key_type)
|
||||
|
||||
openssl_keydump_cmd = [self.openssl_binary, account_key_type, "-in", key_file, "-noout", "-text"]
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_keydump_cmd, check_rc=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
|
||||
if account_key_type == 'rsa':
|
||||
pub_hex, pub_exp = re.search(
|
||||
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
|
||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL).groups()
|
||||
pub_exp = "{0:x}".format(int(pub_exp))
|
||||
if len(pub_exp) % 2:
|
||||
pub_exp = "0{0}".format(pub_exp)
|
||||
|
||||
return {
|
||||
'key_file': key_file,
|
||||
'type': 'rsa',
|
||||
'alg': 'RS256',
|
||||
'jwk': {
|
||||
"kty": "RSA",
|
||||
"e": nopad_b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
|
||||
"n": nopad_b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
|
||||
},
|
||||
'hash': 'sha256',
|
||||
}
|
||||
elif account_key_type == 'ec':
|
||||
pub_data = re.search(
|
||||
r"pub:\s*\n\s+04:([a-f0-9\:\s]+?)\nASN1 OID: (\S+)(?:\nNIST CURVE: (\S+))?",
|
||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
|
||||
if pub_data is None:
|
||||
raise KeyParsingError('cannot parse elliptic curve key')
|
||||
pub_hex = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_data.group(1)).encode("utf-8"))
|
||||
asn1_oid_curve = pub_data.group(2).lower()
|
||||
nist_curve = pub_data.group(3).lower() if pub_data.group(3) else None
|
||||
if asn1_oid_curve == 'prime256v1' or nist_curve == 'p-256':
|
||||
bits = 256
|
||||
alg = 'ES256'
|
||||
hashalg = 'sha256'
|
||||
point_size = 32
|
||||
curve = 'P-256'
|
||||
elif asn1_oid_curve == 'secp384r1' or nist_curve == 'p-384':
|
||||
bits = 384
|
||||
alg = 'ES384'
|
||||
hashalg = 'sha384'
|
||||
point_size = 48
|
||||
curve = 'P-384'
|
||||
elif asn1_oid_curve == 'secp521r1' or nist_curve == 'p-521':
|
||||
# Not yet supported on Let's Encrypt side, see
|
||||
# https://github.com/letsencrypt/boulder/issues/2217
|
||||
bits = 521
|
||||
alg = 'ES512'
|
||||
hashalg = 'sha512'
|
||||
point_size = 66
|
||||
curve = 'P-521'
|
||||
else:
|
||||
raise KeyParsingError('unknown elliptic curve: %s / %s' % (asn1_oid_curve, nist_curve))
|
||||
num_bytes = (bits + 7) // 8
|
||||
if len(pub_hex) != 2 * num_bytes:
|
||||
raise KeyParsingError('bad elliptic curve point (%s / %s)' % (asn1_oid_curve, nist_curve))
|
||||
return {
|
||||
'key_file': key_file,
|
||||
'type': 'ec',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
"kty": "EC",
|
||||
"crv": curve,
|
||||
"x": nopad_b64(pub_hex[:num_bytes]),
|
||||
"y": nopad_b64(pub_hex[num_bytes:]),
|
||||
},
|
||||
'hash': hashalg,
|
||||
'point_size': point_size,
|
||||
}
|
||||
|
||||
def sign(self, payload64, protected64, key_data):
|
||||
sign_payload = "{0}.{1}".format(protected64, payload64).encode('utf8')
|
||||
if key_data['type'] == 'hmac':
|
||||
hex_key = to_native(binascii.hexlify(base64.urlsafe_b64decode(key_data['jwk']['k'])))
|
||||
cmd_postfix = ["-mac", "hmac", "-macopt", "hexkey:{0}".format(hex_key), "-binary"]
|
||||
else:
|
||||
cmd_postfix = ["-sign", key_data['key_file']]
|
||||
openssl_sign_cmd = [self.openssl_binary, "dgst", "-{0}".format(key_data['hash'])] + cmd_postfix
|
||||
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_sign_cmd, data=sign_payload, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
|
||||
if key_data['type'] == 'ec':
|
||||
dummy, der_out, dummy = self.module.run_command(
|
||||
[self.openssl_binary, "asn1parse", "-inform", "DER"],
|
||||
data=out, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
expected_len = 2 * key_data['point_size']
|
||||
sig = re.findall(
|
||||
r"prim:\s+INTEGER\s+:([0-9A-F]{1,%s})\n" % expected_len,
|
||||
to_text(der_out, errors='surrogate_or_strict'))
|
||||
if len(sig) != 2:
|
||||
raise BackendException(
|
||||
"failed to generate Elliptic Curve signature; cannot parse DER output: {0}".format(
|
||||
to_text(der_out, errors='surrogate_or_strict')))
|
||||
sig[0] = (expected_len - len(sig[0])) * '0' + sig[0]
|
||||
sig[1] = (expected_len - len(sig[1])) * '0' + sig[1]
|
||||
out = binascii.unhexlify(sig[0]) + binascii.unhexlify(sig[1])
|
||||
|
||||
return {
|
||||
"protected": protected64,
|
||||
"payload": payload64,
|
||||
"signature": nopad_b64(to_bytes(out)),
|
||||
}
|
||||
|
||||
def create_mac_key(self, alg, key):
|
||||
'''Create a MAC key.'''
|
||||
if alg == 'HS256':
|
||||
hashalg = 'sha256'
|
||||
hashbytes = 32
|
||||
elif alg == 'HS384':
|
||||
hashalg = 'sha384'
|
||||
hashbytes = 48
|
||||
elif alg == 'HS512':
|
||||
hashalg = 'sha512'
|
||||
hashbytes = 64
|
||||
else:
|
||||
raise BackendException('Unsupported MAC key algorithm for OpenSSL backend: {0}'.format(alg))
|
||||
key_bytes = base64.urlsafe_b64decode(key)
|
||||
if len(key_bytes) < hashbytes:
|
||||
raise BackendException(
|
||||
'{0} key must be at least {1} bytes long (after Base64 decoding)'.format(alg, hashbytes))
|
||||
return {
|
||||
'type': 'hmac',
|
||||
'alg': alg,
|
||||
'jwk': {
|
||||
'kty': 'oct',
|
||||
'k': key,
|
||||
},
|
||||
'hash': hashalg,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ip(ip):
|
||||
try:
|
||||
return to_native(compat_ipaddress.ip_address(to_text(ip)).compressed)
|
||||
except ValueError:
|
||||
# We don't want to error out on something IPAddress() can't parse
|
||||
return ip
|
||||
|
||||
def get_csr_identifiers(self, csr_filename=None, csr_content=None):
|
||||
'''
|
||||
Return a set of requested identifiers (CN and SANs) for the CSR.
|
||||
Each identifier is a pair (type, identifier), where type is either
|
||||
'dns' or 'ip'.
|
||||
'''
|
||||
filename = csr_filename
|
||||
data = None
|
||||
if csr_content is not None:
|
||||
filename = '/dev/stdin'
|
||||
data = csr_content.encode('utf-8')
|
||||
|
||||
openssl_csr_cmd = [self.openssl_binary, "req", "-in", filename, "-noout", "-text"]
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_csr_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
|
||||
identifiers = set([])
|
||||
common_name = re.search(r"Subject:.* CN\s?=\s?([^\s,;/]+)", to_text(out, errors='surrogate_or_strict'))
|
||||
if common_name is not None:
|
||||
identifiers.add(('dns', common_name.group(1)))
|
||||
subject_alt_names = re.search(
|
||||
r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n",
|
||||
to_text(out, errors='surrogate_or_strict'), re.MULTILINE | re.DOTALL)
|
||||
if subject_alt_names is not None:
|
||||
for san in subject_alt_names.group(1).split(", "):
|
||||
if san.lower().startswith("dns:"):
|
||||
identifiers.add(('dns', san[4:]))
|
||||
elif san.lower().startswith("ip:"):
|
||||
identifiers.add(('ip', self._normalize_ip(san[3:])))
|
||||
elif san.lower().startswith("ip address:"):
|
||||
identifiers.add(('ip', self._normalize_ip(san[11:])))
|
||||
else:
|
||||
raise BackendException('Found unsupported SAN identifier "{0}"'.format(san))
|
||||
return identifiers
|
||||
|
||||
def get_cert_days(self, cert_filename=None, cert_content=None, now=None):
|
||||
'''
|
||||
Return the days the certificate in cert_filename remains valid and -1
|
||||
if the file was not found. If cert_filename contains more than one
|
||||
certificate, only the first one will be considered.
|
||||
|
||||
If now is not specified, datetime.datetime.now() is used.
|
||||
'''
|
||||
filename = cert_filename
|
||||
data = None
|
||||
if cert_content is not None:
|
||||
filename = '/dev/stdin'
|
||||
data = cert_content.encode('utf-8')
|
||||
cert_filename_suffix = ''
|
||||
elif cert_filename is not None:
|
||||
if not os.path.exists(cert_filename):
|
||||
return -1
|
||||
cert_filename_suffix = ' in {0}'.format(cert_filename)
|
||||
else:
|
||||
return -1
|
||||
|
||||
openssl_cert_cmd = [self.openssl_binary, "x509", "-in", filename, "-noout", "-text"]
|
||||
dummy, out, dummy = self.module.run_command(
|
||||
openssl_cert_cmd, data=data, check_rc=True, binary_data=True, environ_update=_OPENSSL_ENVIRONMENT_UPDATE)
|
||||
try:
|
||||
not_after_str = re.search(r"\s+Not After\s*:\s+(.*)", to_text(out, errors='surrogate_or_strict')).group(1)
|
||||
not_after = datetime.datetime.strptime(not_after_str, '%b %d %H:%M:%S %Y %Z')
|
||||
except AttributeError:
|
||||
raise BackendException("No 'Not after' date found{0}".format(cert_filename_suffix))
|
||||
except ValueError:
|
||||
raise BackendException("Failed to parse 'Not after' date{0}".format(cert_filename_suffix))
|
||||
if now is None:
|
||||
now = datetime.datetime.now()
|
||||
return (not_after - now).days
|
||||
|
||||
def create_chain_matcher(self, criterium):
|
||||
'''
|
||||
Given a Criterium object, creates a ChainMatcher object.
|
||||
'''
|
||||
raise BackendException('Alternate chain matching can only be used with the "cryptography" backend.')
|
||||
58
plugins/module_utils/acme/backends.py
Normal file
58
plugins/module_utils/acme/backends.py
Normal 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.
|
||||
'''
|
||||
128
plugins/module_utils/acme/certificates.py
Normal file
128
plugins/module_utils/acme/certificates.py
Normal 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
|
||||
299
plugins/module_utils/acme/challenges.py
Normal file
299
plugins/module_utils/acme/challenges.py
Normal file
@@ -0,0 +1,299 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from ansible.module_utils.common.text.converters import to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
|
||||
nopad_b64,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
|
||||
format_error_problem,
|
||||
ACMEProtocolException,
|
||||
ModuleFailException,
|
||||
)
|
||||
|
||||
|
||||
def create_key_authorization(client, token):
|
||||
'''
|
||||
Returns the key authorization for the given token
|
||||
https://tools.ietf.org/html/rfc8555#section-8.1
|
||||
'''
|
||||
accountkey_json = json.dumps(client.account_jwk, sort_keys=True, separators=(',', ':'))
|
||||
thumbprint = nopad_b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
|
||||
return "{0}.{1}".format(token, thumbprint)
|
||||
|
||||
|
||||
def combine_identifier(identifier_type, identifier):
|
||||
return '{type}:{identifier}'.format(type=identifier_type, identifier=identifier)
|
||||
|
||||
|
||||
def split_identifier(identifier):
|
||||
parts = identifier.split(':', 1)
|
||||
if len(parts) != 2:
|
||||
raise ModuleFailException(
|
||||
'Identifier "{identifier}" is not of the form <type>:<identifier>'.format(identifier=identifier))
|
||||
return parts
|
||||
|
||||
|
||||
class Challenge(object):
|
||||
def __init__(self, data, url):
|
||||
self.data = data
|
||||
|
||||
self.type = data['type']
|
||||
self.url = url
|
||||
self.status = data['status']
|
||||
self.token = data.get('token')
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, client, data, url=None):
|
||||
return cls(data, url or (data['uri'] if client.version == 1 else data['url']))
|
||||
|
||||
def call_validate(self, client):
|
||||
challenge_response = {}
|
||||
if client.version == 1:
|
||||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
|
||||
key_authorization = create_key_authorization(client, token)
|
||||
challenge_response['resource'] = 'challenge'
|
||||
challenge_response['keyAuthorization'] = key_authorization
|
||||
challenge_response['type'] = self.type
|
||||
client.send_signed_request(
|
||||
self.url,
|
||||
challenge_response,
|
||||
error_msg='Failed to validate challenge',
|
||||
expected_status_codes=[200, 202],
|
||||
)
|
||||
|
||||
def to_json(self):
|
||||
return self.data.copy()
|
||||
|
||||
def get_validation_data(self, client, identifier_type, identifier):
|
||||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", self.token)
|
||||
key_authorization = create_key_authorization(client, token)
|
||||
|
||||
if self.type == 'http-01':
|
||||
# https://tools.ietf.org/html/rfc8555#section-8.3
|
||||
return {
|
||||
'resource': '.well-known/acme-challenge/{token}'.format(token=token),
|
||||
'resource_value': key_authorization,
|
||||
}
|
||||
|
||||
if self.type == 'dns-01':
|
||||
if identifier_type != 'dns':
|
||||
return None
|
||||
# https://tools.ietf.org/html/rfc8555#section-8.4
|
||||
resource = '_acme-challenge'
|
||||
value = nopad_b64(hashlib.sha256(to_bytes(key_authorization)).digest())
|
||||
record = (resource + identifier[1:]) if identifier.startswith('*.') else '{0}.{1}'.format(resource, identifier)
|
||||
return {
|
||||
'resource': resource,
|
||||
'resource_value': value,
|
||||
'record': record,
|
||||
}
|
||||
|
||||
if self.type == 'tls-alpn-01':
|
||||
# https://www.rfc-editor.org/rfc/rfc8737.html#section-3
|
||||
if identifier_type == 'ip':
|
||||
# IPv4/IPv6 address: use reverse mapping (RFC1034, RFC3596)
|
||||
resource = compat_ipaddress.ip_address(identifier).reverse_pointer
|
||||
if not resource.endswith('.'):
|
||||
resource += '.'
|
||||
else:
|
||||
resource = identifier
|
||||
value = base64.b64encode(hashlib.sha256(to_bytes(key_authorization)).digest())
|
||||
return {
|
||||
'resource': resource,
|
||||
'resource_original': combine_identifier(identifier_type, identifier),
|
||||
'resource_value': value,
|
||||
}
|
||||
|
||||
# Unknown challenge type: ignore
|
||||
return None
|
||||
|
||||
|
||||
class Authorization(object):
|
||||
def _setup(self, client, data):
|
||||
data['uri'] = self.url
|
||||
self.data = data
|
||||
self.challenges = [Challenge.from_json(client, challenge) for challenge in data['challenges']]
|
||||
if client.version == 1 and 'status' not in data:
|
||||
# https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2
|
||||
# "status (required, string): ...
|
||||
# If this field is missing, then the default value is "pending"."
|
||||
self.status = 'pending'
|
||||
else:
|
||||
self.status = data['status']
|
||||
self.identifier = data['identifier']['value']
|
||||
self.identifier_type = data['identifier']['type']
|
||||
if data.get('wildcard', False):
|
||||
self.identifier = '*.{0}'.format(self.identifier)
|
||||
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
|
||||
self.data = None
|
||||
self.challenges = []
|
||||
self.status = None
|
||||
self.identifier_type = None
|
||||
self.identifier = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, client, data, url):
|
||||
result = cls(url)
|
||||
result._setup(client, data)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_url(cls, client, url):
|
||||
result = cls(url)
|
||||
result.refresh(client)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def create(cls, client, identifier_type, identifier):
|
||||
'''
|
||||
Create a new authorization for the given identifier.
|
||||
Return the authorization object of the new authorization
|
||||
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.4
|
||||
'''
|
||||
new_authz = {
|
||||
"identifier": {
|
||||
"type": identifier_type,
|
||||
"value": identifier,
|
||||
},
|
||||
}
|
||||
if client.version == 1:
|
||||
url = client.directory['new-authz']
|
||||
new_authz["resource"] = "new-authz"
|
||||
else:
|
||||
if 'newAuthz' not in client.directory.directory:
|
||||
raise ACMEProtocolException(client.module, 'ACME endpoint does not support pre-authorization')
|
||||
url = client.directory['newAuthz']
|
||||
|
||||
result, info = client.send_signed_request(
|
||||
url, new_authz, error_msg='Failed to request challenges', expected_status_codes=[200, 201])
|
||||
return cls.from_json(client, result, info['location'])
|
||||
|
||||
@property
|
||||
def combined_identifier(self):
|
||||
return combine_identifier(self.identifier_type, self.identifier)
|
||||
|
||||
def to_json(self):
|
||||
return self.data.copy()
|
||||
|
||||
def refresh(self, client):
|
||||
result, dummy = client.get_request(self.url)
|
||||
changed = self.data != result
|
||||
self._setup(client, result)
|
||||
return changed
|
||||
|
||||
def get_challenge_data(self, client):
|
||||
'''
|
||||
Returns a dict with the data for all proposed (and supported) challenges
|
||||
of the given authorization.
|
||||
'''
|
||||
data = {}
|
||||
for challenge in self.challenges:
|
||||
validation_data = challenge.get_validation_data(client, self.identifier_type, self.identifier)
|
||||
if validation_data is not None:
|
||||
data[challenge.type] = validation_data
|
||||
return data
|
||||
|
||||
def raise_error(self, error_msg, module=None):
|
||||
'''
|
||||
Aborts with a specific error for a challenge.
|
||||
'''
|
||||
error_details = []
|
||||
# multiple challenges could have failed at this point, gather error
|
||||
# details for all of them before failing
|
||||
for challenge in self.challenges:
|
||||
if challenge.status == 'invalid':
|
||||
msg = 'Challenge {type}'.format(type=challenge.type)
|
||||
if 'error' in challenge.data:
|
||||
msg = '{msg}: {problem}'.format(
|
||||
msg=msg,
|
||||
problem=format_error_problem(challenge.data['error'], subproblem_prefix='{0}.'.format(challenge.type)),
|
||||
)
|
||||
error_details.append(msg)
|
||||
raise ACMEProtocolException(
|
||||
module,
|
||||
'Failed to validate challenge for {identifier}: {error}. {details}'.format(
|
||||
identifier=self.combined_identifier,
|
||||
error=error_msg,
|
||||
details='; '.join(error_details),
|
||||
),
|
||||
extras=dict(
|
||||
identifier=self.combined_identifier,
|
||||
authorization=self.data,
|
||||
),
|
||||
)
|
||||
|
||||
def find_challenge(self, challenge_type):
|
||||
for challenge in self.challenges:
|
||||
if challenge_type == challenge.type:
|
||||
return challenge
|
||||
return None
|
||||
|
||||
def wait_for_validation(self, client, callenge_type):
|
||||
while True:
|
||||
self.refresh(client)
|
||||
if self.status in ['valid', 'invalid', 'revoked']:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if self.status == 'invalid':
|
||||
self.raise_error('Status is "invalid"', module=client.module)
|
||||
|
||||
return self.status == 'valid'
|
||||
|
||||
def call_validate(self, client, challenge_type, wait=True):
|
||||
'''
|
||||
Validate the authorization provided in the auth dict. Returns True
|
||||
when the validation was successful and False when it was not.
|
||||
'''
|
||||
challenge = self.find_challenge(challenge_type)
|
||||
if challenge is None:
|
||||
raise ModuleFailException('Found no challenge of type "{challenge}" for identifier {identifier}!'.format(
|
||||
challenge=challenge_type,
|
||||
identifier=self.combined_identifier,
|
||||
))
|
||||
|
||||
challenge.call_validate(client)
|
||||
|
||||
if not wait:
|
||||
return self.status == 'valid'
|
||||
return self.wait_for_validation(client, challenge_type)
|
||||
|
||||
def deactivate(self, client):
|
||||
'''
|
||||
Deactivates this authorization.
|
||||
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
|
||||
https://tools.ietf.org/html/rfc8555#section-7.5.2
|
||||
'''
|
||||
if self.status != 'valid':
|
||||
return
|
||||
authz_deactivate = {
|
||||
'status': 'deactivated'
|
||||
}
|
||||
if client.version == 1:
|
||||
authz_deactivate['resource'] = 'authz'
|
||||
result, info = client.send_signed_request(self.url, authz_deactivate, fail_on_error=False)
|
||||
if 200 <= info['status'] < 300 and result.get('status') == 'deactivated':
|
||||
self.status = 'deactivated'
|
||||
return True
|
||||
return False
|
||||
131
plugins/module_utils/acme/errors.py
Normal file
131
plugins/module_utils/acme/errors.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2016 Michael Gruener <michael.gruener@chaosmoon.net>
|
||||
# Copyright: (c) 2021 Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.module_utils.six import binary_type
|
||||
from ansible.module_utils.common.text.converters import to_text
|
||||
|
||||
|
||||
def format_error_problem(problem, subproblem_prefix=''):
|
||||
if 'title' in problem:
|
||||
msg = 'Error "{title}" ({type})'.format(
|
||||
type=problem['type'],
|
||||
title=problem['title'],
|
||||
)
|
||||
else:
|
||||
msg = 'Error {type}'.format(type=problem['type'])
|
||||
if 'detail' in problem:
|
||||
msg += ': "{detail}"'.format(detail=problem['detail'])
|
||||
subproblems = problem.get('subproblems')
|
||||
if subproblems is not None:
|
||||
msg = '{msg} Subproblems:'.format(msg=msg)
|
||||
for index, problem in enumerate(subproblems):
|
||||
index_str = '{prefix}{index}'.format(prefix=subproblem_prefix, index=index)
|
||||
msg = '{msg}\n({index}) {problem}'.format(
|
||||
msg=msg,
|
||||
index=index_str,
|
||||
problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index_str)),
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
class ModuleFailException(Exception):
|
||||
'''
|
||||
If raised, module.fail_json() will be called with the given parameters after cleanup.
|
||||
'''
|
||||
def __init__(self, msg, **args):
|
||||
super(ModuleFailException, self).__init__(self, msg)
|
||||
self.msg = msg
|
||||
self.module_fail_args = args
|
||||
|
||||
def do_fail(self, module, **arguments):
|
||||
module.fail_json(msg=self.msg, other=self.module_fail_args, **arguments)
|
||||
|
||||
|
||||
class ACMEProtocolException(ModuleFailException):
|
||||
def __init__(self, module, msg=None, info=None, response=None, content=None, content_json=None, extras=None):
|
||||
# Try to get hold of content, if response is given and content is not provided
|
||||
if content is None and content_json is None and response is not None:
|
||||
try:
|
||||
content = response.read()
|
||||
except AttributeError:
|
||||
content = info.pop('body', None)
|
||||
|
||||
# Make sure that content_json is None or a dictionary
|
||||
if content_json is not None and not isinstance(content_json, dict):
|
||||
if content is None and isinstance(content_json, binary_type):
|
||||
content = content_json
|
||||
content_json = None
|
||||
|
||||
# Try to get hold of JSON decoded content, when content is given and JSON not provided
|
||||
if content_json is None and content is not None and module is not None:
|
||||
try:
|
||||
content_json = module.from_json(to_text(content))
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
extras = extras or dict()
|
||||
|
||||
if msg is None:
|
||||
msg = 'ACME request failed'
|
||||
add_msg = ''
|
||||
|
||||
if info is not None:
|
||||
url = info['url']
|
||||
code = info['status']
|
||||
extras['http_url'] = url
|
||||
extras['http_status'] = code
|
||||
if code is not None and code >= 400 and content_json is not None and 'type' in content_json:
|
||||
if 'status' in content_json and content_json['status'] != code:
|
||||
code = 'status {problem_code} (HTTP status: {http_code})'.format(http_code=code, problem_code=content_json['status'])
|
||||
else:
|
||||
code = 'status {problem_code}'.format(problem_code=code)
|
||||
subproblems = content_json.pop('subproblems', None)
|
||||
add_msg = ' {problem}.'.format(problem=format_error_problem(content_json))
|
||||
extras['problem'] = content_json
|
||||
extras['subproblems'] = subproblems or []
|
||||
if subproblems is not None:
|
||||
add_msg = '{add_msg} Subproblems:'.format(add_msg=add_msg)
|
||||
for index, problem in enumerate(subproblems):
|
||||
add_msg = '{add_msg}\n({index}) {problem}.'.format(
|
||||
add_msg=add_msg,
|
||||
index=index,
|
||||
problem=format_error_problem(problem, subproblem_prefix='{0}.'.format(index)),
|
||||
)
|
||||
else:
|
||||
code = 'HTTP status {code}'.format(code=code)
|
||||
if content_json is not None:
|
||||
add_msg = ' The JSON error result: {content}'.format(content=content_json)
|
||||
elif content is not None:
|
||||
add_msg = ' The raw error result: {content}'.format(content=to_text(content))
|
||||
msg = '{msg} for {url} with {code}'.format(msg=msg, url=url, code=code)
|
||||
elif content_json is not None:
|
||||
add_msg = ' The JSON result: {content}'.format(content=content_json)
|
||||
elif content is not None:
|
||||
add_msg = ' The raw result: {content}'.format(content=to_text(content))
|
||||
|
||||
super(ACMEProtocolException, self).__init__(
|
||||
'{msg}.{add_msg}'.format(msg=msg, add_msg=add_msg),
|
||||
**extras
|
||||
)
|
||||
self.problem = {}
|
||||
self.subproblems = []
|
||||
for k, v in extras.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
class BackendException(ModuleFailException):
|
||||
pass
|
||||
|
||||
|
||||
class NetworkException(ModuleFailException):
|
||||
pass
|
||||
|
||||
|
||||
class KeyParsingError(ModuleFailException):
|
||||
pass
|
||||
86
plugins/module_utils/acme/io.py
Normal file
86
plugins/module_utils/acme/io.py
Normal 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
|
||||
129
plugins/module_utils/acme/orders.py
Normal file
129
plugins/module_utils/acme/orders.py
Normal 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)
|
||||
71
plugins/module_utils/acme/utils.py
Normal file
71
plugins/module_utils/acme/utils.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
"""
|
||||
|
||||
@@ -23,7 +23,7 @@ import base64
|
||||
import binascii
|
||||
import re
|
||||
|
||||
from ansible.module_utils._text import to_text
|
||||
from ansible.module_utils.common.text.converters import to_text, to_bytes
|
||||
from ._asn1 import serialize_asn1_string_as_der
|
||||
|
||||
try:
|
||||
@@ -35,6 +35,15 @@ 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
|
||||
|
||||
from .basic import (
|
||||
CRYPTOGRAPHY_HAS_ED25519,
|
||||
CRYPTOGRAPHY_HAS_ED448,
|
||||
@@ -428,3 +437,22 @@ 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_key_and_certificates is None:
|
||||
raise ValueError('load_key_and_certificates() not present in the current cryptography version')
|
||||
private_key, certificate, additional_certificates = _load_key_and_certificates(
|
||||
pkcs12_bytes, to_bytes(passphrase) if passphrase is not None else None)
|
||||
|
||||
friendly_name = None
|
||||
if certificate:
|
||||
# See https://github.com/pyca/cryptography/issues/5760#issuecomment-842687238
|
||||
maybe_name = certificate._backend._lib.X509_alias_get0(
|
||||
certificate._x509, certificate._backend._ffi.NULL)
|
||||
if maybe_name != certificate._backend._ffi.NULL:
|
||||
friendly_name = certificate._backend._ffi.string(maybe_name)
|
||||
|
||||
return private_key, certificate, additional_certificates, friendly_name
|
||||
|
||||
@@ -33,6 +33,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptograp
|
||||
cryptography_compare_public_keys,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.certificate_info import (
|
||||
get_certificate_info,
|
||||
)
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
@@ -95,6 +99,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 +125,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."""
|
||||
@@ -284,13 +302,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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ __metaclass__ = type
|
||||
import abc
|
||||
import datetime
|
||||
|
||||
from ansible.module_utils._text import to_native, to_bytes, to_text
|
||||
from ansible.module_utils.common.text.converters import to_native, to_bytes, to_text
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
parse_name_field,
|
||||
@@ -177,25 +177,25 @@ class AssertOnlyCertificateBackend(CertificateBackend):
|
||||
if self.privatekey_path is not None or self.privatekey_content is not None:
|
||||
if 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)')
|
||||
'Certificate and private key %s do not match' %
|
||||
(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)')
|
||||
'Certificate and CSR %s do not match: private key mismatch' %
|
||||
(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)')
|
||||
'Certificate and CSR %s do not match: subject mismatch' %
|
||||
(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)')
|
||||
'Certificate and CSR %s do not match: extensions mismatch' %
|
||||
(self.csr_path or '(provided in module options)')
|
||||
)
|
||||
|
||||
if self.signature_algorithms is not None:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
565
plugins/module_utils/crypto/module_backends/certificate_info.py
Normal file
565
plugins/module_utils/crypto/module_backends/certificate_info.py
Normal file
@@ -0,0 +1,565 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
|
||||
# Copyright: (c) 2020, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import abc
|
||||
import binascii
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible.module_utils import six
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
load_certificate,
|
||||
get_fingerprint_of_bytes,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
|
||||
cryptography_decode_name,
|
||||
cryptography_get_extensions_from_cert,
|
||||
cryptography_oid_to_name,
|
||||
cryptography_serial_number_of_cert,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import (
|
||||
pyopenssl_get_extensions_from_cert,
|
||||
pyopenssl_normalize_name,
|
||||
pyopenssl_normalize_name_attribute,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
|
||||
get_publickey_info,
|
||||
)
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.6'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# OpenSSL 1.1.0 or newer
|
||||
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
|
||||
else:
|
||||
# OpenSSL 1.0.x or older
|
||||
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class CertificateInfoRetrieval(object):
|
||||
def __init__(self, module, backend, content):
|
||||
# content must be a bytes string
|
||||
self.module = module
|
||||
self.backend = backend
|
||||
self.content = content
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_der_bytes(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_signature_algorithm(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_ordered(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_issuer_ordered(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_version(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_extended_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_basic_constraints(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_ocsp_must_staple(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_alt_name(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_not_before(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_not_after(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key_pem(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key_object(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_authority_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_serial_number(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_all_extensions(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_ocsp_uri(self):
|
||||
pass
|
||||
|
||||
def get_info(self, prefer_one_fingerprint=False):
|
||||
result = dict()
|
||||
self.cert = load_certificate(None, content=self.content, backend=self.backend)
|
||||
|
||||
result['signature_algorithm'] = self._get_signature_algorithm()
|
||||
subject = self._get_subject_ordered()
|
||||
issuer = self._get_issuer_ordered()
|
||||
result['subject'] = dict()
|
||||
for k, v in subject:
|
||||
result['subject'][k] = v
|
||||
result['subject_ordered'] = subject
|
||||
result['issuer'] = dict()
|
||||
for k, v in issuer:
|
||||
result['issuer'][k] = v
|
||||
result['issuer_ordered'] = issuer
|
||||
result['version'] = self._get_version()
|
||||
result['key_usage'], result['key_usage_critical'] = self._get_key_usage()
|
||||
result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage()
|
||||
result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints()
|
||||
result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple()
|
||||
result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name()
|
||||
|
||||
not_before = self.get_not_before()
|
||||
not_after = self.get_not_after()
|
||||
result['not_before'] = not_before.strftime(TIMESTAMP_FORMAT)
|
||||
result['not_after'] = not_after.strftime(TIMESTAMP_FORMAT)
|
||||
result['expired'] = not_after < datetime.datetime.utcnow()
|
||||
|
||||
result['public_key'] = self._get_public_key_pem()
|
||||
|
||||
public_key_info = get_publickey_info(
|
||||
self.module,
|
||||
self.backend,
|
||||
key=self._get_public_key_object(),
|
||||
prefer_one_fingerprint=prefer_one_fingerprint)
|
||||
result.update({
|
||||
'public_key_type': public_key_info['type'],
|
||||
'public_key_data': public_key_info['public_data'],
|
||||
'public_key_fingerprints': public_key_info['fingerprints'],
|
||||
})
|
||||
|
||||
result['fingerprints'] = get_fingerprint_of_bytes(
|
||||
self._get_der_bytes(), prefer_one=prefer_one_fingerprint)
|
||||
|
||||
if self.backend != 'pyopenssl':
|
||||
ski = self._get_subject_key_identifier()
|
||||
if ski is not None:
|
||||
ski = to_native(binascii.hexlify(ski))
|
||||
ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)])
|
||||
result['subject_key_identifier'] = ski
|
||||
|
||||
aki, aci, acsn = self._get_authority_key_identifier()
|
||||
if aki is not None:
|
||||
aki = to_native(binascii.hexlify(aki))
|
||||
aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)])
|
||||
result['authority_key_identifier'] = aki
|
||||
result['authority_cert_issuer'] = aci
|
||||
result['authority_cert_serial_number'] = acsn
|
||||
|
||||
result['serial_number'] = self._get_serial_number()
|
||||
result['extensions_by_oid'] = self._get_all_extensions()
|
||||
result['ocsp_uri'] = self._get_ocsp_uri()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class CertificateInfoRetrievalCryptography(CertificateInfoRetrieval):
|
||||
"""Validate the supplied cert, using the cryptography backend"""
|
||||
def __init__(self, module, content):
|
||||
super(CertificateInfoRetrievalCryptography, self).__init__(module, 'cryptography', content)
|
||||
|
||||
def _get_der_bytes(self):
|
||||
return self.cert.public_bytes(serialization.Encoding.DER)
|
||||
|
||||
def _get_signature_algorithm(self):
|
||||
return cryptography_oid_to_name(self.cert.signature_algorithm_oid)
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
result = []
|
||||
for attribute in self.cert.subject:
|
||||
result.append([cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
return result
|
||||
|
||||
def _get_issuer_ordered(self):
|
||||
result = []
|
||||
for attribute in self.cert.issuer:
|
||||
result.append([cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
return result
|
||||
|
||||
def _get_version(self):
|
||||
if self.cert.version == x509.Version.v1:
|
||||
return 1
|
||||
if self.cert.version == x509.Version.v3:
|
||||
return 3
|
||||
return "unknown"
|
||||
|
||||
def _get_key_usage(self):
|
||||
try:
|
||||
current_key_ext = self.cert.extensions.get_extension_for_class(x509.KeyUsage)
|
||||
current_key_usage = current_key_ext.value
|
||||
key_usage = dict(
|
||||
digital_signature=current_key_usage.digital_signature,
|
||||
content_commitment=current_key_usage.content_commitment,
|
||||
key_encipherment=current_key_usage.key_encipherment,
|
||||
data_encipherment=current_key_usage.data_encipherment,
|
||||
key_agreement=current_key_usage.key_agreement,
|
||||
key_cert_sign=current_key_usage.key_cert_sign,
|
||||
crl_sign=current_key_usage.crl_sign,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
)
|
||||
if key_usage['key_agreement']:
|
||||
key_usage.update(dict(
|
||||
encipher_only=current_key_usage.encipher_only,
|
||||
decipher_only=current_key_usage.decipher_only
|
||||
))
|
||||
|
||||
key_usage_names = dict(
|
||||
digital_signature='Digital Signature',
|
||||
content_commitment='Non Repudiation',
|
||||
key_encipherment='Key Encipherment',
|
||||
data_encipherment='Data Encipherment',
|
||||
key_agreement='Key Agreement',
|
||||
key_cert_sign='Certificate Sign',
|
||||
crl_sign='CRL Sign',
|
||||
encipher_only='Encipher Only',
|
||||
decipher_only='Decipher Only',
|
||||
)
|
||||
return sorted([
|
||||
key_usage_names[name] for name, value in key_usage.items() if value
|
||||
]), current_key_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
|
||||
return sorted([
|
||||
cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value
|
||||
]), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.cert.extensions.get_extension_for_class(x509.BasicConstraints)
|
||||
result = []
|
||||
result.append('CA:{0}'.format('TRUE' if ext_keyusage_ext.value.ca else 'FALSE'))
|
||||
if ext_keyusage_ext.value.path_length is not None:
|
||||
result.append('pathlen:{0}'.format(ext_keyusage_ext.value.path_length))
|
||||
return sorted(result), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
try:
|
||||
try:
|
||||
# This only works with cryptography >= 2.1
|
||||
tlsfeature_ext = self.cert.extensions.get_extension_for_class(x509.TLSFeature)
|
||||
value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
|
||||
except AttributeError:
|
||||
# Fallback for cryptography < 2.1
|
||||
oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24")
|
||||
tlsfeature_ext = self.cert.extensions.get_extension_for_oid(oid)
|
||||
value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05"
|
||||
return value, tlsfeature_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
try:
|
||||
san_ext = self.cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
result = [cryptography_decode_name(san) for san in san_ext.value]
|
||||
return result, san_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def get_not_before(self):
|
||||
return self.cert.not_valid_before
|
||||
|
||||
def get_not_after(self):
|
||||
return self.cert.not_valid_after
|
||||
|
||||
def _get_public_key_pem(self):
|
||||
return self.cert.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
def _get_public_key_object(self):
|
||||
return self.cert.public_key()
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
try:
|
||||
ext = self.cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
||||
return ext.value.digest
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
try:
|
||||
ext = self.cert.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
|
||||
issuer = None
|
||||
if ext.value.authority_cert_issuer is not None:
|
||||
issuer = [cryptography_decode_name(san) for san in ext.value.authority_cert_issuer]
|
||||
return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, None, None
|
||||
|
||||
def _get_serial_number(self):
|
||||
return cryptography_serial_number_of_cert(self.cert)
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return cryptography_get_extensions_from_cert(self.cert)
|
||||
|
||||
def _get_ocsp_uri(self):
|
||||
try:
|
||||
ext = self.cert.extensions.get_extension_for_class(x509.AuthorityInformationAccess)
|
||||
for desc in ext.value:
|
||||
if desc.access_method == x509.oid.AuthorityInformationAccessOID.OCSP:
|
||||
if isinstance(desc.access_location, x509.UniformResourceIdentifier):
|
||||
return desc.access_location.value
|
||||
except x509.ExtensionNotFound as dummy:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class CertificateInfoRetrievalPyOpenSSL(CertificateInfoRetrieval):
|
||||
"""validate the supplied certificate."""
|
||||
|
||||
def __init__(self, module, content):
|
||||
super(CertificateInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content)
|
||||
|
||||
def _get_der_bytes(self):
|
||||
return crypto.dump_certificate(crypto.FILETYPE_ASN1, self.cert)
|
||||
|
||||
def _get_signature_algorithm(self):
|
||||
return to_text(self.cert.get_signature_algorithm())
|
||||
|
||||
def __get_name(self, name):
|
||||
result = []
|
||||
for sub in name.get_components():
|
||||
result.append([pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
|
||||
return result
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
return self.__get_name(self.cert.get_subject())
|
||||
|
||||
def _get_issuer_ordered(self):
|
||||
return self.__get_name(self.cert.get_issuer())
|
||||
|
||||
def _get_version(self):
|
||||
# Version numbers in certs are off by one:
|
||||
# v1: 0, v2: 1, v3: 2 ...
|
||||
return self.cert.get_version() + 1
|
||||
|
||||
def _get_extension(self, short_name):
|
||||
for extension_idx in range(0, self.cert.get_extension_count()):
|
||||
extension = self.cert.get_extension(extension_idx)
|
||||
if extension.get_short_name() == short_name:
|
||||
result = [
|
||||
pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
|
||||
]
|
||||
return sorted(result), bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_key_usage(self):
|
||||
return self._get_extension(b'keyUsage')
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
return self._get_extension(b'extendedKeyUsage')
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
return self._get_extension(b'basicConstraints')
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
extensions = [self.cert.get_extension(i) for i in range(0, self.cert.get_extension_count())]
|
||||
oms_ext = [
|
||||
ext for ext in extensions
|
||||
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
|
||||
]
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
|
||||
# Older versions of libssl don't know about OCSP Must Staple
|
||||
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
|
||||
if oms_ext:
|
||||
return True, bool(oms_ext[0].get_critical())
|
||||
else:
|
||||
return None, False
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
for extension_idx in range(0, self.cert.get_extension_count()):
|
||||
extension = self.cert.get_extension(extension_idx)
|
||||
if extension.get_short_name() == b'subjectAltName':
|
||||
result = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
|
||||
to_text(extension, errors='surrogate_or_strict').split(', ')]
|
||||
return result, bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def get_not_before(self):
|
||||
time_string = to_native(self.cert.get_notBefore())
|
||||
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
|
||||
|
||||
def get_not_after(self):
|
||||
time_string = to_native(self.cert.get_notAfter())
|
||||
return datetime.datetime.strptime(time_string, "%Y%m%d%H%M%SZ")
|
||||
|
||||
def _get_public_key_pem(self):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_PEM,
|
||||
self.cert.get_pubkey(),
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
# pyOpenSSL < 16.0:
|
||||
bio = crypto._new_mem_buf()
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.cert.get_pubkey()._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def _get_public_key_object(self):
|
||||
return self.cert.get_pubkey()
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None, None, None
|
||||
|
||||
def _get_serial_number(self):
|
||||
return self.cert.get_serial_number()
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return pyopenssl_get_extensions_from_cert(self.cert)
|
||||
|
||||
def _get_ocsp_uri(self):
|
||||
for i in range(self.cert.get_extension_count()):
|
||||
ext = self.cert.get_extension(i)
|
||||
if ext.get_short_name() == b'authorityInfoAccess':
|
||||
v = str(ext)
|
||||
m = re.search('^OCSP - URI:(.*)$', v, flags=re.MULTILINE)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def get_certificate_info(module, backend, content, prefer_one_fingerprint=False):
|
||||
if backend == 'cryptography':
|
||||
info = CertificateInfoRetrievalCryptography(module, content)
|
||||
elif backend == 'pyopenssl':
|
||||
info = CertificateInfoRetrievalPyOpenSSL(module, content)
|
||||
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
|
||||
|
||||
|
||||
def select_backend(module, backend, content):
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# First try cryptography, then pyOpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
try:
|
||||
getattr(crypto.X509Req, 'get_extensions')
|
||||
except AttributeError:
|
||||
module.fail_json(msg='You need to have PyOpenSSL>=0.15 to generate CSRs')
|
||||
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
return backend, CertificateInfoRetrievalPyOpenSSL(module, content)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
return backend, CertificateInfoRetrievalCryptography(module, content)
|
||||
else:
|
||||
raise ValueError('Unsupported value for backend: {0}'.format(backend))
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
100
plugins/module_utils/crypto/module_backends/crl_info.py
Normal file
100
plugins/module_utils/crypto/module_backends/crl_info.py
Normal 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()
|
||||
@@ -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_text
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
|
||||
OpenSSLObjectError,
|
||||
@@ -48,6 +48,10 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_
|
||||
pyopenssl_parse_name_constraints,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.csr_info import (
|
||||
get_csr_info,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.common import ArgumentSpec
|
||||
|
||||
|
||||
@@ -177,6 +181,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."""
|
||||
@@ -190,6 +208,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."""
|
||||
@@ -238,13 +257,19 @@ 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
|
||||
|
||||
|
||||
@@ -567,7 +592,7 @@ 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)
|
||||
|
||||
@@ -579,8 +604,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:
|
||||
@@ -653,10 +678,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, 'name constraints permitted')) for altname in self.name_constraints_permitted]
|
||||
nc_excl = [str(cryptography_get_name(altname, 'name constraints excluded')) for altname in self.name_constraints_excluded]
|
||||
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:
|
||||
@@ -685,9 +710,9 @@ class CertificateSigningRequestCryptographyBackend(CertificateSigningRequestBack
|
||||
aci = None
|
||||
csr_aci = None
|
||||
if self.authority_cert_issuer is not None:
|
||||
aci = [str(cryptography_get_name(n, 'authority cert issuer')) for n in self.authority_cert_issuer]
|
||||
aci = [to_text(cryptography_get_name(n, 'authority cert issuer')) for n in self.authority_cert_issuer]
|
||||
if ext.value.authority_cert_issuer is not None:
|
||||
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)
|
||||
|
||||
481
plugins/module_utils/crypto/module_backends/csr_info.py
Normal file
481
plugins/module_utils/crypto/module_backends/csr_info.py
Normal file
@@ -0,0 +1,481 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
|
||||
# Copyright: (c) 2020, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import abc
|
||||
import binascii
|
||||
import traceback
|
||||
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible.module_utils import six
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
load_certificate_request,
|
||||
get_fingerprint_of_bytes,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.cryptography_support import (
|
||||
cryptography_decode_name,
|
||||
cryptography_get_extensions_from_csr,
|
||||
cryptography_oid_to_name,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.pyopenssl_support import (
|
||||
pyopenssl_get_extensions_from_csr,
|
||||
pyopenssl_normalize_name,
|
||||
pyopenssl_normalize_name_attribute,
|
||||
pyopenssl_parse_name_constraints,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
|
||||
get_publickey_info,
|
||||
)
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.3'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# OpenSSL 1.1.0 or newer
|
||||
OPENSSL_MUST_STAPLE_NAME = b"tlsfeature"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"status_request"
|
||||
else:
|
||||
# OpenSSL 1.0.x or older
|
||||
OPENSSL_MUST_STAPLE_NAME = b"1.3.6.1.5.5.7.1.24"
|
||||
OPENSSL_MUST_STAPLE_VALUE = b"DER:30:03:02:01:05"
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ"
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class CSRInfoRetrieval(object):
|
||||
def __init__(self, module, backend, content, validate_signature):
|
||||
# content must be a bytes string
|
||||
self.module = module
|
||||
self.backend = backend
|
||||
self.content = content
|
||||
self.validate_signature = validate_signature
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_ordered(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_extended_key_usage(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_basic_constraints(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_ocsp_must_staple(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_alt_name(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_name_constraints(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key_pem(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key_object(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_subject_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_authority_key_identifier(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_all_extensions(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_signature_valid(self):
|
||||
pass
|
||||
|
||||
def get_info(self, prefer_one_fingerprint=False):
|
||||
result = dict()
|
||||
self.csr = load_certificate_request(None, content=self.content, backend=self.backend)
|
||||
|
||||
subject = self._get_subject_ordered()
|
||||
result['subject'] = dict()
|
||||
for k, v in subject:
|
||||
result['subject'][k] = v
|
||||
result['subject_ordered'] = subject
|
||||
result['key_usage'], result['key_usage_critical'] = self._get_key_usage()
|
||||
result['extended_key_usage'], result['extended_key_usage_critical'] = self._get_extended_key_usage()
|
||||
result['basic_constraints'], result['basic_constraints_critical'] = self._get_basic_constraints()
|
||||
result['ocsp_must_staple'], result['ocsp_must_staple_critical'] = self._get_ocsp_must_staple()
|
||||
result['subject_alt_name'], result['subject_alt_name_critical'] = self._get_subject_alt_name()
|
||||
(
|
||||
result['name_constraints_permitted'],
|
||||
result['name_constraints_excluded'],
|
||||
result['name_constraints_critical'],
|
||||
) = self._get_name_constraints()
|
||||
|
||||
result['public_key'] = self._get_public_key_pem()
|
||||
|
||||
public_key_info = get_publickey_info(
|
||||
self.module,
|
||||
self.backend,
|
||||
key=self._get_public_key_object(),
|
||||
prefer_one_fingerprint=prefer_one_fingerprint)
|
||||
result.update({
|
||||
'public_key_type': public_key_info['type'],
|
||||
'public_key_data': public_key_info['public_data'],
|
||||
'public_key_fingerprints': public_key_info['fingerprints'],
|
||||
})
|
||||
|
||||
if self.backend != 'pyopenssl':
|
||||
ski = self._get_subject_key_identifier()
|
||||
if ski is not None:
|
||||
ski = to_native(binascii.hexlify(ski))
|
||||
ski = ':'.join([ski[i:i + 2] for i in range(0, len(ski), 2)])
|
||||
result['subject_key_identifier'] = ski
|
||||
|
||||
aki, aci, acsn = self._get_authority_key_identifier()
|
||||
if aki is not None:
|
||||
aki = to_native(binascii.hexlify(aki))
|
||||
aki = ':'.join([aki[i:i + 2] for i in range(0, len(aki), 2)])
|
||||
result['authority_key_identifier'] = aki
|
||||
result['authority_cert_issuer'] = aci
|
||||
result['authority_cert_serial_number'] = acsn
|
||||
|
||||
result['extensions_by_oid'] = self._get_all_extensions()
|
||||
|
||||
result['signature_valid'] = self._is_signature_valid()
|
||||
if self.validate_signature and not result['signature_valid']:
|
||||
self.module.fail_json(
|
||||
msg='CSR signature is invalid!',
|
||||
**result
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class CSRInfoRetrievalCryptography(CSRInfoRetrieval):
|
||||
"""Validate the supplied CSR, using the cryptography backend"""
|
||||
def __init__(self, module, content, validate_signature):
|
||||
super(CSRInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, validate_signature)
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
result = []
|
||||
for attribute in self.csr.subject:
|
||||
result.append([cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
return result
|
||||
|
||||
def _get_key_usage(self):
|
||||
try:
|
||||
current_key_ext = self.csr.extensions.get_extension_for_class(x509.KeyUsage)
|
||||
current_key_usage = current_key_ext.value
|
||||
key_usage = dict(
|
||||
digital_signature=current_key_usage.digital_signature,
|
||||
content_commitment=current_key_usage.content_commitment,
|
||||
key_encipherment=current_key_usage.key_encipherment,
|
||||
data_encipherment=current_key_usage.data_encipherment,
|
||||
key_agreement=current_key_usage.key_agreement,
|
||||
key_cert_sign=current_key_usage.key_cert_sign,
|
||||
crl_sign=current_key_usage.crl_sign,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
)
|
||||
if key_usage['key_agreement']:
|
||||
key_usage.update(dict(
|
||||
encipher_only=current_key_usage.encipher_only,
|
||||
decipher_only=current_key_usage.decipher_only
|
||||
))
|
||||
|
||||
key_usage_names = dict(
|
||||
digital_signature='Digital Signature',
|
||||
content_commitment='Non Repudiation',
|
||||
key_encipherment='Key Encipherment',
|
||||
data_encipherment='Data Encipherment',
|
||||
key_agreement='Key Agreement',
|
||||
key_cert_sign='Certificate Sign',
|
||||
crl_sign='CRL Sign',
|
||||
encipher_only='Encipher Only',
|
||||
decipher_only='Decipher Only',
|
||||
)
|
||||
return sorted([
|
||||
key_usage_names[name] for name, value in key_usage.items() if value
|
||||
]), current_key_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
|
||||
return sorted([
|
||||
cryptography_oid_to_name(eku) for eku in ext_keyusage_ext.value
|
||||
]), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
try:
|
||||
ext_keyusage_ext = self.csr.extensions.get_extension_for_class(x509.BasicConstraints)
|
||||
result = ['CA:{0}'.format('TRUE' if ext_keyusage_ext.value.ca else 'FALSE')]
|
||||
if ext_keyusage_ext.value.path_length is not None:
|
||||
result.append('pathlen:{0}'.format(ext_keyusage_ext.value.path_length))
|
||||
return sorted(result), ext_keyusage_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
try:
|
||||
try:
|
||||
# This only works with cryptography >= 2.1
|
||||
tlsfeature_ext = self.csr.extensions.get_extension_for_class(x509.TLSFeature)
|
||||
value = cryptography.x509.TLSFeatureType.status_request in tlsfeature_ext.value
|
||||
except AttributeError:
|
||||
# Fallback for cryptography < 2.1
|
||||
oid = x509.oid.ObjectIdentifier("1.3.6.1.5.5.7.1.24")
|
||||
tlsfeature_ext = self.csr.extensions.get_extension_for_oid(oid)
|
||||
value = tlsfeature_ext.value.value == b"\x30\x03\x02\x01\x05"
|
||||
return value, tlsfeature_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
try:
|
||||
san_ext = self.csr.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
result = [cryptography_decode_name(san) for san in san_ext.value]
|
||||
return result, san_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, False
|
||||
|
||||
def _get_name_constraints(self):
|
||||
try:
|
||||
nc_ext = self.csr.extensions.get_extension_for_class(x509.NameConstraints)
|
||||
permitted = [cryptography_decode_name(san) for san in nc_ext.value.permitted_subtrees or []]
|
||||
excluded = [cryptography_decode_name(san) for san in nc_ext.value.excluded_subtrees or []]
|
||||
return permitted, excluded, nc_ext.critical
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, None, False
|
||||
|
||||
def _get_public_key_pem(self):
|
||||
return self.csr.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
|
||||
def _get_public_key_object(self):
|
||||
return self.csr.public_key()
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
try:
|
||||
ext = self.csr.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
|
||||
return ext.value.digest
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
try:
|
||||
ext = self.csr.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
|
||||
issuer = None
|
||||
if ext.value.authority_cert_issuer is not None:
|
||||
issuer = [cryptography_decode_name(san) for san in ext.value.authority_cert_issuer]
|
||||
return ext.value.key_identifier, issuer, ext.value.authority_cert_serial_number
|
||||
except cryptography.x509.ExtensionNotFound:
|
||||
return None, None, None
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return cryptography_get_extensions_from_csr(self.csr)
|
||||
|
||||
def _is_signature_valid(self):
|
||||
return self.csr.is_signature_valid
|
||||
|
||||
|
||||
class CSRInfoRetrievalPyOpenSSL(CSRInfoRetrieval):
|
||||
"""validate the supplied CSR."""
|
||||
|
||||
def __init__(self, module, content, validate_signature):
|
||||
super(CSRInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content, validate_signature)
|
||||
|
||||
def __get_name(self, name):
|
||||
result = []
|
||||
for sub in name.get_components():
|
||||
result.append([pyopenssl_normalize_name(sub[0]), to_text(sub[1])])
|
||||
return result
|
||||
|
||||
def _get_subject_ordered(self):
|
||||
return self.__get_name(self.csr.get_subject())
|
||||
|
||||
def _get_extension(self, short_name):
|
||||
for extension in self.csr.get_extensions():
|
||||
if extension.get_short_name() == short_name:
|
||||
result = [
|
||||
pyopenssl_normalize_name(usage.strip()) for usage in to_text(extension, errors='surrogate_or_strict').split(',')
|
||||
]
|
||||
return sorted(result), bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_key_usage(self):
|
||||
return self._get_extension(b'keyUsage')
|
||||
|
||||
def _get_extended_key_usage(self):
|
||||
return self._get_extension(b'extendedKeyUsage')
|
||||
|
||||
def _get_basic_constraints(self):
|
||||
return self._get_extension(b'basicConstraints')
|
||||
|
||||
def _get_ocsp_must_staple(self):
|
||||
extensions = self.csr.get_extensions()
|
||||
oms_ext = [
|
||||
ext for ext in extensions
|
||||
if to_bytes(ext.get_short_name()) == OPENSSL_MUST_STAPLE_NAME and to_bytes(ext) == OPENSSL_MUST_STAPLE_VALUE
|
||||
]
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER < 0x10100000:
|
||||
# Older versions of libssl don't know about OCSP Must Staple
|
||||
oms_ext.extend([ext for ext in extensions if ext.get_short_name() == b'UNDEF' and ext.get_data() == b'\x30\x03\x02\x01\x05'])
|
||||
if oms_ext:
|
||||
return True, bool(oms_ext[0].get_critical())
|
||||
else:
|
||||
return None, False
|
||||
|
||||
def _get_subject_alt_name(self):
|
||||
for extension in self.csr.get_extensions():
|
||||
if extension.get_short_name() == b'subjectAltName':
|
||||
result = [pyopenssl_normalize_name_attribute(altname.strip()) for altname in
|
||||
to_text(extension, errors='surrogate_or_strict').split(', ')]
|
||||
return result, bool(extension.get_critical())
|
||||
return None, False
|
||||
|
||||
def _get_name_constraints(self):
|
||||
for extension in self.csr.get_extensions():
|
||||
if extension.get_short_name() == b'nameConstraints':
|
||||
permitted, excluded = pyopenssl_parse_name_constraints(extension)
|
||||
return permitted, excluded, bool(extension.get_critical())
|
||||
return None, None, False
|
||||
|
||||
def _get_public_key_pem(self):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_PEM,
|
||||
self.csr.get_pubkey(),
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
bio = crypto._new_mem_buf()
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.csr.get_pubkey()._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def _get_public_key_object(self):
|
||||
return self.csr.get_pubkey()
|
||||
|
||||
def _get_subject_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None
|
||||
|
||||
def _get_authority_key_identifier(self):
|
||||
# Won't be implemented
|
||||
return None, None, None
|
||||
|
||||
def _get_all_extensions(self):
|
||||
return pyopenssl_get_extensions_from_csr(self.csr)
|
||||
|
||||
def _is_signature_valid(self):
|
||||
try:
|
||||
return bool(self.csr.verify(self.csr.get_pubkey()))
|
||||
except crypto.Error:
|
||||
# OpenSSL error means that key is not consistent
|
||||
return False
|
||||
|
||||
|
||||
def get_csr_info(module, backend, content, validate_signature=True, prefer_one_fingerprint=False):
|
||||
if backend == 'cryptography':
|
||||
info = CSRInfoRetrievalCryptography(module, content, validate_signature=validate_signature)
|
||||
elif backend == 'pyopenssl':
|
||||
info = CSRInfoRetrievalPyOpenSSL(module, content, validate_signature=validate_signature)
|
||||
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
|
||||
|
||||
|
||||
def select_backend(module, backend, content, validate_signature=True):
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# First try cryptography, then pyOpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
try:
|
||||
getattr(crypto.X509Req, 'get_extensions')
|
||||
except AttributeError:
|
||||
module.fail_json(msg='You need to have PyOpenSSL>=0.15 to generate CSRs')
|
||||
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
return backend, CSRInfoRetrievalPyOpenSSL(module, content, validate_signature=validate_signature)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
return backend, CSRInfoRetrievalCryptography(module, content, validate_signature=validate_signature)
|
||||
else:
|
||||
raise ValueError('Unsupported value for backend: {0}'.format(backend))
|
||||
@@ -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,
|
||||
@@ -37,6 +37,12 @@ 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
|
||||
|
||||
|
||||
@@ -102,6 +108,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 +150,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 +241,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,6 +256,10 @@ class PrivateKeyBackend:
|
||||
else:
|
||||
result['privatekey'] = None
|
||||
|
||||
result['diff'] = dict(
|
||||
before=self.diff_before,
|
||||
after=self.diff_after,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
434
plugins/module_utils/crypto/module_backends/privatekey_info.py
Normal file
434
plugins/module_utils/crypto/module_backends/privatekey_info.py
Normal file
@@ -0,0 +1,434 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright: (c) 2016-2017, Yanis Guenane <yanis+ansible@guenane.org>
|
||||
# Copyright: (c) 2017, Markus Teufelberger <mteufelberger+ansible@mgit.at>
|
||||
# Copyright: (c) 2020, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import abc
|
||||
import traceback
|
||||
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible.module_utils import six
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.common.text.converters import to_native, to_bytes
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
|
||||
CRYPTOGRAPHY_HAS_ED25519,
|
||||
CRYPTOGRAPHY_HAS_ED448,
|
||||
OpenSSLObjectError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
load_privatekey,
|
||||
get_fingerprint_of_bytes,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.math import (
|
||||
binary_exp_mod,
|
||||
quick_is_not_prime,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
|
||||
_get_cryptography_public_key_info,
|
||||
_bigint_to_int,
|
||||
_get_pyopenssl_public_key_info,
|
||||
)
|
||||
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
|
||||
MINIMAL_PYOPENSSL_VERSION = '0.15'
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
SIGNATURE_TEST_DATA = b'1234'
|
||||
|
||||
|
||||
def _get_cryptography_private_key_info(key):
|
||||
key_type, key_public_data = _get_cryptography_public_key_info(key.public_key())
|
||||
key_private_data = dict()
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
private_numbers = key.private_numbers()
|
||||
key_private_data['p'] = private_numbers.p
|
||||
key_private_data['q'] = private_numbers.q
|
||||
key_private_data['exponent'] = private_numbers.d
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
|
||||
private_numbers = key.private_numbers()
|
||||
key_private_data['x'] = private_numbers.x
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
private_numbers = key.private_numbers()
|
||||
key_private_data['multiplier'] = private_numbers.private_value
|
||||
return key_type, key_public_data, key_private_data
|
||||
|
||||
|
||||
def _check_dsa_consistency(key_public_data, key_private_data):
|
||||
# Get parameters
|
||||
p = key_public_data.get('p')
|
||||
q = key_public_data.get('q')
|
||||
g = key_public_data.get('g')
|
||||
y = key_public_data.get('y')
|
||||
x = key_private_data.get('x')
|
||||
for v in (p, q, g, y, x):
|
||||
if v is None:
|
||||
return None
|
||||
# Make sure that g is not 0, 1 or -1 in Z/pZ
|
||||
if g < 2 or g >= p - 1:
|
||||
return False
|
||||
# Make sure that x is in range
|
||||
if x < 1 or x >= q:
|
||||
return False
|
||||
# Check whether q divides p-1
|
||||
if (p - 1) % q != 0:
|
||||
return False
|
||||
# Check that g**q mod p == 1
|
||||
if binary_exp_mod(g, q, p) != 1:
|
||||
return False
|
||||
# Check whether g**x mod p == y
|
||||
if binary_exp_mod(g, x, p) != y:
|
||||
return False
|
||||
# Check (quickly) whether p or q are not primes
|
||||
if quick_is_not_prime(q) or quick_is_not_prime(p):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_cryptography_key_consistent(key, key_public_data, key_private_data):
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
return bool(key._backend._lib.RSA_check_key(key._rsa_cdata))
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey):
|
||||
result = _check_dsa_consistency(key_public_data, key_private_data)
|
||||
if result is not None:
|
||||
return result
|
||||
try:
|
||||
signature = key.sign(SIGNATURE_TEST_DATA, cryptography.hazmat.primitives.hashes.SHA256())
|
||||
except AttributeError:
|
||||
# sign() was added in cryptography 1.5, but we support older versions
|
||||
return None
|
||||
try:
|
||||
key.public_key().verify(
|
||||
signature,
|
||||
SIGNATURE_TEST_DATA,
|
||||
cryptography.hazmat.primitives.hashes.SHA256()
|
||||
)
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
return False
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
try:
|
||||
signature = key.sign(
|
||||
SIGNATURE_TEST_DATA,
|
||||
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256())
|
||||
)
|
||||
except AttributeError:
|
||||
# sign() was added in cryptography 1.5, but we support older versions
|
||||
return None
|
||||
try:
|
||||
key.public_key().verify(
|
||||
signature,
|
||||
SIGNATURE_TEST_DATA,
|
||||
cryptography.hazmat.primitives.asymmetric.ec.ECDSA(cryptography.hazmat.primitives.hashes.SHA256())
|
||||
)
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
return False
|
||||
has_simple_sign_function = False
|
||||
if CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey):
|
||||
has_simple_sign_function = True
|
||||
if CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey):
|
||||
has_simple_sign_function = True
|
||||
if has_simple_sign_function:
|
||||
signature = key.sign(SIGNATURE_TEST_DATA)
|
||||
try:
|
||||
key.public_key().verify(signature, SIGNATURE_TEST_DATA)
|
||||
return True
|
||||
except cryptography.exceptions.InvalidSignature:
|
||||
return False
|
||||
# For X25519 and X448, there's no test yet.
|
||||
return None
|
||||
|
||||
|
||||
class PrivateKeyConsistencyError(OpenSSLObjectError):
|
||||
def __init__(self, msg, result):
|
||||
super(PrivateKeyConsistencyError, self).__init__(msg)
|
||||
self.error_message = msg
|
||||
self.result = result
|
||||
|
||||
|
||||
class PrivateKeyParseError(OpenSSLObjectError):
|
||||
def __init__(self, msg, result):
|
||||
super(PrivateKeyParseError, self).__init__(msg)
|
||||
self.error_message = msg
|
||||
self.result = result
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class PrivateKeyInfoRetrieval(object):
|
||||
def __init__(self, module, backend, content, passphrase=None, return_private_key_data=False):
|
||||
# content must be a bytes string
|
||||
self.module = module
|
||||
self.backend = backend
|
||||
self.content = content
|
||||
self.passphrase = passphrase
|
||||
self.return_private_key_data = return_private_key_data
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key(self, binary):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_info(self):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _is_key_consistent(self, key_public_data, key_private_data):
|
||||
pass
|
||||
|
||||
def get_info(self, prefer_one_fingerprint=False):
|
||||
result = dict(
|
||||
can_parse_key=False,
|
||||
key_is_consistent=None,
|
||||
)
|
||||
priv_key_detail = self.content
|
||||
try:
|
||||
self.key = load_privatekey(
|
||||
path=None,
|
||||
content=priv_key_detail,
|
||||
passphrase=to_bytes(self.passphrase) if self.passphrase is not None else self.passphrase,
|
||||
backend=self.backend
|
||||
)
|
||||
result['can_parse_key'] = True
|
||||
except OpenSSLObjectError as exc:
|
||||
raise PrivateKeyParseError(to_native(exc), result)
|
||||
|
||||
result['public_key'] = self._get_public_key(binary=False)
|
||||
pk = self._get_public_key(binary=True)
|
||||
result['public_key_fingerprints'] = get_fingerprint_of_bytes(
|
||||
pk, prefer_one=prefer_one_fingerprint) if pk is not None else dict()
|
||||
|
||||
key_type, key_public_data, key_private_data = self._get_key_info()
|
||||
result['type'] = key_type
|
||||
result['public_data'] = key_public_data
|
||||
if self.return_private_key_data:
|
||||
result['private_data'] = key_private_data
|
||||
|
||||
result['key_is_consistent'] = self._is_key_consistent(key_public_data, key_private_data)
|
||||
if result['key_is_consistent'] is False:
|
||||
# Only fail when it is False, to avoid to fail on None (which means "we don't know")
|
||||
msg = (
|
||||
"Private key is not consistent! (See "
|
||||
"https://blog.hboeck.de/archives/888-How-I-tricked-Symantec-with-a-Fake-Private-Key.html)"
|
||||
)
|
||||
raise PrivateKeyConsistencyError(msg, result)
|
||||
return result
|
||||
|
||||
|
||||
class PrivateKeyInfoRetrievalCryptography(PrivateKeyInfoRetrieval):
|
||||
"""Validate the supplied private key, using the cryptography backend"""
|
||||
def __init__(self, module, content, **kwargs):
|
||||
super(PrivateKeyInfoRetrievalCryptography, self).__init__(module, 'cryptography', content, **kwargs)
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
return self.key.public_key().public_bytes(
|
||||
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
def _get_key_info(self):
|
||||
return _get_cryptography_private_key_info(self.key)
|
||||
|
||||
def _is_key_consistent(self, key_public_data, key_private_data):
|
||||
return _is_cryptography_key_consistent(self.key, key_public_data, key_private_data)
|
||||
|
||||
|
||||
class PrivateKeyInfoRetrievalPyOpenSSL(PrivateKeyInfoRetrieval):
|
||||
"""validate the supplied private key."""
|
||||
|
||||
def __init__(self, module, content, **kwargs):
|
||||
super(PrivateKeyInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content, **kwargs)
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
|
||||
self.key
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
# pyOpenSSL < 16.0:
|
||||
bio = crypto._new_mem_buf()
|
||||
if binary:
|
||||
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.key._pkey)
|
||||
else:
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.key._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def _get_key_info(self):
|
||||
key_type, key_public_data, try_fallback = _get_pyopenssl_public_key_info(self.key)
|
||||
key_private_data = dict()
|
||||
openssl_key_type = self.key.type()
|
||||
if crypto.TYPE_RSA == openssl_key_type:
|
||||
try:
|
||||
# Use OpenSSL directly to extract key data
|
||||
key = OpenSSL._util.lib.EVP_PKEY_get1_RSA(self.key._pkey)
|
||||
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.RSA_free)
|
||||
# OpenSSL 1.1 and newer have functions to extract the parameters
|
||||
# from the EVP PKEY data structures. Older versions didn't have
|
||||
# these getters, and it was common use to simply access the values
|
||||
# directly. Since there's no guarantee that these data structures
|
||||
# will still be accessible in the future, we use the getters for
|
||||
# 1.1 and later, and directly access the values for 1.0.x and
|
||||
# earlier.
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# Get modulus and exponents
|
||||
n = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
e = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
d = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.RSA_get0_key(key, n, e, d)
|
||||
key_private_data['exponent'] = _bigint_to_int(d[0])
|
||||
# Get factors
|
||||
p = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
q = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.RSA_get0_factors(key, p, q)
|
||||
key_private_data['p'] = _bigint_to_int(p[0])
|
||||
key_private_data['q'] = _bigint_to_int(q[0])
|
||||
else:
|
||||
# Get private exponent
|
||||
key_private_data['exponent'] = _bigint_to_int(key.d)
|
||||
# Get factors
|
||||
key_private_data['p'] = _bigint_to_int(key.p)
|
||||
key_private_data['q'] = _bigint_to_int(key.q)
|
||||
except AttributeError:
|
||||
try_fallback = True
|
||||
elif crypto.TYPE_DSA == openssl_key_type:
|
||||
try:
|
||||
# Use OpenSSL directly to extract key data
|
||||
key = OpenSSL._util.lib.EVP_PKEY_get1_DSA(self.key._pkey)
|
||||
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.DSA_free)
|
||||
# OpenSSL 1.1 and newer have functions to extract the parameters
|
||||
# from the EVP PKEY data structures. Older versions didn't have
|
||||
# these getters, and it was common use to simply access the values
|
||||
# directly. Since there's no guarantee that these data structures
|
||||
# will still be accessible in the future, we use the getters for
|
||||
# 1.1 and later, and directly access the values for 1.0.x and
|
||||
# earlier.
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# Get private key exponents
|
||||
y = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
x = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.DSA_get0_key(key, y, x)
|
||||
key_private_data['x'] = _bigint_to_int(x[0])
|
||||
else:
|
||||
# Get private key exponents
|
||||
key_private_data['x'] = _bigint_to_int(key.priv_key)
|
||||
except AttributeError:
|
||||
try_fallback = True
|
||||
else:
|
||||
# Return 'unknown'
|
||||
key_type = 'unknown ({0})'.format(self.key.type())
|
||||
# If needed and if possible, fall back to cryptography
|
||||
if try_fallback and PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
|
||||
return _get_cryptography_private_key_info(self.key.to_cryptography_key())
|
||||
return key_type, key_public_data, key_private_data
|
||||
|
||||
def _is_key_consistent(self, key_public_data, key_private_data):
|
||||
openssl_key_type = self.key.type()
|
||||
if crypto.TYPE_RSA == openssl_key_type:
|
||||
try:
|
||||
return self.key.check()
|
||||
except crypto.Error:
|
||||
# OpenSSL error means that key is not consistent
|
||||
return False
|
||||
if crypto.TYPE_DSA == openssl_key_type:
|
||||
result = _check_dsa_consistency(key_public_data, key_private_data)
|
||||
if result is not None:
|
||||
return result
|
||||
signature = crypto.sign(self.key, SIGNATURE_TEST_DATA, 'sha256')
|
||||
# Verify wants a cert (where it can get the public key from)
|
||||
cert = crypto.X509()
|
||||
cert.set_pubkey(self.key)
|
||||
try:
|
||||
crypto.verify(cert, signature, SIGNATURE_TEST_DATA, 'sha256')
|
||||
return True
|
||||
except crypto.Error:
|
||||
return False
|
||||
# If needed and if possible, fall back to cryptography
|
||||
if PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
|
||||
return _is_cryptography_key_consistent(self.key.to_cryptography_key(), key_public_data, key_private_data)
|
||||
return None
|
||||
|
||||
|
||||
def get_privatekey_info(module, backend, content, passphrase=None, return_private_key_data=False, prefer_one_fingerprint=False):
|
||||
if backend == 'cryptography':
|
||||
info = PrivateKeyInfoRetrievalCryptography(
|
||||
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
|
||||
elif backend == 'pyopenssl':
|
||||
info = PrivateKeyInfoRetrievalPyOpenSSL(
|
||||
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
|
||||
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
|
||||
|
||||
|
||||
def select_backend(module, backend, content, passphrase=None, return_private_key_data=False):
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# First try cryptography, then pyOpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
return backend, PrivateKeyInfoRetrievalPyOpenSSL(
|
||||
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
return backend, PrivateKeyInfoRetrievalCryptography(
|
||||
module, content, passphrase=passphrase, return_private_key_data=return_private_key_data)
|
||||
else:
|
||||
raise ValueError('Unsupported value for backend: {0}'.format(backend))
|
||||
320
plugins/module_utils/crypto/module_backends/publickey_info.py
Normal file
320
plugins/module_utils/crypto/module_backends/publickey_info.py
Normal file
@@ -0,0 +1,320 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright: (c) 2020-2021, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
import abc
|
||||
import traceback
|
||||
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
from ansible.module_utils import six
|
||||
from ansible.module_utils.basic import missing_required_lib
|
||||
from ansible.module_utils.common.text.converters import to_native
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
|
||||
CRYPTOGRAPHY_HAS_X25519,
|
||||
CRYPTOGRAPHY_HAS_X448,
|
||||
CRYPTOGRAPHY_HAS_ED25519,
|
||||
CRYPTOGRAPHY_HAS_ED448,
|
||||
OpenSSLObjectError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.support import (
|
||||
get_fingerprint_of_bytes,
|
||||
load_publickey,
|
||||
)
|
||||
|
||||
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
|
||||
MINIMAL_PYOPENSSL_VERSION = '16.0.0' # when working with public key objects, the minimal required version is 0.15
|
||||
|
||||
PYOPENSSL_IMP_ERR = None
|
||||
try:
|
||||
import OpenSSL
|
||||
from OpenSSL import crypto
|
||||
PYOPENSSL_VERSION = LooseVersion(OpenSSL.__version__)
|
||||
except ImportError:
|
||||
PYOPENSSL_IMP_ERR = traceback.format_exc()
|
||||
PYOPENSSL_FOUND = False
|
||||
else:
|
||||
PYOPENSSL_FOUND = True
|
||||
|
||||
CRYPTOGRAPHY_IMP_ERR = None
|
||||
try:
|
||||
import cryptography
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
CRYPTOGRAPHY_VERSION = LooseVersion(cryptography.__version__)
|
||||
except ImportError:
|
||||
CRYPTOGRAPHY_IMP_ERR = traceback.format_exc()
|
||||
CRYPTOGRAPHY_FOUND = False
|
||||
else:
|
||||
CRYPTOGRAPHY_FOUND = True
|
||||
|
||||
|
||||
def _get_cryptography_public_key_info(key):
|
||||
key_public_data = dict()
|
||||
if isinstance(key, cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey):
|
||||
key_type = 'RSA'
|
||||
public_numbers = key.public_numbers()
|
||||
key_public_data['size'] = key.key_size
|
||||
key_public_data['modulus'] = public_numbers.n
|
||||
key_public_data['exponent'] = public_numbers.e
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey):
|
||||
key_type = 'DSA'
|
||||
parameter_numbers = key.parameters().parameter_numbers()
|
||||
public_numbers = key.public_numbers()
|
||||
key_public_data['size'] = key.key_size
|
||||
key_public_data['p'] = parameter_numbers.p
|
||||
key_public_data['q'] = parameter_numbers.q
|
||||
key_public_data['g'] = parameter_numbers.g
|
||||
key_public_data['y'] = public_numbers.y
|
||||
elif CRYPTOGRAPHY_HAS_X25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey):
|
||||
key_type = 'X25519'
|
||||
elif CRYPTOGRAPHY_HAS_X448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey):
|
||||
key_type = 'X448'
|
||||
elif CRYPTOGRAPHY_HAS_ED25519 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey):
|
||||
key_type = 'Ed25519'
|
||||
elif CRYPTOGRAPHY_HAS_ED448 and isinstance(key, cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey):
|
||||
key_type = 'Ed448'
|
||||
elif isinstance(key, cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey):
|
||||
key_type = 'ECC'
|
||||
public_numbers = key.public_numbers()
|
||||
key_public_data['curve'] = key.curve.name
|
||||
key_public_data['x'] = public_numbers.x
|
||||
key_public_data['y'] = public_numbers.y
|
||||
key_public_data['exponent_size'] = key.curve.key_size
|
||||
else:
|
||||
key_type = 'unknown ({0})'.format(type(key))
|
||||
return key_type, key_public_data
|
||||
|
||||
|
||||
def _bigint_to_int(bn):
|
||||
'''Convert OpenSSL BIGINT to Python integer'''
|
||||
if bn == OpenSSL._util.ffi.NULL:
|
||||
return None
|
||||
hexstr = OpenSSL._util.lib.BN_bn2hex(bn)
|
||||
try:
|
||||
return int(OpenSSL._util.ffi.string(hexstr), 16)
|
||||
finally:
|
||||
OpenSSL._util.lib.OPENSSL_free(hexstr)
|
||||
|
||||
|
||||
def _get_pyopenssl_public_key_info(key):
|
||||
key_public_data = dict()
|
||||
try_fallback = True
|
||||
openssl_key_type = key.type()
|
||||
if crypto.TYPE_RSA == openssl_key_type:
|
||||
key_type = 'RSA'
|
||||
key_public_data['size'] = key.bits()
|
||||
|
||||
try:
|
||||
# Use OpenSSL directly to extract key data
|
||||
key = OpenSSL._util.lib.EVP_PKEY_get1_RSA(key._pkey)
|
||||
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.RSA_free)
|
||||
# OpenSSL 1.1 and newer have functions to extract the parameters
|
||||
# from the EVP PKEY data structures. Older versions didn't have
|
||||
# these getters, and it was common use to simply access the values
|
||||
# directly. Since there's no guarantee that these data structures
|
||||
# will still be accessible in the future, we use the getters for
|
||||
# 1.1 and later, and directly access the values for 1.0.x and
|
||||
# earlier.
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# Get modulus and exponents
|
||||
n = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
e = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
d = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.RSA_get0_key(key, n, e, d)
|
||||
key_public_data['modulus'] = _bigint_to_int(n[0])
|
||||
key_public_data['exponent'] = _bigint_to_int(e[0])
|
||||
else:
|
||||
# Get modulus and exponents
|
||||
key_public_data['modulus'] = _bigint_to_int(key.n)
|
||||
key_public_data['exponent'] = _bigint_to_int(key.e)
|
||||
try_fallback = False
|
||||
except AttributeError:
|
||||
# Use fallback if available
|
||||
pass
|
||||
elif crypto.TYPE_DSA == openssl_key_type:
|
||||
key_type = 'DSA'
|
||||
key_public_data['size'] = key.bits()
|
||||
|
||||
try:
|
||||
# Use OpenSSL directly to extract key data
|
||||
key = OpenSSL._util.lib.EVP_PKEY_get1_DSA(key._pkey)
|
||||
key = OpenSSL._util.ffi.gc(key, OpenSSL._util.lib.DSA_free)
|
||||
# OpenSSL 1.1 and newer have functions to extract the parameters
|
||||
# from the EVP PKEY data structures. Older versions didn't have
|
||||
# these getters, and it was common use to simply access the values
|
||||
# directly. Since there's no guarantee that these data structures
|
||||
# will still be accessible in the future, we use the getters for
|
||||
# 1.1 and later, and directly access the values for 1.0.x and
|
||||
# earlier.
|
||||
if OpenSSL.SSL.OPENSSL_VERSION_NUMBER >= 0x10100000:
|
||||
# Get public parameters (primes and group element)
|
||||
p = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
q = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
g = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.DSA_get0_pqg(key, p, q, g)
|
||||
key_public_data['p'] = _bigint_to_int(p[0])
|
||||
key_public_data['q'] = _bigint_to_int(q[0])
|
||||
key_public_data['g'] = _bigint_to_int(g[0])
|
||||
# Get public key exponents
|
||||
y = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
x = OpenSSL._util.ffi.new("BIGNUM **")
|
||||
OpenSSL._util.lib.DSA_get0_key(key, y, x)
|
||||
key_public_data['y'] = _bigint_to_int(y[0])
|
||||
else:
|
||||
# Get public parameters (primes and group element)
|
||||
key_public_data['p'] = _bigint_to_int(key.p)
|
||||
key_public_data['q'] = _bigint_to_int(key.q)
|
||||
key_public_data['g'] = _bigint_to_int(key.g)
|
||||
# Get public key exponents
|
||||
key_public_data['y'] = _bigint_to_int(key.pub_key)
|
||||
try_fallback = False
|
||||
except AttributeError:
|
||||
# Use fallback if available
|
||||
pass
|
||||
else:
|
||||
# Return 'unknown'
|
||||
key_type = 'unknown ({0})'.format(key.type())
|
||||
return key_type, key_public_data, try_fallback
|
||||
|
||||
|
||||
class PublicKeyParseError(OpenSSLObjectError):
|
||||
def __init__(self, msg, result):
|
||||
super(PublicKeyParseError, self).__init__(msg)
|
||||
self.error_message = msg
|
||||
self.result = result
|
||||
|
||||
|
||||
@six.add_metaclass(abc.ABCMeta)
|
||||
class PublicKeyInfoRetrieval(object):
|
||||
def __init__(self, module, backend, content=None, key=None):
|
||||
# content must be a bytes string
|
||||
self.module = module
|
||||
self.backend = backend
|
||||
self.content = content
|
||||
self.key = key
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_public_key(self, binary):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_key_info(self):
|
||||
pass
|
||||
|
||||
def get_info(self, prefer_one_fingerprint=False):
|
||||
result = dict()
|
||||
if self.key is None:
|
||||
try:
|
||||
self.key = load_publickey(content=self.content, backend=self.backend)
|
||||
except OpenSSLObjectError as e:
|
||||
raise PublicKeyParseError(to_native(e))
|
||||
|
||||
pk = self._get_public_key(binary=True)
|
||||
result['fingerprints'] = get_fingerprint_of_bytes(
|
||||
pk, prefer_one=prefer_one_fingerprint) if pk is not None else dict()
|
||||
|
||||
key_type, key_public_data = self._get_key_info()
|
||||
result['type'] = key_type
|
||||
result['public_data'] = key_public_data
|
||||
return result
|
||||
|
||||
|
||||
class PublicKeyInfoRetrievalCryptography(PublicKeyInfoRetrieval):
|
||||
"""Validate the supplied public key, using the cryptography backend"""
|
||||
def __init__(self, module, content=None, key=None):
|
||||
super(PublicKeyInfoRetrievalCryptography, self).__init__(module, 'cryptography', content=content, key=key)
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
return self.key.public_bytes(
|
||||
serialization.Encoding.DER if binary else serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
def _get_key_info(self):
|
||||
return _get_cryptography_public_key_info(self.key)
|
||||
|
||||
|
||||
class PublicKeyInfoRetrievalPyOpenSSL(PublicKeyInfoRetrieval):
|
||||
"""validate the supplied public key."""
|
||||
|
||||
def __init__(self, module, content=None, key=None):
|
||||
super(PublicKeyInfoRetrievalPyOpenSSL, self).__init__(module, 'pyopenssl', content=content, key=key)
|
||||
|
||||
def _get_public_key(self, binary):
|
||||
try:
|
||||
return crypto.dump_publickey(
|
||||
crypto.FILETYPE_ASN1 if binary else crypto.FILETYPE_PEM,
|
||||
self.key
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
# pyOpenSSL < 16.0:
|
||||
bio = crypto._new_mem_buf()
|
||||
if binary:
|
||||
rc = crypto._lib.i2d_PUBKEY_bio(bio, self.key._pkey)
|
||||
else:
|
||||
rc = crypto._lib.PEM_write_bio_PUBKEY(bio, self.key._pkey)
|
||||
if rc != 1:
|
||||
crypto._raise_current_error()
|
||||
return crypto._bio_to_string(bio)
|
||||
except AttributeError:
|
||||
self.module.warn('Your pyOpenSSL version does not support dumping public keys. '
|
||||
'Please upgrade to version 16.0 or newer, or use the cryptography backend.')
|
||||
|
||||
def _get_key_info(self):
|
||||
key_type, key_public_data, try_fallback = _get_pyopenssl_public_key_info(self.key)
|
||||
# If needed and if possible, fall back to cryptography
|
||||
if try_fallback and PYOPENSSL_VERSION >= LooseVersion('16.1.0') and CRYPTOGRAPHY_FOUND:
|
||||
return _get_cryptography_public_key_info(self.key.to_cryptography_key())
|
||||
return key_type, key_public_data
|
||||
|
||||
|
||||
def get_publickey_info(module, backend, content=None, key=None, prefer_one_fingerprint=False):
|
||||
if backend == 'cryptography':
|
||||
info = PublicKeyInfoRetrievalCryptography(module, content=content, key=key)
|
||||
elif backend == 'pyopenssl':
|
||||
info = PublicKeyInfoRetrievalPyOpenSSL(module, content=content, key=key)
|
||||
return info.get_info(prefer_one_fingerprint=prefer_one_fingerprint)
|
||||
|
||||
|
||||
def select_backend(module, backend, content=None, key=None):
|
||||
if backend == 'auto':
|
||||
# Detection what is possible
|
||||
can_use_cryptography = CRYPTOGRAPHY_FOUND and CRYPTOGRAPHY_VERSION >= LooseVersion(MINIMAL_CRYPTOGRAPHY_VERSION)
|
||||
can_use_pyopenssl = PYOPENSSL_FOUND and PYOPENSSL_VERSION >= LooseVersion(MINIMAL_PYOPENSSL_VERSION)
|
||||
|
||||
# First try cryptography, then pyOpenSSL
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
|
||||
# Success?
|
||||
if backend == 'auto':
|
||||
module.fail_json(msg=("Can't detect any of the required Python libraries "
|
||||
"cryptography (>= {0}) or PyOpenSSL (>= {1})").format(
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION,
|
||||
MINIMAL_PYOPENSSL_VERSION))
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
if not PYOPENSSL_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('pyOpenSSL >= {0}'.format(MINIMAL_PYOPENSSL_VERSION)),
|
||||
exception=PYOPENSSL_IMP_ERR)
|
||||
module.deprecate('The module is using the PyOpenSSL backend. This backend has been deprecated',
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
return backend, PublicKeyInfoRetrievalPyOpenSSL(module, content=content, key=key)
|
||||
elif backend == 'cryptography':
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
return backend, PublicKeyInfoRetrievalCryptography(module, content=content, key=key)
|
||||
else:
|
||||
raise ValueError('Unsupported value for backend: {0}'.format(backend))
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ __metaclass__ = type
|
||||
|
||||
import base64
|
||||
|
||||
from ansible.module_utils._text import to_bytes, to_text
|
||||
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.compat import ipaddress as compat_ipaddress
|
||||
|
||||
|
||||
@@ -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,11 +92,13 @@ 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='pyopenssl', prefer_one=False):
|
||||
"""Generate the fingerprint of the public key. """
|
||||
|
||||
if backend == 'pyopenssl':
|
||||
@@ -107,15 +122,15 @@ def get_fingerprint_of_privatekey(privatekey, backend='pyopenssl'):
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
return get_fingerprint_of_bytes(publickey)
|
||||
return get_fingerprint_of_bytes(publickey, prefer_one=prefer_one)
|
||||
|
||||
|
||||
def get_fingerprint(path, passphrase=None, content=None, backend='pyopenssl'):
|
||||
def get_fingerprint(path, passphrase=None, content=None, backend='pyopenssl', prefer_one=False):
|
||||
"""Generate the fingerprint of the public key. """
|
||||
|
||||
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'):
|
||||
@@ -183,6 +198,28 @@ def load_privatekey(path, passphrase=None, check_passphrase=True, content=None,
|
||||
return result
|
||||
|
||||
|
||||
def load_publickey(path=None, content=None, backend=None):
|
||||
if content is None:
|
||||
if path is None:
|
||||
raise OpenSSLObjectError('Must provide either path or content')
|
||||
try:
|
||||
with open(path, 'rb') as b_priv_key_fh:
|
||||
content = b_priv_key_fh.read()
|
||||
except (IOError, OSError) as exc:
|
||||
raise OpenSSLObjectError(exc)
|
||||
|
||||
if backend == 'cryptography':
|
||||
try:
|
||||
return serialization.load_pem_public_key(content, backend=cryptography_backend())
|
||||
except Exception as e:
|
||||
raise OpenSSLObjectError('Error while deserializing key: {0}'.format(e))
|
||||
else:
|
||||
try:
|
||||
return crypto.load_publickey(crypto.FILETYPE_PEM, content)
|
||||
except crypto.Error as e:
|
||||
raise OpenSSLObjectError('Error while deserializing key: {0}'.format(e))
|
||||
|
||||
|
||||
def load_certificate(path, content=None, backend='pyopenssl'):
|
||||
"""Load the specified certificate."""
|
||||
|
||||
@@ -334,6 +371,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
328
plugins/module_utils/openssh/backends/common.py
Normal file
328
plugins/module_utils/openssh/backends/common.py
Normal 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,
|
||||
}
|
||||
464
plugins/module_utils/openssh/backends/keypair_backend.py
Normal file
464
plugins/module_utils/openssh/backends/keypair_backend.py
Normal 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))
|
||||
677
plugins/module_utils/openssh/certificate.py
Normal file
677
plugins/module_utils/openssh/certificate.py
Normal 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)))
|
||||
695
plugins/module_utils/openssh/cryptography.py
Normal file
695
plugins/module_utils/openssh/cryptography.py
Normal 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('=')
|
||||
403
plugins/module_utils/openssh/utils.py
Normal file
403
plugins/module_utils/openssh/utils.py
Normal 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)
|
||||
@@ -88,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.
|
||||
@@ -158,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -175,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']),
|
||||
@@ -197,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
|
||||
@@ -212,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 = {}
|
||||
@@ -221,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:
|
||||
@@ -231,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')
|
||||
@@ -252,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:
|
||||
@@ -274,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
|
||||
@@ -306,7 +322,7 @@ def main():
|
||||
changed = True
|
||||
result = {
|
||||
'changed': changed,
|
||||
'account_uri': account.uri,
|
||||
'account_uri': client.account_uri,
|
||||
}
|
||||
if module._diff:
|
||||
result['diff'] = {
|
||||
|
||||
@@ -213,23 +213,31 @@ order_uris:
|
||||
|
||||
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))
|
||||
@@ -252,11 +260,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():
|
||||
@@ -277,10 +285,11 @@ def main():
|
||||
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(
|
||||
[],
|
||||
@@ -291,18 +300,18 @@ 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'])
|
||||
orders = get_orders_list(module, client, account_data['orders'])
|
||||
result['order_uris'] = orders
|
||||
if module.params['retrieve_orders'] == 'url_list':
|
||||
module.deprecate(
|
||||
@@ -312,7 +321,7 @@ def main():
|
||||
version='2.0.0', collection_name='community.crypto')
|
||||
result['orders'] = orders
|
||||
if module.params['retrieve_orders'] == 'object_list':
|
||||
result['orders'] = [get_order(account, order) for order in orders]
|
||||
result['orders'] = [get_order(client, order) for order in orders]
|
||||
module.exit_json(**result)
|
||||
except ModuleFailException as e:
|
||||
e.do_fail(module)
|
||||
|
||||
@@ -508,92 +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.crypto.pem import (
|
||||
split_pem_list,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.acme import (
|
||||
ModuleFailException,
|
||||
write_file,
|
||||
nopad_b64,
|
||||
pem_to_der,
|
||||
ACMEAccount,
|
||||
HAS_CURRENT_CRYPTOGRAPHY,
|
||||
cryptography_get_csr_identifiers,
|
||||
openssl_get_csr_identifiers,
|
||||
cryptography_get_cert_days,
|
||||
handle_standard_module_arguments,
|
||||
process_links,
|
||||
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']
|
||||
@@ -602,13 +567,22 @@ 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']):
|
||||
self.select_chain_matcher.append(
|
||||
self.client.backend.create_chain_matcher(
|
||||
Criterium(criterium, index=criterium_idx)))
|
||||
|
||||
# Make sure account exists
|
||||
modify_account = module.params['modify_account']
|
||||
@@ -639,286 +613,8 @@ class ACMEClient(object):
|
||||
if self.csr is not None and not os.path.exists(self.csr):
|
||||
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.
|
||||
'''
|
||||
found_challenge = False
|
||||
for challenge in auth['challenges']:
|
||||
if self.challenge != challenge['type']:
|
||||
continue
|
||||
|
||||
uri = challenge['uri'] if self.version == 1 else challenge['url']
|
||||
found_challenge = True
|
||||
|
||||
challenge_response = {}
|
||||
if self.version == 1:
|
||||
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
|
||||
keyauthorization = self.account.get_keyauthorization(token)
|
||||
challenge_response["resource"] = "challenge"
|
||||
challenge_response["keyAuthorization"] = keyauthorization
|
||||
challenge_response["type"] = self.challenge
|
||||
result, info = self.account.send_signed_request(uri, challenge_response)
|
||||
if info['status'] not in [200, 202]:
|
||||
raise ModuleFailException("Error validating challenge: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
|
||||
if not found_challenge:
|
||||
raise ModuleFailException("Found no challenge of type '{0}' for identifier {1}:{2}!".format(
|
||||
self.challenge, identifier_type, identifier))
|
||||
|
||||
status = ''
|
||||
|
||||
while status not in ['valid', 'invalid', 'revoked']:
|
||||
result, dummy = self.account.get_request(auth['uri'])
|
||||
result['uri'] = auth['uri']
|
||||
if self._add_or_update_auth(identifier_type, identifier, result):
|
||||
self.changed = True
|
||||
# https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.1.2
|
||||
# "status (required, string): ...
|
||||
# If this field is missing, then the default value is "pending"."
|
||||
if self.version == 1 and 'status' not in result:
|
||||
status = 'pending'
|
||||
else:
|
||||
status = result['status']
|
||||
time.sleep(2)
|
||||
|
||||
if status == 'invalid':
|
||||
self._fail_challenge(identifier_type, identifier, result, 'Authorization for {0} returned invalid')
|
||||
|
||||
return status == 'valid'
|
||||
|
||||
def _finalize_cert(self):
|
||||
'''
|
||||
Create a new certificate based on the csr.
|
||||
Return the certificate object as dict
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4
|
||||
'''
|
||||
csr = pem_to_der(self.csr, self.csr_content)
|
||||
new_cert = {
|
||||
"csr": nopad_b64(csr),
|
||||
}
|
||||
result, info = self.account.send_signed_request(self.finalize_uri, new_cert)
|
||||
if info['status'] not in [200]:
|
||||
raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
|
||||
status = result['status']
|
||||
while status not in ['valid', 'invalid']:
|
||||
time.sleep(2)
|
||||
result, dummy = self.account.get_request(self.order_uri)
|
||||
status = result['status']
|
||||
|
||||
if status != 'valid':
|
||||
raise ModuleFailException("Error new cert: CODE: {0} STATUS: {1} RESULT: {2}".format(info['status'], status, result))
|
||||
|
||||
return result['certificate']
|
||||
|
||||
def _der_to_pem(self, der_cert):
|
||||
'''
|
||||
Convert the DER format certificate in der_cert to a PEM format
|
||||
certificate and return it.
|
||||
'''
|
||||
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
|
||||
"\n".join(textwrap.wrap(base64.b64encode(der_cert).decode('utf8'), 64)))
|
||||
|
||||
def _download_cert(self, url):
|
||||
'''
|
||||
Download and parse the certificate chain.
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4.2
|
||||
'''
|
||||
content, info = self.account.get_request(url, parse_json_result=False, headers={'Accept': 'application/pem-certificate-chain'})
|
||||
|
||||
if not content or not info['content-type'].startswith('application/pem-certificate-chain'):
|
||||
raise ModuleFailException("Cannot download certificate chain from {0}: {1} (headers: {2})".format(url, content, info))
|
||||
|
||||
cert = None
|
||||
chain = []
|
||||
|
||||
# Parse data
|
||||
certs = split_pem_list(content.decode('utf-8'), keep_inbetween=True)
|
||||
if certs:
|
||||
cert = certs[0]
|
||||
chain = certs[1:]
|
||||
|
||||
alternates = []
|
||||
|
||||
def f(link, relation):
|
||||
if relation == 'up':
|
||||
# Process link-up headers if there was no chain in reply
|
||||
if not chain:
|
||||
chain_result, chain_info = self.account.get_request(link, parse_json_result=False)
|
||||
if chain_info['status'] in [200, 201]:
|
||||
chain.append(self._der_to_pem(chain_result))
|
||||
elif relation == 'alternate':
|
||||
alternates.append(link)
|
||||
|
||||
process_links(info, f)
|
||||
|
||||
if cert is None:
|
||||
raise ModuleFailException("Failed to parse certificate chain download from {0}: {1} (headers: {2})".format(url, content, info))
|
||||
return {'cert': cert, 'chain': chain, 'alternates': alternates}
|
||||
|
||||
def _new_cert_v1(self):
|
||||
'''
|
||||
Create a new certificate based on the CSR (ACME v1 protocol).
|
||||
Return the certificate object as dict
|
||||
https://tools.ietf.org/html/draft-ietf-acme-acme-02#section-6.5
|
||||
'''
|
||||
csr = pem_to_der(self.csr, self.csr_content)
|
||||
new_cert = {
|
||||
"resource": "new-cert",
|
||||
"csr": nopad_b64(csr),
|
||||
}
|
||||
result, info = self.account.send_signed_request(self.directory['new-cert'], new_cert)
|
||||
|
||||
chain = []
|
||||
|
||||
def f(link, relation):
|
||||
if relation == 'up':
|
||||
chain_result, chain_info = self.account.get_request(link, parse_json_result=False)
|
||||
if chain_info['status'] in [200, 201]:
|
||||
del chain[:]
|
||||
chain.append(self._der_to_pem(chain_result))
|
||||
|
||||
process_links(info, f)
|
||||
|
||||
if info['status'] not in [200, 201]:
|
||||
raise ModuleFailException("Error new cert: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
else:
|
||||
return {'cert': self._der_to_pem(result), 'uri': info['location'], 'chain': chain}
|
||||
|
||||
def _new_order_v2(self):
|
||||
'''
|
||||
Start a new certificate order (ACME v2 protocol).
|
||||
https://tools.ietf.org/html/rfc8555#section-7.4
|
||||
'''
|
||||
identifiers = []
|
||||
for identifier_type, identifier in self.identifiers:
|
||||
identifiers.append({
|
||||
'type': identifier_type,
|
||||
'value': identifier,
|
||||
})
|
||||
new_order = {
|
||||
"identifiers": identifiers
|
||||
}
|
||||
result, info = self.account.send_signed_request(self.directory['newOrder'], new_order)
|
||||
|
||||
if info['status'] not in [201]:
|
||||
raise ModuleFailException("Error new order: CODE: {0} RESULT: {1}".format(info['status'], result))
|
||||
|
||||
for auth_uri in result['authorizations']:
|
||||
auth_data, dummy = self.account.get_request(auth_uri)
|
||||
auth_data['uri'] = auth_uri
|
||||
identifier_type = auth_data['identifier']['type']
|
||||
identifier = auth_data['identifier']['value']
|
||||
if auth_data.get('wildcard', False):
|
||||
identifier = '*.{0}'.format(identifier)
|
||||
self.authorizations[identifier_type + ':' + identifier] = auth_data
|
||||
|
||||
self.order_uri = info['location']
|
||||
self.finalize_uri = result['finalize']
|
||||
self.identifiers = self.client.backend.get_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
|
||||
|
||||
def is_first_step(self):
|
||||
'''
|
||||
@@ -946,10 +642,13 @@ 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, first_step):
|
||||
@@ -959,15 +658,14 @@ class ACMEClient(object):
|
||||
'''
|
||||
# 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))
|
||||
@@ -994,91 +692,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):
|
||||
'''
|
||||
@@ -1087,80 +734,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 = list(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')):
|
||||
@@ -1172,25 +785,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():
|
||||
@@ -1232,18 +834,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
|
||||
@@ -1253,7 +850,7 @@ 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()
|
||||
is_first_step = client.is_first_step()
|
||||
@@ -1265,7 +862,7 @@ 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']:
|
||||
@@ -1274,13 +871,13 @@ def main():
|
||||
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,
|
||||
|
||||
@@ -54,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."
|
||||
@@ -69,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."
|
||||
@@ -91,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
|
||||
@@ -119,13 +123,25 @@ 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)
|
||||
|
||||
@@ -52,6 +52,11 @@ 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).
|
||||
'''
|
||||
@@ -138,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,
|
||||
)
|
||||
|
||||
@@ -186,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'],
|
||||
@@ -195,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
|
||||
@@ -204,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))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -122,7 +122,7 @@ 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,6 +50,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
|
||||
@@ -165,7 +173,7 @@ from socket import create_connection, setdefaulttimeout, socket
|
||||
from ssl import get_server_certificate, DER_cert_to_PEM_cert, CERT_NONE, CERT_REQUIRED
|
||||
|
||||
from 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,
|
||||
@@ -209,6 +217,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(
|
||||
@@ -220,6 +242,7 @@ def main():
|
||||
server_name=dict(type='str'),
|
||||
timeout=dict(type='int', default=10),
|
||||
select_crypto_backend=dict(type='str', choices=['auto', 'pyopenssl', 'cryptography'], default='auto'),
|
||||
starttls=dict(type='str', choices=['mysql']),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -230,6 +253,7 @@ def main():
|
||||
proxy_port = module.params.get('proxy_port')
|
||||
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':
|
||||
@@ -305,6 +329,9 @@ def main():
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = CERT_NONE
|
||||
|
||||
if start_tls_server_type is not None:
|
||||
send_starttls_packet(sock, start_tls_server_type)
|
||||
|
||||
cert = ctx.wrap_socket(sock, server_hostname=server_name or host).getpeercert(True)
|
||||
cert = DER_cert_to_PEM_cert(cert)
|
||||
except Exception as e:
|
||||
|
||||
@@ -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.
|
||||
@@ -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__':
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
DOCUMENTATION = '''
|
||||
---
|
||||
module: openssh_keypair
|
||||
@@ -18,7 +17,9 @@ description:
|
||||
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,6 +56,35 @@ 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.
|
||||
@@ -92,6 +122,7 @@ 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
|
||||
'''
|
||||
@@ -101,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
|
||||
@@ -150,281 +186,15 @@ comment:
|
||||
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']),
|
||||
@@ -438,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__':
|
||||
|
||||
@@ -236,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,
|
||||
@@ -286,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)
|
||||
|
||||
@@ -183,6 +183,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.
|
||||
@@ -225,428 +296,17 @@ authority_cert_serial_number:
|
||||
'''
|
||||
|
||||
|
||||
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(
|
||||
@@ -664,53 +324,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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,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,
|
||||
@@ -221,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."""
|
||||
|
||||
@@ -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:
|
||||
@@ -58,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:
|
||||
@@ -105,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:
|
||||
@@ -207,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,
|
||||
@@ -225,6 +254,10 @@ 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,
|
||||
@@ -235,23 +268,40 @@ 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):
|
||||
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) for cert in split_pem_list(data)]
|
||||
return [load_certificate(None, content=cert.encode('utf-8'), backend=backend) for cert in split_pem_list(data)]
|
||||
|
||||
|
||||
class PkcsError(OpenSSLObjectError):
|
||||
@@ -259,21 +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']
|
||||
@@ -293,12 +343,37 @@ class Pkcs(OpenSSLObject):
|
||||
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.other_certificates.extend(load_certificate_set(other_cert_bundle, self.backend))
|
||||
else:
|
||||
self.other_certificates = [
|
||||
load_certificate(other_cert) for other_cert in self.other_certificates
|
||||
load_certificate(other_cert, backend=self.backend) for other_cert in self.other_certificates
|
||||
]
|
||||
|
||||
@abc.abstractmethod
|
||||
def generate_bytes(self, module):
|
||||
"""Generate PKCS#12 file archive."""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def parse_bytes(self, pkcs12_content):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _dump_privatekey(self, pkcs12):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _dump_certificate(self, pkcs12):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _dump_other_certificates(self, pkcs12):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_friendly_name(self, pkcs12):
|
||||
pass
|
||||
|
||||
def check(self, module, perms_required=True):
|
||||
"""Ensure the resource is in its desired state."""
|
||||
|
||||
@@ -307,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
|
||||
|
||||
@@ -318,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):
|
||||
@@ -352,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])
|
||||
@@ -390,27 +460,6 @@ class Pkcs(OpenSSLObject):
|
||||
|
||||
return result
|
||||
|
||||
def generate(self, module):
|
||||
"""Generate PKCS#12 file archive."""
|
||||
self.pkcs12 = crypto.PKCS12()
|
||||
|
||||
if self.other_certificates:
|
||||
self.pkcs12.set_ca_certificates(self.other_certificates)
|
||||
|
||||
if self.certificate_path:
|
||||
self.pkcs12.set_certificate(load_certificate(self.certificate_path))
|
||||
|
||||
if self.friendly_name:
|
||||
self.pkcs12.set_friendlyname(to_bytes(self.friendly_name))
|
||||
|
||||
if self.privatekey_path:
|
||||
try:
|
||||
self.pkcs12.set_privatekey(load_privatekey(self.privatekey_path, self.privatekey_passphrase))
|
||||
except OpenSSLBadPassphraseError as exc:
|
||||
raise PkcsError(exc)
|
||||
|
||||
return self.pkcs12.export(self.passphrase, self.iter_size, self.maciter_size)
|
||||
|
||||
def remove(self, module):
|
||||
if self.backup:
|
||||
self.backup_file = module.backup_local(self.path)
|
||||
@@ -422,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)
|
||||
@@ -438,17 +530,143 @@ 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():
|
||||
@@ -459,8 +677,8 @@ def main():
|
||||
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),
|
||||
@@ -469,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 = [
|
||||
@@ -482,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):
|
||||
@@ -493,7 +711,6 @@ def main():
|
||||
)
|
||||
|
||||
try:
|
||||
pkcs12 = Pkcs(module)
|
||||
changed = False
|
||||
|
||||
if module.params['state'] == 'present':
|
||||
@@ -506,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:
|
||||
@@ -516,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:
|
||||
|
||||
@@ -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)
|
||||
@@ -143,7 +144,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 +214,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)
|
||||
|
||||
@@ -130,6 +130,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.
|
||||
@@ -138,450 +194,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(
|
||||
@@ -601,49 +226,39 @@ 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'])
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -185,7 +185,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,6 +203,11 @@ from ansible_collections.community.crypto.plugins.module_utils.crypto.support im
|
||||
get_fingerprint,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
|
||||
PublicKeyParseError,
|
||||
get_publickey_info,
|
||||
)
|
||||
|
||||
MINIMAL_PYOPENSSL_VERSION = '16.0.0'
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION = '1.2.3'
|
||||
MINIMAL_CRYPTOGRAPHY_VERSION_OPENSSH = '1.4'
|
||||
@@ -244,6 +249,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 +265,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,
|
||||
@@ -294,6 +317,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 +338,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 +355,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':
|
||||
@@ -387,6 +414,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
|
||||
|
||||
|
||||
|
||||
219
plugins/modules/openssl_publickey_info.py
Normal file
219
plugins/modules/openssl_publickey_info.py
Normal file
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2021, Felix Fontein <felix@fontein.de>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: openssl_publickey_info
|
||||
short_description: Provide information for OpenSSL public keys
|
||||
description:
|
||||
- This module allows one to query information on OpenSSL public keys.
|
||||
- It uses the pyOpenSSL or cryptography python library to interact with OpenSSL. If both the
|
||||
cryptography and PyOpenSSL libraries are available (and meet the minimum version requirements)
|
||||
cryptography will be preferred as a backend over PyOpenSSL (unless the backend is forced with
|
||||
C(select_crypto_backend)). Please note that the PyOpenSSL backend was deprecated in Ansible 2.9
|
||||
and will be removed in community.crypto 2.0.0.
|
||||
version_added: 1.7.0
|
||||
requirements:
|
||||
- PyOpenSSL >= 0.15 or cryptography >= 1.2.3
|
||||
author:
|
||||
- Felix Fontein (@felixfontein)
|
||||
options:
|
||||
path:
|
||||
description:
|
||||
- Remote absolute path where the public key file is loaded from.
|
||||
type: path
|
||||
content:
|
||||
description:
|
||||
- Content of the public key file.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: str
|
||||
|
||||
select_crypto_backend:
|
||||
description:
|
||||
- Determines which crypto backend to use.
|
||||
- The default choice is C(auto), which tries to use C(cryptography) if available, and falls back to C(pyopenssl).
|
||||
- If set to C(pyopenssl), will try to use the L(pyOpenSSL,https://pypi.org/project/pyOpenSSL/) library.
|
||||
- If set to C(cryptography), will try to use the L(cryptography,https://cryptography.io/) library.
|
||||
- Please note that the C(pyopenssl) backend has been deprecated in Ansible 2.9, and will be removed in community.crypto 2.0.0.
|
||||
From that point on, only the C(cryptography) backend will be available.
|
||||
type: str
|
||||
default: auto
|
||||
choices: [ auto, cryptography, pyopenssl ]
|
||||
|
||||
notes:
|
||||
- Supports C(check_mode).
|
||||
|
||||
seealso:
|
||||
- module: community.crypto.openssl_publickey
|
||||
- module: community.crypto.openssl_privatekey_info
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Generate an OpenSSL private key with the default values (4096 bits, RSA)
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /etc/ssl/private/ansible.com.pem
|
||||
|
||||
- name: Create public key from private key
|
||||
community.crypto.openssl_privatekey:
|
||||
privatekey_path: /etc/ssl/private/ansible.com.pem
|
||||
path: /etc/ssl/ansible.com.pub
|
||||
|
||||
- name: Get information on public key
|
||||
community.crypto.openssl_publickey_info:
|
||||
path: /etc/ssl/ansible.com.pub
|
||||
register: result
|
||||
|
||||
- name: Dump information
|
||||
ansible.builtin.debug:
|
||||
var: result
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
fingerprints:
|
||||
description:
|
||||
- Fingerprints of public key.
|
||||
- For every hash algorithm available, the fingerprint is computed.
|
||||
returned: success
|
||||
type: dict
|
||||
sample: "{'sha256': 'd4:b3:aa:6d:c8:04:ce:4e:ba:f6:29:4d:92:a3:94:b0:c2:ff:bd:bf:33:63:11:43:34:0f:51:b0:95:09:2f:63',
|
||||
'sha512': 'f7:07:4a:f0:b0:f0:e6:8b:95:5f:f9:e6:61:0a:32:68:f1..."
|
||||
type:
|
||||
description:
|
||||
- The key's type.
|
||||
- One of C(RSA), C(DSA), C(ECC), C(Ed25519), C(X25519), C(Ed448), or C(X448).
|
||||
- Will start with C(unknown) if the key type cannot be determined.
|
||||
returned: success
|
||||
type: str
|
||||
sample: RSA
|
||||
public_data:
|
||||
description:
|
||||
- Public key data. Depends on key type.
|
||||
returned: success
|
||||
type: dict
|
||||
contains:
|
||||
size:
|
||||
description:
|
||||
- Bit size of modulus (RSA) or prime number (DSA).
|
||||
type: int
|
||||
returned: When C(type=RSA) or C(type=DSA)
|
||||
modulus:
|
||||
description:
|
||||
- The RSA key's modulus.
|
||||
type: int
|
||||
returned: When C(type=RSA)
|
||||
exponent:
|
||||
description:
|
||||
- The RSA key's public exponent.
|
||||
type: int
|
||||
returned: When C(type=RSA)
|
||||
p:
|
||||
description:
|
||||
- The C(p) value for DSA.
|
||||
- This is the prime modulus upon which arithmetic takes place.
|
||||
type: int
|
||||
returned: When C(type=DSA)
|
||||
q:
|
||||
description:
|
||||
- The C(q) value for DSA.
|
||||
- This is a prime that divides C(p - 1), and at the same time the order of the subgroup of the
|
||||
multiplicative group of the prime field used.
|
||||
type: int
|
||||
returned: When C(type=DSA)
|
||||
g:
|
||||
description:
|
||||
- The C(g) value for DSA.
|
||||
- This is the element spanning the subgroup of the multiplicative group of the prime field used.
|
||||
type: int
|
||||
returned: When C(type=DSA)
|
||||
curve:
|
||||
description:
|
||||
- The curve's name for ECC.
|
||||
type: str
|
||||
returned: When C(type=ECC)
|
||||
exponent_size:
|
||||
description:
|
||||
- The maximum number of bits of a private key. This is basically the bit size of the subgroup used.
|
||||
type: int
|
||||
returned: When C(type=ECC)
|
||||
x:
|
||||
description:
|
||||
- The C(x) coordinate for the public point on the elliptic curve.
|
||||
type: int
|
||||
returned: When C(type=ECC)
|
||||
y:
|
||||
description:
|
||||
- For C(type=ECC), this is the C(y) coordinate for the public point on the elliptic curve.
|
||||
- For C(type=DSA), this is the publicly known group element whose discrete logarithm w.r.t. C(g) is the private key.
|
||||
type: int
|
||||
returned: When C(type=DSA) or C(type=ECC)
|
||||
'''
|
||||
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.common.text.converters import to_native
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.basic import (
|
||||
OpenSSLObjectError,
|
||||
)
|
||||
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.publickey_info import (
|
||||
PublicKeyParseError,
|
||||
select_backend,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str', no_log=True),
|
||||
select_crypto_backend=dict(type='str', default='auto', choices=['auto', 'cryptography', 'pyopenssl']),
|
||||
),
|
||||
required_one_of=(
|
||||
['path', 'content'],
|
||||
),
|
||||
mutually_exclusive=(
|
||||
['path', 'content'],
|
||||
),
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
result = dict(
|
||||
can_load_key=False,
|
||||
can_parse_key=False,
|
||||
key_is_consistent=None,
|
||||
)
|
||||
|
||||
if module.params['content'] is not None:
|
||||
data = module.params['content'].encode('utf-8')
|
||||
else:
|
||||
try:
|
||||
with open(module.params['path'], 'rb') as f:
|
||||
data = f.read()
|
||||
except (IOError, OSError) as e:
|
||||
module.fail_json(msg='Error while reading public key file from disk: {0}'.format(e), **result)
|
||||
|
||||
backend, module_backend = select_backend(
|
||||
module,
|
||||
module.params['select_crypto_backend'],
|
||||
data)
|
||||
|
||||
try:
|
||||
result.update(module_backend.get_info())
|
||||
module.exit_json(**result)
|
||||
except PublicKeyParseError as exc:
|
||||
result.update(exc.result)
|
||||
module.fail_json(msg=exc.error_message, **result)
|
||||
except OpenSSLObjectError as exc:
|
||||
module.fail_json(msg=to_native(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -139,7 +139,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
|
||||
|
||||
|
||||
|
||||
@@ -139,7 +139,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
|
||||
|
||||
|
||||
|
||||
@@ -362,7 +362,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,
|
||||
@@ -472,7 +472,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."""
|
||||
|
||||
@@ -227,6 +227,77 @@ public_key:
|
||||
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.
|
||||
@@ -304,529 +375,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(
|
||||
@@ -848,53 +412,37 @@ def main():
|
||||
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')
|
||||
|
||||
try:
|
||||
if module.params['path'] is not None:
|
||||
base_dir = os.path.dirname(module.params['path']) or '.'
|
||||
if not os.path.isdir(base_dir):
|
||||
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(
|
||||
name=base_dir,
|
||||
msg='The directory %s does not exist or the file is not a directory' % base_dir
|
||||
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))
|
||||
|
||||
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)
|
||||
try:
|
||||
result = module_backend.get_info()
|
||||
|
||||
# If cryptography is available we'll use it
|
||||
if can_use_cryptography:
|
||||
backend = 'cryptography'
|
||||
elif can_use_pyopenssl:
|
||||
backend = 'pyopenssl'
|
||||
not_before = module_backend.get_not_before()
|
||||
not_after = module_backend.get_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))
|
||||
result['valid_at'] = dict()
|
||||
if valid_at:
|
||||
for k, v in valid_at.items():
|
||||
result['valid_at'][k] = not_before <= v <= not_after
|
||||
|
||||
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))
|
||||
|
||||
@@ -125,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,
|
||||
|
||||
@@ -370,7 +370,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,
|
||||
@@ -409,6 +409,10 @@ 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
|
||||
@@ -550,6 +554,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:
|
||||
@@ -580,7 +597,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)
|
||||
@@ -672,15 +689,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
|
||||
@@ -692,7 +710,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):
|
||||
@@ -742,6 +762,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
|
||||
|
||||
|
||||
@@ -810,7 +834,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()
|
||||
|
||||
@@ -30,6 +30,15 @@ options:
|
||||
- Content of the X.509 CRL in PEM format, or Base64-encoded X.509 CRL.
|
||||
- Either I(path) or I(content) must be specified, but not both.
|
||||
type: str
|
||||
list_revoked_certificates:
|
||||
description:
|
||||
- If set to C(false), the list of revoked certificates is not included in the result.
|
||||
- This is useful when retrieving information on large CRL files. Enumerating all revoked
|
||||
certificates can take some time, including serializing the result as JSON, sending it to
|
||||
the Ansible controller, and decoding it again.
|
||||
type: bool
|
||||
default: true
|
||||
version_added: 1.7.0
|
||||
|
||||
notes:
|
||||
- All timestamp values are provided in ASN.1 TIME format, in other words, following the C(YYYYMMDDHHMMSSZ) pattern.
|
||||
@@ -48,6 +57,12 @@ EXAMPLES = r'''
|
||||
- name: Print the information
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ result }}"
|
||||
|
||||
- name: Get information on CRL without list of revoked certificates
|
||||
community.crypto.x509_crl_info:
|
||||
path: /etc/ssl/very-large.crl
|
||||
list_revoked_certificates: false
|
||||
register: result
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
@@ -87,7 +102,7 @@ digest:
|
||||
sample: sha256WithRSAEncryption
|
||||
revoked_certificates:
|
||||
description: List of certificates to be revoked.
|
||||
returned: success
|
||||
returned: success if I(list_revoked_certificates=true)
|
||||
type: list
|
||||
elements: dict
|
||||
contains:
|
||||
@@ -134,129 +149,22 @@ revoked_certificates:
|
||||
|
||||
|
||||
import base64
|
||||
import traceback
|
||||
import binascii
|
||||
|
||||
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.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,
|
||||
)
|
||||
|
||||
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 CRLError(OpenSSLObjectError):
|
||||
pass
|
||||
|
||||
|
||||
class CRLInfo(OpenSSLObject):
|
||||
"""The main module implementation."""
|
||||
|
||||
def __init__(self, module):
|
||||
super(CRLInfo, self).__init__(
|
||||
module.params['path'] or '',
|
||||
'present',
|
||||
False,
|
||||
module.check_mode
|
||||
)
|
||||
|
||||
self.content = module.params['content']
|
||||
|
||||
self.module = module
|
||||
|
||||
self.crl = None
|
||||
if self.content is None:
|
||||
try:
|
||||
with open(self.path, 'rb') as f:
|
||||
data = f.read()
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e))
|
||||
else:
|
||||
data = self.content.encode('utf-8')
|
||||
if not identify_pem_format(data):
|
||||
data = base64.b64decode(self.content)
|
||||
|
||||
self.crl_pem = identify_pem_format(data)
|
||||
try:
|
||||
if self.crl_pem:
|
||||
self.crl = x509.load_pem_x509_crl(data, default_backend())
|
||||
else:
|
||||
self.crl = x509.load_der_x509_crl(data, default_backend())
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg='Error while decoding CRL: {0}'.format(e))
|
||||
|
||||
def get_info(self):
|
||||
result = {
|
||||
'changed': False,
|
||||
'format': 'pem' if self.crl_pem else 'der',
|
||||
'last_update': None,
|
||||
'next_update': None,
|
||||
'digest': None,
|
||||
'issuer_ordered': None,
|
||||
'issuer': None,
|
||||
'revoked_certificates': [],
|
||||
}
|
||||
|
||||
result['last_update'] = self.crl.last_update.strftime(TIMESTAMP_FORMAT)
|
||||
result['next_update'] = self.crl.next_update.strftime(TIMESTAMP_FORMAT)
|
||||
result['digest'] = cryptography_oid_to_name(cryptography_get_signature_algorithm_oid_from_crl(self.crl))
|
||||
issuer = []
|
||||
for attribute in self.crl.issuer:
|
||||
issuer.append([cryptography_oid_to_name(attribute.oid), attribute.value])
|
||||
result['issuer_ordered'] = issuer
|
||||
result['issuer'] = {}
|
||||
for k, v in issuer:
|
||||
result['issuer'][k] = v
|
||||
result['revoked_certificates'] = []
|
||||
for cert in self.crl:
|
||||
entry = cryptography_decode_revoked_certificate(cert)
|
||||
result['revoked_certificates'].append(cryptography_dump_revoked(entry))
|
||||
|
||||
return result
|
||||
|
||||
def generate(self):
|
||||
# Empty method because OpenSSLObject wants this
|
||||
pass
|
||||
|
||||
def dump(self):
|
||||
# Empty method because OpenSSLObject wants this
|
||||
pass
|
||||
from ansible_collections.community.crypto.plugins.module_utils.crypto.module_backends.crl_info import (
|
||||
get_crl_info,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -264,6 +172,7 @@ def main():
|
||||
argument_spec=dict(
|
||||
path=dict(type='path'),
|
||||
content=dict(type='str'),
|
||||
list_revoked_certificates=dict(type='bool', default=True),
|
||||
),
|
||||
required_one_of=(
|
||||
['path', 'content'],
|
||||
@@ -274,13 +183,22 @@ def main():
|
||||
supports_check_mode=True,
|
||||
)
|
||||
|
||||
if not CRYPTOGRAPHY_FOUND:
|
||||
module.fail_json(msg=missing_required_lib('cryptography >= {0}'.format(MINIMAL_CRYPTOGRAPHY_VERSION)),
|
||||
exception=CRYPTOGRAPHY_IMP_ERR)
|
||||
if module.params['content'] is None:
|
||||
try:
|
||||
with open(module.params['path'], 'rb') as f:
|
||||
data = f.read()
|
||||
except (IOError, OSError) as e:
|
||||
module.fail_json(msg='Error while reading CRL file from disk: {0}'.format(e))
|
||||
else:
|
||||
data = module.params['content'].encode('utf-8')
|
||||
if not identify_pem_format(data):
|
||||
try:
|
||||
data = base64.b64decode(module.params['content'])
|
||||
except (binascii.Error, TypeError) as e:
|
||||
module.fail_json(msg='Error while Base64 decoding content: {0}'.format(e))
|
||||
|
||||
try:
|
||||
crl = CRLInfo(module)
|
||||
result = crl.get_info()
|
||||
result = get_crl_info(module, data, list_revoked_certificates=module.params['list_revoked_certificates'])
|
||||
module.exit_json(**result)
|
||||
except OpenSSLObjectError as e:
|
||||
module.fail_json(msg=to_native(e))
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
# Copyright (c) 2016 Toshio Kuratomi <tkuratomi@ansible.com>
|
||||
# Copyright (c) 2019 Ansible Project
|
||||
# Copyright (c) 2020 Felix Fontein <felix@fontein.de>
|
||||
# Copyright (c) 2021 Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
# Parts taken from ansible.module_utils.basic and ansible.module_utils.common.warnings.
|
||||
|
||||
# NOTE: THIS MUST NOT BE USED BY A MODULE! THIS IS ONLY FOR ACTION PLUGINS!
|
||||
# NOTE: THIS IS ONLY FOR ACTION PLUGINS!
|
||||
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
@@ -26,9 +27,6 @@ from ansible.module_utils.common._collections_compat import (
|
||||
Mapping
|
||||
)
|
||||
from ansible.module_utils.common.parameters import (
|
||||
handle_aliases,
|
||||
list_deprecations,
|
||||
list_no_log_values,
|
||||
PASS_VARS,
|
||||
PASS_BOOLS,
|
||||
)
|
||||
@@ -62,10 +60,30 @@ from ansible.module_utils.six import (
|
||||
string_types,
|
||||
text_type,
|
||||
)
|
||||
from ansible.module_utils._text import to_native, to_text
|
||||
from ansible.module_utils.common.text.converters import to_native, to_text
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
|
||||
try:
|
||||
# For ansible-core 2.11, we can use the ArgumentSpecValidator. We also import
|
||||
# ModuleArgumentSpecValidator since that indicates that the 'classical' approach
|
||||
# will no longer work.
|
||||
from ansible.module_utils.common.arg_spec import (
|
||||
ArgumentSpecValidator,
|
||||
ModuleArgumentSpecValidator, # noqa
|
||||
)
|
||||
from ansible.module_utils.errors import UnsupportedError
|
||||
HAS_ARGSPEC_VALIDATOR = True
|
||||
except ImportError:
|
||||
# For ansible-base 2.10 and Ansible 2.9, we need to use the 'classical' approach
|
||||
from ansible.module_utils.common.parameters import (
|
||||
handle_aliases,
|
||||
list_deprecations,
|
||||
list_no_log_values,
|
||||
)
|
||||
HAS_ARGSPEC_VALIDATOR = False
|
||||
|
||||
|
||||
class _ModuleExitException(Exception):
|
||||
def __init__(self, result):
|
||||
super(_ModuleExitException, self).__init__()
|
||||
@@ -104,54 +122,91 @@ class AnsibleActionModule(object):
|
||||
self._options_context = list()
|
||||
|
||||
self.params = copy.deepcopy(action_plugin._task.args)
|
||||
self._set_fallbacks()
|
||||
|
||||
# append to legal_inputs and then possibly check against them
|
||||
try:
|
||||
self.aliases = self._handle_aliases()
|
||||
except (ValueError, TypeError) as e:
|
||||
# Use exceptions here because it isn't safe to call fail_json until no_log is processed
|
||||
raise _ModuleExitException(dict(failed=True, msg="Module alias error: %s" % to_native(e)))
|
||||
|
||||
# Save parameter values that should never be logged
|
||||
self.no_log_values = set()
|
||||
self._handle_no_log_values()
|
||||
if HAS_ARGSPEC_VALIDATOR:
|
||||
self._validator = ArgumentSpecValidator(
|
||||
self.argument_spec,
|
||||
self.mutually_exclusive,
|
||||
self.required_together,
|
||||
self.required_one_of,
|
||||
self.required_if,
|
||||
self.required_by,
|
||||
)
|
||||
self._validation_result = self._validator.validate(self.params)
|
||||
self.params.update(self._validation_result.validated_parameters)
|
||||
self.no_log_values.update(self._validation_result._no_log_values)
|
||||
|
||||
self._check_arguments()
|
||||
try:
|
||||
error = self._validation_result.errors[0]
|
||||
except IndexError:
|
||||
error = None
|
||||
|
||||
# check exclusive early
|
||||
if not bypass_checks:
|
||||
self._check_mutually_exclusive(mutually_exclusive)
|
||||
# We cannot use ModuleArgumentSpecValidator directly since it uses mechanisms for reporting
|
||||
# warnings and deprecations that do not work in plugins. This is a copy of that code adjusted
|
||||
# for our use-case:
|
||||
for d in self._validation_result._deprecations:
|
||||
self.deprecate(
|
||||
"Alias '{name}' is deprecated. See the module docs for more information".format(name=d['name']),
|
||||
version=d.get('version'), date=d.get('date'), collection_name=d.get('collection_name'))
|
||||
|
||||
self._set_defaults(pre=True)
|
||||
for w in self._validation_result._warnings:
|
||||
self.warn('Both option {option} and its alias {alias} are set.'.format(option=w['option'], alias=w['alias']))
|
||||
|
||||
self._CHECK_ARGUMENT_TYPES_DISPATCHER = {
|
||||
'str': self._check_type_str,
|
||||
'list': self._check_type_list,
|
||||
'dict': self._check_type_dict,
|
||||
'bool': self._check_type_bool,
|
||||
'int': self._check_type_int,
|
||||
'float': self._check_type_float,
|
||||
'path': self._check_type_path,
|
||||
'raw': self._check_type_raw,
|
||||
'jsonarg': self._check_type_jsonarg,
|
||||
'json': self._check_type_jsonarg,
|
||||
'bytes': self._check_type_bytes,
|
||||
'bits': self._check_type_bits,
|
||||
}
|
||||
if not bypass_checks:
|
||||
self._check_required_arguments()
|
||||
self._check_argument_types()
|
||||
self._check_argument_values()
|
||||
self._check_required_together(required_together)
|
||||
self._check_required_one_of(required_one_of)
|
||||
self._check_required_if(required_if)
|
||||
self._check_required_by(required_by)
|
||||
# Fail for validation errors, even in check mode
|
||||
if error:
|
||||
msg = self._validation_result.errors.msg
|
||||
if isinstance(error, UnsupportedError):
|
||||
msg = "Unsupported parameters for ({name}) {kind}: {msg}".format(name=self._name, kind='module', msg=msg)
|
||||
|
||||
self._set_defaults(pre=False)
|
||||
self.fail_json(msg=msg)
|
||||
else:
|
||||
self._set_fallbacks()
|
||||
|
||||
# deal with options sub-spec
|
||||
self._handle_options()
|
||||
# append to legal_inputs and then possibly check against them
|
||||
try:
|
||||
self.aliases = self._handle_aliases()
|
||||
except (ValueError, TypeError) as e:
|
||||
# Use exceptions here because it isn't safe to call fail_json until no_log is processed
|
||||
raise _ModuleExitException(dict(failed=True, msg="Module alias error: %s" % to_native(e)))
|
||||
|
||||
# Save parameter values that should never be logged
|
||||
self._handle_no_log_values()
|
||||
|
||||
self._check_arguments()
|
||||
|
||||
# check exclusive early
|
||||
if not bypass_checks:
|
||||
self._check_mutually_exclusive(mutually_exclusive)
|
||||
|
||||
self._set_defaults(pre=True)
|
||||
|
||||
self._CHECK_ARGUMENT_TYPES_DISPATCHER = {
|
||||
'str': self._check_type_str,
|
||||
'list': check_type_list,
|
||||
'dict': check_type_dict,
|
||||
'bool': check_type_bool,
|
||||
'int': check_type_int,
|
||||
'float': check_type_float,
|
||||
'path': check_type_path,
|
||||
'raw': check_type_raw,
|
||||
'jsonarg': check_type_jsonarg,
|
||||
'json': check_type_jsonarg,
|
||||
'bytes': check_type_bytes,
|
||||
'bits': check_type_bits,
|
||||
}
|
||||
if not bypass_checks:
|
||||
self._check_required_arguments()
|
||||
self._check_argument_types()
|
||||
self._check_argument_values()
|
||||
self._check_required_together(required_together)
|
||||
self._check_required_one_of(required_one_of)
|
||||
self._check_required_if(required_if)
|
||||
self._check_required_by(required_by)
|
||||
|
||||
self._set_defaults(pre=False)
|
||||
|
||||
# deal with options sub-spec
|
||||
self._handle_options()
|
||||
|
||||
def _handle_aliases(self, spec=None, param=None, option_prefix=''):
|
||||
if spec is None:
|
||||
@@ -413,36 +468,6 @@ class AnsibleActionModule(object):
|
||||
self.warn(to_native(msg))
|
||||
return to_native(value, errors='surrogate_or_strict')
|
||||
|
||||
def _check_type_list(self, value):
|
||||
return check_type_list(value)
|
||||
|
||||
def _check_type_dict(self, value):
|
||||
return check_type_dict(value)
|
||||
|
||||
def _check_type_bool(self, value):
|
||||
return check_type_bool(value)
|
||||
|
||||
def _check_type_int(self, value):
|
||||
return check_type_int(value)
|
||||
|
||||
def _check_type_float(self, value):
|
||||
return check_type_float(value)
|
||||
|
||||
def _check_type_path(self, value):
|
||||
return check_type_path(value)
|
||||
|
||||
def _check_type_jsonarg(self, value):
|
||||
return check_type_jsonarg(value)
|
||||
|
||||
def _check_type_raw(self, value):
|
||||
return check_type_raw(value)
|
||||
|
||||
def _check_type_bytes(self, value):
|
||||
return check_type_bytes(value)
|
||||
|
||||
def _check_type_bits(self, value):
|
||||
return check_type_bits(value)
|
||||
|
||||
def _handle_options(self, argument_spec=None, params=None, prefix=''):
|
||||
''' deal with options to create sub spec '''
|
||||
if argument_spec is None:
|
||||
|
||||
5
tests/config.yml
Normal file
5
tests/config.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
# See template for more information:
|
||||
# https://github.com/ansible/ansible/blob/devel/test/lib/ansible_test/config/config.yml
|
||||
modules:
|
||||
python_requires: default
|
||||
@@ -1,2 +1,14 @@
|
||||
shippable/cloud/group1
|
||||
cloud/acme
|
||||
|
||||
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
|
||||
# (https://github.com/ansible/ansible/issues/75711)
|
||||
# shippable/posix/group1
|
||||
|
||||
# Skip all VMs, since we cannot talk to the ACME simulator from these:
|
||||
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
|
||||
# skip/aix
|
||||
# skip/freebsd
|
||||
# skip/macos
|
||||
# skip/osx
|
||||
# skip/rhel
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
dependencies:
|
||||
- setup_acme
|
||||
- setup_remote_tmp_dir
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
- name: Generate account keys
|
||||
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/{{ item }}.pem"
|
||||
loop:
|
||||
- accountkey
|
||||
- accountkey2
|
||||
- accountkey3
|
||||
- accountkey4
|
||||
- accountkey5
|
||||
- block:
|
||||
- name: Generate account keys
|
||||
openssl_privatekey:
|
||||
path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
|
||||
passphrase: "{{ item.pass | default(omit) | default(omit, true) }}"
|
||||
cipher: "{{ 'auto' if (item.pass | default(false)) else omit }}"
|
||||
type: ECC
|
||||
curve: secp256r1
|
||||
force: true
|
||||
loop: "{{ account_keys }}"
|
||||
|
||||
- name: Parse account keys (to ease debugging some test failures)
|
||||
command: "{{ openssl_binary }} ec -in {{ output_dir }}/{{ item }}.pem -noout -text"
|
||||
loop:
|
||||
- accountkey
|
||||
- accountkey2
|
||||
- accountkey3
|
||||
- accountkey4
|
||||
- accountkey5
|
||||
- name: Parse account keys (to ease debugging some test failures)
|
||||
openssl_privatekey_info:
|
||||
path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
|
||||
passphrase: "{{ item.pass | default(omit) | default(omit, true) }}"
|
||||
return_private_key_data: true
|
||||
loop: "{{ account_keys }}"
|
||||
|
||||
vars:
|
||||
account_keys:
|
||||
- name: accountkey
|
||||
- name: accountkey2
|
||||
pass: "{{ 'hunter2' if select_crypto_backend != 'openssl' else '' }}"
|
||||
- name: accountkey3
|
||||
- name: accountkey4
|
||||
- name: accountkey5
|
||||
|
||||
- name: Do not try to create account
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -31,7 +40,7 @@
|
||||
- name: Create it now (check mode, diff)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -47,7 +56,7 @@
|
||||
- name: Create it now
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -61,7 +70,7 @@
|
||||
- name: Create it now (idempotent)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -72,10 +81,15 @@
|
||||
- mailto:example@example.org
|
||||
register: account_created_idempotent
|
||||
|
||||
- name: Read account key
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/accountkey.pem'
|
||||
register: slurp
|
||||
|
||||
- name: Change email address (check mode, diff)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}"
|
||||
account_key_content: "{{ slurp.content | b64decode }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -90,7 +104,7 @@
|
||||
- name: Change email address
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}"
|
||||
account_key_content: "{{ slurp.content | b64decode }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -103,7 +117,7 @@
|
||||
- name: Change email address (idempotent)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
account_uri: "{{ account_created.account_uri }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
@@ -117,7 +131,7 @@
|
||||
- name: Cannot access account with wrong URI
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
account_uri: "{{ account_created.account_uri ~ '12345thisdoesnotexist' }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
@@ -130,7 +144,7 @@
|
||||
- name: Clear contact email addresses (check mode, diff)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -144,7 +158,7 @@
|
||||
- name: Clear contact email addresses
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -156,7 +170,7 @@
|
||||
- name: Clear contact email addresses (idempotent)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -168,11 +182,12 @@
|
||||
- name: Change account key (check mode, diff)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
new_account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
new_account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
new_account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
state: changed_key
|
||||
contact:
|
||||
- mailto:example@example.com
|
||||
@@ -183,11 +198,12 @@
|
||||
- name: Change account key
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
new_account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
new_account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
new_account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
state: changed_key
|
||||
contact:
|
||||
- mailto:example@example.com
|
||||
@@ -196,7 +212,8 @@
|
||||
- name: Deactivate account (check mode, diff)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -208,7 +225,8 @@
|
||||
- name: Deactivate account
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -218,7 +236,8 @@
|
||||
- name: Deactivate account (idempotent)
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -228,7 +247,8 @@
|
||||
- name: Do not try to create account II
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
account_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -240,7 +260,7 @@
|
||||
- name: Do not try to create account III
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -252,7 +272,7 @@
|
||||
- name: Create account with External Account Binding
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/{{ item.account }}.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/{{ item.account }}.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
- name: Remove output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Re-create output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: directory
|
||||
|
||||
- block:
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
shippable/cloud/group1
|
||||
cloud/acme
|
||||
|
||||
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
|
||||
# (https://github.com/ansible/ansible/issues/75711)
|
||||
# shippable/posix/group1
|
||||
|
||||
# Skip all VMs, since we cannot talk to the ACME simulator from these:
|
||||
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
|
||||
# skip/aix
|
||||
# skip/freebsd
|
||||
# skip/macos
|
||||
# skip/osx
|
||||
# skip/rhel
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
dependencies:
|
||||
- setup_acme
|
||||
- setup_remote_tmp_dir
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
---
|
||||
- name: Generate account key
|
||||
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem"
|
||||
- block:
|
||||
- name: Generate account keys
|
||||
openssl_privatekey:
|
||||
path: "{{ remote_tmp_dir }}/{{ item }}.pem"
|
||||
type: ECC
|
||||
curve: secp256r1
|
||||
force: true
|
||||
loop: "{{ account_keys }}"
|
||||
|
||||
- name: Generate second account key
|
||||
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey2.pem"
|
||||
- name: Parse account keys (to ease debugging some test failures)
|
||||
openssl_privatekey_info:
|
||||
path: "{{ remote_tmp_dir }}/{{ item }}.pem"
|
||||
return_private_key_data: true
|
||||
loop: "{{ account_keys }}"
|
||||
|
||||
- name: Parse account key (to ease debugging some test failures)
|
||||
command: "{{ openssl_binary }} ec -in {{ output_dir }}/accountkey.pem -noout -text"
|
||||
vars:
|
||||
account_keys:
|
||||
- accountkey
|
||||
- accountkey2
|
||||
|
||||
- name: Check that account does not exist
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -20,7 +31,7 @@
|
||||
- name: Create it now
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -33,16 +44,21 @@
|
||||
- name: Check that account exists
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
register: account_created
|
||||
|
||||
- name: Read account key
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/accountkey.pem'
|
||||
register: slurp
|
||||
|
||||
- name: Clear email address
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}"
|
||||
account_key_content: "{{ slurp.content | b64decode }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -53,7 +69,7 @@
|
||||
- name: Check that account was modified
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -63,7 +79,7 @@
|
||||
- name: Check with wrong account URI
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -73,7 +89,7 @@
|
||||
- name: Check with wrong account key
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/accountkey2.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/accountkey2.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
- name: Remove output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Re-create output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: directory
|
||||
|
||||
- block:
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
shippable/cloud/group1
|
||||
cloud/acme
|
||||
|
||||
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
|
||||
# (https://github.com/ansible/ansible/issues/75711)
|
||||
# shippable/posix/group1
|
||||
|
||||
# Skip all VMs, since we cannot talk to the ACME simulator from these:
|
||||
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
|
||||
# skip/aix
|
||||
# skip/freebsd
|
||||
# skip/macos
|
||||
# skip/osx
|
||||
# skip/rhel
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
dependencies:
|
||||
- setup_acme
|
||||
- setup_pyopenssl # needed for Ubuntu 16.04
|
||||
- setup_remote_tmp_dir
|
||||
- prepare_jinja2_compat
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
---
|
||||
## SET UP ACCOUNT KEYS ########################################################################
|
||||
- name: Create ECC256 account key
|
||||
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem"
|
||||
- name: Create ECC384 account key
|
||||
command: "{{ openssl_binary }} ecparam -name secp384r1 -genkey -out {{ output_dir }}/account-ec384.pem"
|
||||
- name: Create RSA account key
|
||||
command: "{{ openssl_binary }} genrsa -out {{ output_dir }}/account-rsa.pem {{ default_rsa_key_size }}"
|
||||
- block:
|
||||
- name: Generate account keys
|
||||
openssl_privatekey:
|
||||
path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
|
||||
type: "{{ item.type }}"
|
||||
size: "{{ item.size | default(omit) }}"
|
||||
curve: "{{ item.curve | default(omit) }}"
|
||||
force: true
|
||||
loop: "{{ account_keys }}"
|
||||
|
||||
vars:
|
||||
account_keys:
|
||||
- name: account-ec256
|
||||
type: ECC
|
||||
curve: secp256r1
|
||||
- name: account-ec384
|
||||
type: ECC
|
||||
curve: secp384r1
|
||||
- name: account-rsa
|
||||
type: RSA
|
||||
size: "{{ default_rsa_key_size }}"
|
||||
## SET UP ACCOUNTS ############################################################################
|
||||
- name: Make sure ECC256 account hasn't been created yet
|
||||
acme_account:
|
||||
@@ -13,15 +28,19 @@
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
account_key_src: "{{ output_dir }}/account-ec256.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
|
||||
state: absent
|
||||
- name: Read account key (EC384)
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/account-ec384.pem'
|
||||
register: slurp
|
||||
- name: Create ECC384 account
|
||||
acme_account:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/account-ec384.pem') }}"
|
||||
account_key_content: "{{ slurp.content | b64decode }}"
|
||||
state: present
|
||||
allow_creation: yes
|
||||
terms_agreed: yes
|
||||
@@ -34,7 +53,7 @@
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
account_key_src: "{{ output_dir }}/account-rsa.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-rsa.pem"
|
||||
state: present
|
||||
allow_creation: yes
|
||||
terms_agreed: yes
|
||||
@@ -72,6 +91,7 @@
|
||||
vars:
|
||||
certgen_title: Certificate 2
|
||||
certificate_name: cert-2
|
||||
certificate_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else '' }}"
|
||||
key_type: ec256
|
||||
subject_alt_name: "DNS:*.example.com,DNS:example.com"
|
||||
subject_alt_name_critical: yes
|
||||
@@ -99,6 +119,10 @@
|
||||
set_fact:
|
||||
cert_2_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_2_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
- name: Read account key (RSA)
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/account-rsa.pem'
|
||||
register: slurp_account_key
|
||||
- name: Obtain cert 3
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
@@ -107,7 +131,7 @@
|
||||
key_type: ec384
|
||||
subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com"
|
||||
subject_alt_name_critical: no
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/account-rsa.pem') }}"
|
||||
account_key_content: "{{ slurp_account_key.content | b64decode }}"
|
||||
challenge: dns-01
|
||||
modify_account: no
|
||||
deactivate_authzs: no
|
||||
@@ -215,6 +239,10 @@
|
||||
set_fact:
|
||||
cert_5_recreate_2: "{{ challenge_data is changed }}"
|
||||
cert_5c_obtain_results: "{{ certificate_obtain_result }}"
|
||||
- name: Read account key (EC384)
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/account-ec384.pem'
|
||||
register: slurp_account_key
|
||||
- name: Obtain cert 5 (should again by force)
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
@@ -223,7 +251,7 @@
|
||||
key_type: ec521
|
||||
subject_alt_name: "DNS:t2.example.com"
|
||||
subject_alt_name_critical: no
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/account-ec384.pem') }}"
|
||||
account_key_content: "{{ slurp_account_key.content | b64decode }}"
|
||||
challenge: http-01
|
||||
modify_account: no
|
||||
deactivate_authzs: yes
|
||||
@@ -236,189 +264,204 @@
|
||||
set_fact:
|
||||
cert_5_recreate_3: "{{ challenge_data is changed }}"
|
||||
cert_5d_obtain_results: "{{ certificate_obtain_result }}"
|
||||
- name: Obtain cert 6
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 6
|
||||
certificate_name: cert-6
|
||||
key_type: rsa
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name: "DNS:example.org"
|
||||
subject_alt_name_critical: no
|
||||
account_key: account-ec256
|
||||
challenge: tls-alpn-01
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
force: no
|
||||
remaining_days: 10
|
||||
terms_agreed: yes
|
||||
account_email: "example@example.org"
|
||||
acme_expected_root_number: 0
|
||||
select_chain:
|
||||
# All intermediates have the same subject key identifier, so always
|
||||
# the first chain will be found, and we need a second condition to
|
||||
# make sure that the first condition actually works. (The second
|
||||
# condition has been tested above.)
|
||||
- test_certificates: first
|
||||
subject_key_identifier: "{{ acme_intermediates[0].subject_key_identifier }}"
|
||||
- test_certificates: last
|
||||
issuer: "{{ acme_roots[1].subject }}"
|
||||
use_csr_content: true
|
||||
- name: Store obtain results for cert 6
|
||||
set_fact:
|
||||
cert_6_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_6_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
- name: Obtain cert 7
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 7
|
||||
certificate_name: cert-7
|
||||
key_type: rsa
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name:
|
||||
- "IP:127.0.0.1"
|
||||
# - "IP:::1"
|
||||
subject_alt_name_critical: no
|
||||
account_key: account-ec256
|
||||
challenge: http-01
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
force: no
|
||||
remaining_days: 10
|
||||
terms_agreed: yes
|
||||
account_email: "example@example.org"
|
||||
acme_expected_root_number: 2
|
||||
select_chain:
|
||||
- test_certificates: last
|
||||
authority_key_identifier: "{{ acme_roots[2].subject_key_identifier }}"
|
||||
use_csr_content: false
|
||||
- name: Store obtain results for cert 7
|
||||
set_fact:
|
||||
cert_7_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_7_alternate: "{{ 2 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
- name: Obtain cert 8
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 8
|
||||
certificate_name: cert-8
|
||||
key_type: rsa
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name:
|
||||
- "IP:127.0.0.1"
|
||||
# IPv4 only since our test validation server doesn't work
|
||||
# with IPv6 (thanks to Python's socketserver).
|
||||
subject_alt_name_critical: no
|
||||
account_key: account-ec256
|
||||
challenge: tls-alpn-01
|
||||
challenge_alpn_tls: acme_challenge_cert_helper
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
force: no
|
||||
remaining_days: 10
|
||||
terms_agreed: yes
|
||||
account_email: "example@example.org"
|
||||
use_csr_content: true
|
||||
- name: Store obtain results for cert 8
|
||||
set_fact:
|
||||
cert_8_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_8_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
- block:
|
||||
- name: Obtain cert 6
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 6
|
||||
certificate_name: cert-6
|
||||
key_type: rsa
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name: "DNS:example.org"
|
||||
subject_alt_name_critical: no
|
||||
account_key: account-ec256
|
||||
challenge: tls-alpn-01
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
force: no
|
||||
remaining_days: 10
|
||||
terms_agreed: yes
|
||||
account_email: "example@example.org"
|
||||
acme_expected_root_number: 0
|
||||
select_chain:
|
||||
# All intermediates have the same subject key identifier, so always
|
||||
# the first chain will be found, and we need a second condition to
|
||||
# make sure that the first condition actually works. (The second
|
||||
# condition has been tested above.)
|
||||
- test_certificates: first
|
||||
subject_key_identifier: "{{ acme_intermediates[0].subject_key_identifier }}"
|
||||
- test_certificates: last
|
||||
issuer: "{{ acme_roots[1].subject }}"
|
||||
use_csr_content: true
|
||||
- name: Store obtain results for cert 6
|
||||
set_fact:
|
||||
cert_6_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_6_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
when: acme_intermediates[0].subject_key_identifier is defined
|
||||
- block:
|
||||
- name: Obtain cert 7
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 7
|
||||
certificate_name: cert-7
|
||||
key_type: rsa
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name:
|
||||
- "IP:127.0.0.1"
|
||||
# - "IP:::1"
|
||||
subject_alt_name_critical: no
|
||||
account_key: account-ec256
|
||||
challenge: http-01
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
force: no
|
||||
remaining_days: 10
|
||||
terms_agreed: yes
|
||||
account_email: "example@example.org"
|
||||
acme_expected_root_number: 2
|
||||
select_chain:
|
||||
- test_certificates: last
|
||||
authority_key_identifier: "{{ acme_roots[2].subject_key_identifier }}"
|
||||
use_csr_content: false
|
||||
- name: Store obtain results for cert 7
|
||||
set_fact:
|
||||
cert_7_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_7_alternate: "{{ 2 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
when: acme_roots[2].subject_key_identifier is defined
|
||||
- block:
|
||||
- name: Obtain cert 8
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
certgen_title: Certificate 8
|
||||
certificate_name: cert-8
|
||||
key_type: rsa
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name:
|
||||
- "IP:127.0.0.1"
|
||||
# IPv4 only since our test validation server doesn't work
|
||||
# with IPv6 (thanks to Python's socketserver).
|
||||
subject_alt_name_critical: no
|
||||
account_key: account-ec256
|
||||
challenge: tls-alpn-01
|
||||
challenge_alpn_tls: acme_challenge_cert_helper
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
force: no
|
||||
remaining_days: 10
|
||||
terms_agreed: yes
|
||||
account_email: "example@example.org"
|
||||
use_csr_content: true
|
||||
- name: Store obtain results for cert 8
|
||||
set_fact:
|
||||
cert_8_obtain_results: "{{ certificate_obtain_result }}"
|
||||
cert_8_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}"
|
||||
when: cryptography_version.stdout is version('1.3', '>=')
|
||||
## DISSECT CERTIFICATES #######################################################################
|
||||
# Make sure certificates are valid. Root certificate for Pebble equals the chain certificate.
|
||||
- name: Verifying cert 1
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-1-root.pem" -untrusted "{{ output_dir }}/cert-1-chain.pem" "{{ output_dir }}/cert-1.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-1-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-1-chain.pem" "{{ remote_tmp_dir }}/cert-1.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_1_valid
|
||||
- name: Verifying cert 2
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-2-root.pem" -untrusted "{{ output_dir }}/cert-2-chain.pem" "{{ output_dir }}/cert-2.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-2-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-2-chain.pem" "{{ remote_tmp_dir }}/cert-2.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_2_valid
|
||||
- name: Verifying cert 3
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-3-root.pem" -untrusted "{{ output_dir }}/cert-3-chain.pem" "{{ output_dir }}/cert-3.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-3-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-3-chain.pem" "{{ remote_tmp_dir }}/cert-3.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_3_valid
|
||||
- name: Verifying cert 4
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-4-root.pem" -untrusted "{{ output_dir }}/cert-4-chain.pem" "{{ output_dir }}/cert-4.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-4-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-4-chain.pem" "{{ remote_tmp_dir }}/cert-4.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_4_valid
|
||||
- name: Verifying cert 5
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-5-root.pem" -untrusted "{{ output_dir }}/cert-5-chain.pem" "{{ output_dir }}/cert-5.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-5-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-5-chain.pem" "{{ remote_tmp_dir }}/cert-5.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_5_valid
|
||||
- name: Verifying cert 6
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-6-root.pem" -untrusted "{{ output_dir }}/cert-6-chain.pem" "{{ output_dir }}/cert-6.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-6-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-6-chain.pem" "{{ remote_tmp_dir }}/cert-6.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_6_valid
|
||||
when: acme_intermediates[0].subject_key_identifier is defined
|
||||
- name: Verifying cert 7
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-7-root.pem" -untrusted "{{ output_dir }}/cert-7-chain.pem" "{{ output_dir }}/cert-7.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-7-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-7-chain.pem" "{{ remote_tmp_dir }}/cert-7.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_7_valid
|
||||
when: acme_roots[2].subject_key_identifier is defined
|
||||
- name: Verifying cert 8
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ output_dir }}/cert-8-root.pem" -untrusted "{{ output_dir }}/cert-8-chain.pem" "{{ output_dir }}/cert-8.pem"'
|
||||
command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-8-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-8-chain.pem" "{{ remote_tmp_dir }}/cert-8.pem"'
|
||||
ignore_errors: yes
|
||||
register: cert_8_valid
|
||||
when: cryptography_version.stdout is version('1.3', '>=')
|
||||
# Dump certificate info
|
||||
- name: Dumping cert 1
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-1.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-1.pem" -noout -text'
|
||||
register: cert_1_text
|
||||
- name: Dumping cert 2
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-2.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-2.pem" -noout -text'
|
||||
register: cert_2_text
|
||||
- name: Dumping cert 3
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-3.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-3.pem" -noout -text'
|
||||
register: cert_3_text
|
||||
- name: Dumping cert 4
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-4.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-4.pem" -noout -text'
|
||||
register: cert_4_text
|
||||
- name: Dumping cert 5
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-5.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-5.pem" -noout -text'
|
||||
register: cert_5_text
|
||||
- name: Dumping cert 6
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-6.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-6.pem" -noout -text'
|
||||
register: cert_6_text
|
||||
when: acme_intermediates[0].subject_key_identifier is defined
|
||||
- name: Dumping cert 7
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-7.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-7.pem" -noout -text'
|
||||
register: cert_7_text
|
||||
when: acme_roots[2].subject_key_identifier is defined
|
||||
- name: Dumping cert 8
|
||||
command: '{{ openssl_binary }} x509 -in "{{ output_dir }}/cert-8.pem" -noout -text'
|
||||
command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-8.pem" -noout -text'
|
||||
register: cert_8_text
|
||||
when: cryptography_version.stdout is version('1.3', '>=')
|
||||
# Dump certificate info
|
||||
- name: Dumping cert 1
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-1.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-1.pem"
|
||||
register: cert_1_info
|
||||
- name: Dumping cert 2
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-2.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-2.pem"
|
||||
register: cert_2_info
|
||||
- name: Dumping cert 3
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-3.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-3.pem"
|
||||
register: cert_3_info
|
||||
- name: Dumping cert 4
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-4.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-4.pem"
|
||||
register: cert_4_info
|
||||
- name: Dumping cert 5
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-5.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-5.pem"
|
||||
register: cert_5_info
|
||||
- name: Dumping cert 6
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-6.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-6.pem"
|
||||
register: cert_6_info
|
||||
when: acme_intermediates[0].subject_key_identifier is defined
|
||||
- name: Dumping cert 7
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-7.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-7.pem"
|
||||
register: cert_7_info
|
||||
when: acme_roots[2].subject_key_identifier is defined
|
||||
- name: Dumping cert 8
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/cert-8.pem"
|
||||
path: "{{ remote_tmp_dir }}/cert-8.pem"
|
||||
register: cert_8_info
|
||||
when: cryptography_version.stdout is version('1.3', '>=')
|
||||
## GET ACCOUNT ORDERS #########################################################################
|
||||
- name: Don't retrieve orders
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/account-ec256.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -427,7 +470,7 @@
|
||||
- name: Retrieve orders as URL list (1/2)
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/account-ec256.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -436,7 +479,7 @@
|
||||
- name: Retrieve orders as URL list (2/2)
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/account-ec384.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec384.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -445,7 +488,7 @@
|
||||
- name: Retrieve orders as object list (1/2)
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/account-ec256.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -454,7 +497,7 @@
|
||||
- name: Retrieve orders as object list (2/2)
|
||||
acme_account_info:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/account-ec384.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec384.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
|
||||
@@ -8,38 +8,48 @@
|
||||
- name: Obtain root and intermediate certificates
|
||||
get_url:
|
||||
url: "http://{{ acme_host }}:5000/{{ item.0 }}-certificate-for-ca/{{ item.1 }}"
|
||||
dest: "{{ output_dir }}/acme-{{ item.0 }}-{{ item.1 }}.pem"
|
||||
dest: "{{ remote_tmp_dir }}/acme-{{ item.0 }}-{{ item.1 }}.pem"
|
||||
loop: "{{ query('nested', types, root_numbers) }}"
|
||||
|
||||
- name: Analyze root certificates
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/acme-root-{{ item }}.pem"
|
||||
path: "{{ remote_tmp_dir }}/acme-root-{{ item }}.pem"
|
||||
loop: "{{ root_numbers }}"
|
||||
register: acme_roots
|
||||
|
||||
- name: Analyze intermediate certificates
|
||||
x509_certificate_info:
|
||||
path: "{{ output_dir }}/acme-intermediate-{{ item }}.pem"
|
||||
path: "{{ remote_tmp_dir }}/acme-intermediate-{{ item }}.pem"
|
||||
loop: "{{ root_numbers }}"
|
||||
register: acme_intermediates
|
||||
|
||||
- set_fact:
|
||||
x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}"
|
||||
y__: "{{ lookup('file', output_dir ~ '/acme-root-' ~ item.item ~ '.pem', rstrip=False) }}"
|
||||
loop: "{{ acme_roots.results }}"
|
||||
register: acme_roots_tmp
|
||||
- name: Read root certificates
|
||||
slurp:
|
||||
src: "{{ remote_tmp_dir ~ '/acme-root-' ~ item ~ '.pem' }}"
|
||||
loop: "{{ root_numbers }}"
|
||||
register: slurp_roots
|
||||
|
||||
- set_fact:
|
||||
x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}"
|
||||
loop: "{{ acme_roots.results }}"
|
||||
register: acme_roots_tmp
|
||||
|
||||
- name: Read intermediate certificates
|
||||
slurp:
|
||||
src: "{{ remote_tmp_dir ~ '/acme-intermediate-' ~ item ~ '.pem' }}"
|
||||
loop: "{{ root_numbers }}"
|
||||
register: slurp_intermediates
|
||||
|
||||
- set_fact:
|
||||
x__: "{{ item | dict2items | selectattr('key', 'in', interesting_keys) | list | items2dict }}"
|
||||
y__: "{{ lookup('file', output_dir ~ '/acme-intermediate-' ~ item.item ~ '.pem', rstrip=False) }}"
|
||||
loop: "{{ acme_intermediates.results }}"
|
||||
register: acme_intermediates_tmp
|
||||
|
||||
- set_fact:
|
||||
acme_roots: "{{ acme_roots_tmp.results | map(attribute='ansible_facts.x__') | list }}"
|
||||
acme_root_certs: "{{ acme_roots_tmp.results | map(attribute='ansible_facts.y__') | list }}"
|
||||
acme_root_certs: "{{ slurp_roots.results | map(attribute='content') | map('b64decode') | list }}"
|
||||
acme_intermediates: "{{ acme_intermediates_tmp.results | map(attribute='ansible_facts.x__') | list }}"
|
||||
acme_intermediate_certs: "{{ acme_intermediates_tmp.results | map(attribute='ansible_facts.y__') | list }}"
|
||||
acme_intermediate_certs: "{{ slurp_intermediates.results | map(attribute='content') | map('b64decode') | list }}"
|
||||
|
||||
vars:
|
||||
types:
|
||||
@@ -88,12 +98,12 @@
|
||||
|
||||
- name: Remove output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Re-create output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: directory
|
||||
|
||||
- block:
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
assert:
|
||||
that:
|
||||
- "'DNS:example.com' in cert_1_text.stdout"
|
||||
- name: Read certificate 1 files
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/{{ item }}'
|
||||
loop:
|
||||
- cert-1.pem
|
||||
- cert-1-chain.pem
|
||||
- cert-1-fullchain.pem
|
||||
register: slurp
|
||||
- name: Check that certificate 1 retrieval got all chains
|
||||
assert:
|
||||
that:
|
||||
@@ -15,9 +23,9 @@
|
||||
- "'cert' in cert_1_obtain_results.all_chains[cert_1_alternate | int]"
|
||||
- "'chain' in cert_1_obtain_results.all_chains[cert_1_alternate | int]"
|
||||
- "'full_chain' in cert_1_obtain_results.all_chains[cert_1_alternate | int]"
|
||||
- "lookup('file', output_dir ~ '/cert-1.pem', rstrip=False) == cert_1_obtain_results.all_chains[cert_1_alternate | int].cert"
|
||||
- "lookup('file', output_dir ~ '/cert-1-chain.pem', rstrip=False) == cert_1_obtain_results.all_chains[cert_1_alternate | int].chain"
|
||||
- "lookup('file', output_dir ~ '/cert-1-fullchain.pem', rstrip=False) == cert_1_obtain_results.all_chains[cert_1_alternate | int].full_chain"
|
||||
- "(slurp.results[0].content | b64decode) == cert_1_obtain_results.all_chains[cert_1_alternate | int].cert"
|
||||
- "(slurp.results[1].content | b64decode) == cert_1_obtain_results.all_chains[cert_1_alternate | int].chain"
|
||||
- "(slurp.results[2].content | b64decode) == cert_1_obtain_results.all_chains[cert_1_alternate | int].full_chain"
|
||||
|
||||
- name: Check that certificate 2 is valid
|
||||
assert:
|
||||
@@ -28,6 +36,14 @@
|
||||
that:
|
||||
- "'DNS:*.example.com' in cert_2_text.stdout"
|
||||
- "'DNS:example.com' in cert_2_text.stdout"
|
||||
- name: Read certificate 2 files
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/{{ item }}'
|
||||
loop:
|
||||
- cert-2.pem
|
||||
- cert-2-chain.pem
|
||||
- cert-2-fullchain.pem
|
||||
register: slurp
|
||||
- name: Check that certificate 1 retrieval got all chains
|
||||
assert:
|
||||
that:
|
||||
@@ -36,9 +52,9 @@
|
||||
- "'cert' in cert_2_obtain_results.all_chains[cert_2_alternate | int]"
|
||||
- "'chain' in cert_2_obtain_results.all_chains[cert_2_alternate | int]"
|
||||
- "'full_chain' in cert_2_obtain_results.all_chains[cert_2_alternate | int]"
|
||||
- "lookup('file', output_dir ~ '/cert-2.pem', rstrip=False) == cert_2_obtain_results.all_chains[cert_2_alternate | int].cert"
|
||||
- "lookup('file', output_dir ~ '/cert-2-chain.pem', rstrip=False) == cert_2_obtain_results.all_chains[cert_2_alternate | int].chain"
|
||||
- "lookup('file', output_dir ~ '/cert-2-fullchain.pem', rstrip=False) == cert_2_obtain_results.all_chains[cert_2_alternate | int].full_chain"
|
||||
- "(slurp.results[0].content | b64decode) == cert_2_obtain_results.all_chains[cert_2_alternate | int].cert"
|
||||
- "(slurp.results[1].content | b64decode) == cert_2_obtain_results.all_chains[cert_2_alternate | int].chain"
|
||||
- "(slurp.results[2].content | b64decode) == cert_2_obtain_results.all_chains[cert_2_alternate | int].full_chain"
|
||||
|
||||
- name: Check that certificate 3 is valid
|
||||
assert:
|
||||
@@ -50,6 +66,14 @@
|
||||
- "'DNS:*.example.com' in cert_3_text.stdout"
|
||||
- "'DNS:example.org' in cert_3_text.stdout"
|
||||
- "'DNS:t1.example.com' in cert_3_text.stdout"
|
||||
- name: Read certificate 3 files
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/{{ item }}'
|
||||
loop:
|
||||
- cert-3.pem
|
||||
- cert-3-chain.pem
|
||||
- cert-3-fullchain.pem
|
||||
register: slurp
|
||||
- name: Check that certificate 1 retrieval got all chains
|
||||
assert:
|
||||
that:
|
||||
@@ -58,9 +82,9 @@
|
||||
- "'cert' in cert_3_obtain_results.all_chains[cert_3_alternate | int]"
|
||||
- "'chain' in cert_3_obtain_results.all_chains[cert_3_alternate | int]"
|
||||
- "'full_chain' in cert_3_obtain_results.all_chains[cert_3_alternate | int]"
|
||||
- "lookup('file', output_dir ~ '/cert-3.pem', rstrip=False) == cert_3_obtain_results.all_chains[cert_3_alternate | int].cert"
|
||||
- "lookup('file', output_dir ~ '/cert-3-chain.pem', rstrip=False) == cert_3_obtain_results.all_chains[cert_3_alternate | int].chain"
|
||||
- "lookup('file', output_dir ~ '/cert-3-fullchain.pem', rstrip=False) == cert_3_obtain_results.all_chains[cert_3_alternate | int].full_chain"
|
||||
- "(slurp.results[0].content | b64decode) == cert_3_obtain_results.all_chains[cert_3_alternate | int].cert"
|
||||
- "(slurp.results[1].content | b64decode) == cert_3_obtain_results.all_chains[cert_3_alternate | int].chain"
|
||||
- "(slurp.results[2].content | b64decode) == cert_3_obtain_results.all_chains[cert_3_alternate | int].full_chain"
|
||||
|
||||
- name: Check that certificate 4 is valid
|
||||
assert:
|
||||
@@ -100,14 +124,38 @@
|
||||
that:
|
||||
- cert_5_recreate_3 == True
|
||||
|
||||
- name: Check that certificate 6 is valid
|
||||
assert:
|
||||
that:
|
||||
- cert_6_valid is not failed
|
||||
- name: Check that certificate 6 contains correct SANs
|
||||
assert:
|
||||
that:
|
||||
- "'DNS:example.org' in cert_6_text.stdout"
|
||||
- block:
|
||||
- name: Check that certificate 6 is valid
|
||||
assert:
|
||||
that:
|
||||
- cert_6_valid is not failed
|
||||
- name: Check that certificate 6 contains correct SANs
|
||||
assert:
|
||||
that:
|
||||
- "'DNS:example.org' in cert_6_text.stdout"
|
||||
when: acme_intermediates[0].subject_key_identifier is defined
|
||||
|
||||
- block:
|
||||
- name: Check that certificate 7 is valid
|
||||
assert:
|
||||
that:
|
||||
- cert_7_valid is not failed
|
||||
- name: Check that certificate 7 contains correct SANs
|
||||
assert:
|
||||
that:
|
||||
- "'IP Address:127.0.0.1' in cert_8_text.stdout or 'IP:127.0.0.1' in cert_8_text.stdout"
|
||||
when: acme_roots[2].subject_key_identifier is defined
|
||||
|
||||
- block:
|
||||
- name: Check that certificate 8 is valid
|
||||
assert:
|
||||
that:
|
||||
- cert_8_valid is not failed
|
||||
- name: Check that certificate 8 contains correct SANs
|
||||
assert:
|
||||
that:
|
||||
- "'IP Address:127.0.0.1' in cert_8_text.stdout or 'IP:127.0.0.1' in cert_8_text.stdout"
|
||||
when: cryptography_version.stdout is version('1.3', '>=')
|
||||
|
||||
- name: Validate that orders were not retrieved
|
||||
assert:
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
shippable/cloud/group1
|
||||
cloud/acme
|
||||
|
||||
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
|
||||
# (https://github.com/ansible/ansible/issues/75711)
|
||||
# shippable/posix/group1
|
||||
|
||||
# Skip all VMs, since we cannot talk to the ACME simulator from these:
|
||||
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
|
||||
# skip/aix
|
||||
# skip/freebsd
|
||||
# skip/macos
|
||||
# skip/osx
|
||||
# skip/rhel
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
dependencies:
|
||||
- setup_acme
|
||||
- setup_remote_tmp_dir
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
---
|
||||
## SET UP ACCOUNT KEYS ########################################################################
|
||||
- name: Create ECC256 account key
|
||||
command: "{{ openssl_binary }} ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem"
|
||||
- name: Create ECC384 account key
|
||||
command: "{{ openssl_binary }} ecparam -name secp384r1 -genkey -out {{ output_dir }}/account-ec384.pem"
|
||||
- name: Create RSA account key
|
||||
command: "{{ openssl_binary }} genrsa -out {{ output_dir }}/account-rsa.pem {{ default_rsa_key_size }}"
|
||||
- block:
|
||||
- name: Generate account keys
|
||||
openssl_privatekey:
|
||||
path: "{{ remote_tmp_dir }}/{{ item.name }}.pem"
|
||||
type: "{{ item.type }}"
|
||||
size: "{{ item.size | default(omit) }}"
|
||||
curve: "{{ item.curve | default(omit) }}"
|
||||
force: true
|
||||
loop: "{{ account_keys }}"
|
||||
|
||||
vars:
|
||||
account_keys:
|
||||
- name: account-ec256
|
||||
type: ECC
|
||||
curve: secp256r1
|
||||
- name: account-ec384
|
||||
type: ECC
|
||||
curve: secp384r1
|
||||
- name: account-rsa
|
||||
type: RSA
|
||||
size: "{{ default_rsa_key_size }}"
|
||||
## CREATE ACCOUNTS AND OBTAIN CERTIFICATES ####################################################
|
||||
- name: Read account key (EC256)
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/account-ec256.pem'
|
||||
register: slurp_account_key
|
||||
- name: Obtain cert 1
|
||||
include_tasks: obtain-cert.yml
|
||||
vars:
|
||||
@@ -16,7 +35,7 @@
|
||||
rsa_bits: "{{ default_rsa_key_size }}"
|
||||
subject_alt_name: "DNS:example.com"
|
||||
subject_alt_name_critical: no
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/account-ec256.pem') }}"
|
||||
account_key_content: "{{ slurp_account_key.content | b64decode }}"
|
||||
challenge: http-01
|
||||
modify_account: yes
|
||||
deactivate_authzs: no
|
||||
@@ -29,6 +48,7 @@
|
||||
vars:
|
||||
certgen_title: Certificate 2 for revocation
|
||||
certificate_name: cert-2
|
||||
certificate_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else '' }}"
|
||||
key_type: ec256
|
||||
subject_alt_name: "DNS:*.example.com"
|
||||
subject_alt_name_critical: yes
|
||||
@@ -60,8 +80,8 @@
|
||||
- name: Revoke certificate 1 via account key
|
||||
acme_certificate_revoke:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_src: "{{ output_dir }}/account-ec256.pem"
|
||||
certificate: "{{ output_dir }}/cert-1.pem"
|
||||
account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem"
|
||||
certificate: "{{ remote_tmp_dir }}/cert-1.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
@@ -70,18 +90,23 @@
|
||||
- name: Revoke certificate 2 via certificate private key
|
||||
acme_certificate_revoke:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
private_key_src: "{{ output_dir }}/cert-2.key"
|
||||
certificate: "{{ output_dir }}/cert-2.pem"
|
||||
private_key_src: "{{ remote_tmp_dir }}/cert-2.key"
|
||||
private_key_passphrase: "{{ 'hunter2' if select_crypto_backend != 'openssl' else omit }}"
|
||||
certificate: "{{ remote_tmp_dir }}/cert-2.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
ignore_errors: yes
|
||||
register: cert_2_revoke
|
||||
- name: Read account key (RSA)
|
||||
slurp:
|
||||
src: '{{ remote_tmp_dir }}/account-rsa.pem'
|
||||
register: slurp_account_key
|
||||
- name: Revoke certificate 3 via account key (fullchain)
|
||||
acme_certificate_revoke:
|
||||
select_crypto_backend: "{{ select_crypto_backend }}"
|
||||
account_key_content: "{{ lookup('file', output_dir ~ '/account-rsa.pem') }}"
|
||||
certificate: "{{ output_dir }}/cert-3-fullchain.pem"
|
||||
account_key_content: "{{ slurp_account_key.content | b64decode }}"
|
||||
certificate: "{{ remote_tmp_dir }}/cert-3-fullchain.pem"
|
||||
acme_version: 2
|
||||
acme_directory: https://{{ acme_host }}:14000/dir
|
||||
validate_certs: no
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
- name: Remove output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: Re-create output directory
|
||||
file:
|
||||
path: "{{ output_dir }}"
|
||||
path: "{{ remote_tmp_dir }}"
|
||||
state: directory
|
||||
|
||||
- block:
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
shippable/cloud/group1
|
||||
cloud/acme
|
||||
|
||||
# Since skipping below fails miserably with ansible-core 2.11 and earlier, we have to skip all POSIX tests...
|
||||
# (https://github.com/ansible/ansible/issues/75711)
|
||||
# shippable/posix/group1
|
||||
|
||||
# Skip all VMs, since we cannot talk to the ACME simulator from these:
|
||||
# (TODO: remove when ansible-core 2.12 is the earliest version we support)
|
||||
# skip/aix
|
||||
# skip/freebsd
|
||||
# skip/macos
|
||||
# skip/osx
|
||||
# skip/rhel
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user