Repository: ansible/ansible

Issue:
OpenSSL_{public,private}key do not expose the key's fingerprint(s)
##### ISSUE TYPE
 - Feature Idea

##### COMPONENT NAME
Modules openssl_privatekey and openssl_publickey

##### ANSIBLE VERSION
```
ansible 2.2.1.0
  config file = /home/kellerfuchs/hashbang/admin-tools/ansible.cfg
  configured module search path = Default w/o overrides
```

##### OS / ENVIRONMENT
Debian “Stretch” (current testing release, undergoing freeze)

##### SUMMARY
The modules `openssl_privatekey` and `openssl_publickey` should provide, as returned values, the pubkey's hash, under various algorithms (say, SHA-1, SHA-256 and SHA-512; yes, SHA-1 is broken but lots of software only support it for pinning pubkeys).

Those hashes can then be used in configuration templates and so on.

##### STEPS TO REPRODUCE
An example playbook, inspired by <https://github.com/hashbang/admin-tools>, would look like
```yaml
- hosts: irc_servers
  tasks:
    - name: Create server-link certificate if needed
      openssl_privatekey:
        path: /home/unrealircd/server_link.pem
        size: 4096
        type: RSA
      register: link_key
      notify: [rehash ircd]

    - name: Create a matching self-signed cert
      command: openssl x509 [...]
      when: link_key.changed

    - name: Copy static configuration
      copy:
        src: "{{ item }}"
        dest: /home/core/unrealircd/{{ item | basename }}
      with_fileglob:
        - unrealircd/*.conf
        - unrealircd/ircd.motd
      notify: [rehash ircd]

    - name: Generate templated configuration
      template:
        src: "{{ item }}"
        dest: /home/core/unrealircd/{{ item | basename | regex_replace('\.j2$', '') }}
      with_fileglob:
        - unrealircd/*.j2
      notify: [rehash ircd]
```

Then, it becomes possible to use configuration snippets such as:
```
{% for server in groups.irc_servers if not server == ansible_fqdn %}
link {{ server }} {
    username *;
    hostname "{{ server }}";
    port 6697;
    password-connect "*";
    password-receive "{{ hostvars[server].link_key.fingerprint.sha256 }}" { sslclientcertfp; };
    hub *;
    class servers;
    options { [...] };
};
{% endfor %}
```

##### EXPECTED RESULTS
The service gets configured with SSL key pinning.

##### ACTUAL RESULTS
This feature doesn't yet exist, though it can be simulated manually by calling `openssl` and parsing the output...


Discussion:
cc @Spredzy
Hey @KellerFuchs,

Just to make sure I understand what you are asking for. You'd like the module to return the left value in the below example, along the other data that are returned?

```
[root@localhost tmp]# for i in sha1 sha256 sha512 md5; do openssl dgst -$i -c toto.pem ; done
SHA1(toto.pem)= 5f:fc:88:ff:62:5e:61:7e:e0:4f:87:44:6d:24:a3:b2:d8:b7:eb:6c
SHA256(toto.pem)= 3b:ac:11:b3:ea:40:f3:ec:b3:fe:5e:c3:a2:be:e3:73:b4:33:44:4f:a0:49:09:0f:cb:27:ff:91:da:7f:02:e6
SHA512(toto.pem)= ef:52:b2:04:2c:91:9f:81:e7:97:b0:7e:2e:a0:a1:ef:dc:1e:77:cc:55:13:20:88:ad:e8:48:12:5a:c3:6d:41:02:f7:2f:1c:27:55:09:98:71:1b:5b:15:93:67:d4:f2:5e:15:de:8b:a7:4f:cb:88:f7:71:12:3d:b3:c5:ac:3c
MD5(toto.pem)= 4b:0c:9d:e3:b9:6b:38:27:12:97:ab:3f:de:55:40:b7
```
No, that's the file digest.
I will write you an example later today.
So, the TL;DR version is: extract the public key, transform it to DER representation and then hash it:

    % openssl rsa -in key.pem -pubout -outform DER | openssl dgst -sha1 -c

Let me show you an example:

1. Generate a new RSA key

        % openssl genrsa -size 4096 -out key.pem

2. Get the public key's hash

        % openssl rsa -in key.pem -pubout -outform DER | openssl dgst -sha1 -c
        writing RSA key
        (stdin)= b2:ff:0e:5f:58:85:d3:b3:00:fa:dc:9d:75:f2:6f:1c:d6:0a:f7:0f

3. Now, as a sanity check, let's generate two (self-signed) certificates and compute their pubkey hash

        % openssl req -x509 -key key.pem -out cert.pem -days 42
        [...]

        % openssl req -x509 -key key.pem -out cert2.pem -days 1337
        [...]

        % openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha1 -c
        (stdin)= b2:ff:0e:5f:58:85:d3:b3:00:fa:dc:9d:75:f2:6f:1c:d6:0a:f7:0f

        % openssl x509 -in cert2.pem -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha1 -c
        (stdin)= b2:ff:0e:5f:58:85:d3:b3:00:fa:dc:9d:75:f2:6f:1c:d6:0a:f7:0f

As you can see, different certificates with different hashes but the same public key do indeed produce the same public key hash (and we can use that hash for key pinning).

Resolving patch:
diff --git a/lib/ansible/module_utils/crypto.py b/lib/ansible/module_utils/crypto.py
new file mode 100644
--- /dev/null
+++ b/lib/ansible/module_utils/crypto.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+#
+# (c) 2016, Yanis Guenane <yanis+ansible@guenane.org>
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
+
+
+try:
+    from OpenSSL import crypto
+except ImportError:
+    # An error will be raised in the calling class to let the end
+    # user know that OpenSSL couldn't be found.
+    pass
+
+import hashlib
+
+
+def get_fingerprint(path):
+    """Generate the fingerprint of the public key. """
+
+    fingerprint = {}
+
+    privatekey = crypto.load_privatekey(crypto.FILETYPE_PEM, open(path, 'r').read())
+
+    try:
+        publickey = crypto.dump_publickey(crypto.FILETYPE_ASN1, privatekey)
+        for algo in hashlib.algorithms:
+            f = getattr(hashlib, algo)
+            pubkey_digest = f(publickey).hexdigest()
+            fingerprint[algo] = ':'.join(pubkey_digest[i:i + 2] for i in range(0, len(pubkey_digest), 2))
+    except AttributeError:
+        # If PyOpenSSL < 16.0 crypto.dump_publickey() will fail.
+        # By doing this we prevent the code from raising an error
+        # yet we return no value in the fingerprint hash.
+        pass
+
+    return fingerprint
diff --git a/lib/ansible/modules/crypto/openssl_privatekey.py b/lib/ansible/modules/crypto/openssl_privatekey.py
--- a/lib/ansible/modules/crypto/openssl_privatekey.py
+++ b/lib/ansible/modules/crypto/openssl_privatekey.py
@@ -101,9 +101,24 @@
     returned: changed or success
     type: string
     sample: /etc/ssl/private/ansible.com.pem
+fingerprint:
+    description: The fingerprint of the public key. Fingerprint will be generated for each hashlib.algorithms available.
+                 Requires PyOpenSSL >= 16.0 for meaningful output.
+    returned: changed or success
+    type: dict
+    sample:
+      md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29"
+      sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10"
+      sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46"
+      sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7"
+      sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d"
+      sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b"
 '''
 
-from ansible.module_utils.basic import *
+import errno
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.crypto import get_fingerprint
+from ansible.module_utils.pycompat24 import get_exception
 
 try:
     from OpenSSL import crypto
@@ -112,7 +127,6 @@
 else:
     pyopenssl_found = True
 
-
 import os
 
 
@@ -131,6 +145,8 @@ def __init__(self, module):
         self.path = module.params['path']
         self.mode = module.params['mode']
         self.changed = True
+        self.privatekey = None
+        self.fingerprint = {}
         self.check_mode = module.check_mode
 
 
@@ -163,6 +179,7 @@ def generate(self, module):
         else:
             self.changed = False
 
+        self.fingerprint = get_fingerprint(self.path)
         file_args = module.load_file_common_arguments(module.params)
         if module.set_fs_attributes_if_different(file_args, False):
             self.changed = True
@@ -189,6 +206,7 @@ def dump(self):
             'type': self.type,
             'filename': self.path,
             'changed': self.changed,
+            'fingerprint': self.fingerprint,
         }
 
         return result
diff --git a/lib/ansible/modules/crypto/openssl_publickey.py b/lib/ansible/modules/crypto/openssl_publickey.py
--- a/lib/ansible/modules/crypto/openssl_publickey.py
+++ b/lib/ansible/modules/crypto/openssl_publickey.py
@@ -86,9 +86,24 @@
     returned: changed or success
     type: string
     sample: /etc/ssl/public/ansible.com.pem
+fingerprint:
+    description: The fingerprint of the public key. Fingerprint will be generated for each hashlib.algorithms available.
+                 Requires PyOpenSSL >= 16.0 for meaningful output.
+    returned: changed or success
+    type: dict
+    sample:
+      md5: "84:75:71:72:8d:04:b5:6c:4d:37:6d:66:83:f5:4c:29"
+      sha1: "51:cc:7c:68:5d:eb:41:43:88:7e:1a:ae:c7:f8:24:72:ee:71:f6:10"
+      sha224: "b1:19:a6:6c:14:ac:33:1d:ed:18:50:d3:06:5c:b2:32:91:f1:f1:52:8c:cb:d5:75:e9:f5:9b:46"
+      sha256: "41:ab:c7:cb:d5:5f:30:60:46:99:ac:d4:00:70:cf:a1:76:4f:24:5d:10:24:57:5d:51:6e:09:97:df:2f:de:c7"
+      sha384: "85:39:50:4e:de:d9:19:33:40:70:ae:10:ab:59:24:19:51:c3:a2:e4:0b:1c:b1:6e:dd:b3:0c:d9:9e:6a:46:af:da:18:f8:ef:ae:2e:c0:9a:75:2c:9b:b3:0f:3a:5f:3d"
+      sha512: "fd:ed:5e:39:48:5f:9f:fe:7f:25:06:3f:79:08:cd:ee:a5:e7:b3:3d:13:82:87:1f:84:e1:f5:c7:28:77:53:94:86:56:38:69:f0:d9:35:22:01:1e:a6:60:...:0f:9b"
 '''
 
-from ansible.module_utils.basic import *
+import errno
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.crypto import get_fingerprint
+from ansible.module_utils.pycompat24 import get_exception
 
 try:
     from OpenSSL import crypto
@@ -97,7 +112,6 @@
 else:
     pyopenssl_found = True
 
-
 import os
 
 
@@ -113,19 +127,22 @@ def __init__(self, module):
         self.name = os.path.basename(module.params['path'])
         self.path = module.params['path']
         self.privatekey_path = module.params['privatekey_path']
+        self.privatekey = None
         self.changed = True
+        self.fingerprint = {}
         self.check_mode = module.check_mode
 
 
     def generate(self, module):
-        """Generate the public key.."""
+        """Generate the public key."""
 
         if not os.path.exists(self.path) or self.force:
             try:
                 privatekey_content = open(self.privatekey_path, 'r').read()
-                privatekey = crypto.load_privatekey(crypto.FILETYPE_PEM, privatekey_content)
+                self.privatekey = crypto.load_privatekey(crypto.FILETYPE_PEM, privatekey_content)
+                publickey_content = crypto.dump_publickey(crypto.FILETYPE_PEM, self.privatekey)
                 publickey_file = open(self.path, 'w')
-                publickey_file.write(crypto.dump_publickey(crypto.FILETYPE_PEM, privatekey))
+                publickey_file.write(publickey_content)
                 publickey_file.close()
             except (IOError, OSError):
                 e = get_exception()
@@ -137,6 +154,7 @@ def generate(self, module):
             self.changed = False
 
         file_args = module.load_file_common_arguments(module.params)
+        self.fingerprint = get_fingerprint(self.privatekey_path)
         if module.set_fs_attributes_if_different(file_args, False):
             self.changed = True
 
@@ -159,6 +177,7 @@ def dump(self):
             'privatekey': self.privatekey_path,
             'filename': self.path,
             'changed': self.changed,
+            'fingerprint': self.fingerprint,
         }
 
         return result


Test patch: