From 21c8650180f3ad5fd248a24a116a672805ce4dce Mon Sep 17 00:00:00 2001
From: Jakob Ackermann <das7pad@outlook.com>
Date: Mon, 1 Apr 2019 13:18:33 +0200
Subject: [PATCH] openssh_cert: add serial_number param (#54653)

* [openssh_cert] cleanup the returned certificate info

- Drop the certificate path - it is already present in rc.filename.
- Drop the leading whitespace for all lines.

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>

* [openssh_cert] add support for a certificate serial number

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>

* [openssh_cert] fix lint error

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>

* [openssh_cert] drop explicit default value

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>

* [openssh_cert] enforce the specified or missing serial number

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>

* [openssh_cert] passing no explicit serial number ignores any present one

Signed-off-by: Jakob Ackermann <das7pad@outlook.com>
---
 lib/ansible/modules/crypto/openssh_cert.py    |  32 ++++-
 .../targets/openssh_cert/tasks/main.yml       | 111 ++++++++++++++++++
 2 files changed, 138 insertions(+), 5 deletions(-)

diff --git a/lib/ansible/modules/crypto/openssh_cert.py b/lib/ansible/modules/crypto/openssh_cert.py
index 00e6d61f75c..d2aa2a68a7b 100644
--- a/lib/ansible/modules/crypto/openssh_cert.py
+++ b/lib/ansible/modules/crypto/openssh_cert.py
@@ -108,6 +108,14 @@ options:
         description:
             - Specify the key identity when signing a public key. The identifier that is logged by the server when the certificate is used for authentication.
         type: str
+    serial_number:
+        description:
+            - "Specify the certificate serial number.
+               The serial number is logged by the server when the certificate is used for authentication.
+               The certificate serial number may be used in a KeyRevocationList.
+               The serial number may be omitted for checks, but must be specified again for a new certificate.
+               Note: The default value set by ssh-keygen is 0."
+        type: int
 
 extends_documentation_fragment: files
 '''
@@ -216,6 +224,7 @@ class Certificate(object):
         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']
@@ -290,6 +299,9 @@ class Certificate(object):
             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)])
 
@@ -377,6 +389,7 @@ class Certificate(object):
             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]:
@@ -402,6 +415,11 @@ class Certificate(object):
             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
 
@@ -441,10 +459,10 @@ class Certificate(object):
 
             return False
 
-        if not perms_required:
-            return _check_type() and _check_principals() and _check_validity(module)
+        if perms_required and not _check_perms(module):
+            return False
 
-        return _check_perms(module) and _check_type() and _check_principals() and _check_validity(module)
+        return _check_type() and _check_principals() and _check_validity(module) and _check_serial_number()
 
     def dump(self):
 
@@ -456,9 +474,12 @@ class Certificate(object):
             for word in arr:
                 if word in keywords:
                     concated.append(string)
-                    string = ""
-                string += " " + word
+                    string = word
+                else:
+                    string += " " + word
             concated.append(string)
+            # drop the certificate path
+            concated.pop(0)
             return concated
 
         def format_cert_info():
@@ -512,6 +533,7 @@ def main():
             public_key=dict(type='path'),
             path=dict(type='path', required=True),
             identifier=dict(type='str'),
+            serial_number=dict(type='int'),
             valid_from=dict(type='str'),
             valid_to=dict(type='str'),
             valid_at=dict(type='str'),
diff --git a/test/integration/targets/openssh_cert/tasks/main.yml b/test/integration/targets/openssh_cert/tasks/main.yml
index feae638c471..5e4606b9c6d 100644
--- a/test/integration/targets/openssh_cert/tasks/main.yml
+++ b/test/integration/targets/openssh_cert/tasks/main.yml
@@ -239,6 +239,117 @@
           - "clear"
       valid_from: "2001-01-21"
       valid_to: "2019-01-21"
+  - name: Generate cert without serial
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_no_serial'
+      valid_from: always
+      valid_to: forever
+    register: rc_no_serial_number
+  - name: check default serial
+    assert:
+      that:
+        - "'Serial: 0' in rc_no_serial_number.info"
+      msg: OpenSSH user certificate contains the default serial number.
+  - name: Generate cert without serial (idempotent)
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_no_serial'
+      valid_from: always
+      valid_to: forever
+    register: rc_no_serial_number_idempotent
+  - name: check idempotent
+    assert:
+      that:
+        - rc_no_serial_number_idempotent is not changed
+      msg: OpenSSH certificate generation without serial number is idempotent.
+  - name: Generate cert with serial 42
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_serial_42'
+      valid_from: always
+      valid_to: forever
+      serial_number: 42
+    register: rc_serial_number
+  - name: check serial 42
+    assert:
+      that:
+        - "'Serial: 42' in rc_serial_number.info"
+      msg: OpenSSH user certificate contains the serial number from the params.
+  - name: Generate cert with serial 42 (idempotent)
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_serial_42'
+      valid_from: always
+      valid_to: forever
+      serial_number: 42
+    register: rc_serial_number_idempotent
+  - name: check idempotent
+    assert:
+      that:
+        - rc_serial_number_idempotent is not changed
+      msg: OpenSSH certificate generation with serial number is idempotent.
+  - name: Generate cert with changed serial number
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_serial_42'
+      valid_from: always
+      valid_to: forever
+      serial_number: 1337
+    register: rc_serial_number_changed
+  - name: check changed
+    assert:
+      that:
+        - rc_serial_number_changed is changed
+      msg: OpenSSH certificate regenerated upon serial number change.
+  - name: Generate cert with removed serial number
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_serial_42'
+      valid_from: always
+      valid_to: forever
+      serial_number: 0
+    register: rc_serial_number_removed
+  - name: check changed
+    assert:
+      that:
+        - rc_serial_number_removed is changed
+      msg: OpenSSH certificate regenerated upon serial number removal.
+  - name: Generate a new cert with serial number
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_serial_ignore'
+      valid_from: always
+      valid_to: forever
+      serial_number: 42
+  - name: Generate cert again, omitting the parameter serial_number (idempotent)
+    openssh_cert:
+      type: user
+      signing_key: '{{ output_dir }}/id_key'
+      public_key: '{{ output_dir }}/id_key.pub'
+      path: '{{ output_dir }}/id_cert_serial_ignore'
+      valid_from: always
+      valid_to: forever
+    register: rc_serial_number_ignored
+  - name: check idempotent
+    assert:
+      that:
+        - rc_serial_number_ignored is not changed
+      msg: OpenSSH certificate generation with omitted serial number is idempotent.
   - name: Remove certificate (check mode)
     openssh_cert:
       state: absent