Browse Source

Merge "PKI Cert generation and check updates"

Zuul 1 week ago
parent
commit
05dc91eda4

+ 77
- 1
doc/source/cli/cli.rst View File

@@ -477,6 +477,14 @@ Dashes in the document names will be converted to underscores for consistency.
477 477
 
478 478
 Name of site.
479 479
 
480
+**days** (Optional).
481
+
482
+Duration (in days) certificates should be valid.  Default=365,
483
+minimum=0, no maximum.
484
+
485
+NOTE: A generated certificate where days = 0 should only be used for testing.
486
+A certificate generated in such a way will be valid for 0 seconds.
487
+
480 488
 Examples
481 489
 """"""""
482 490
 
@@ -492,10 +500,78 @@ Examples
492 500
     secrets generate-pki \
493 501
     <site_name> \
494 502
     -o <output> \
495
-    -f <filename>
503
+    -f <filename> \
504
+    -d <days>
496 505
 
497 506
 .. _command-line-repository-overrides:
498 507
 
508
+
509
+Check PKI Certs
510
+---------------
511
+
512
+Determine if any PKI certificates from a site are expired, or will be expired
513
+within N days (default N=60, no maximum, minimum 0). Print those cert names
514
+and expiration dates to ``stdout``.
515
+
516
+**-d / --days** (Optional).
517
+
518
+Number of days past today's date to check certificate expirations.
519
+Default days=60.  Minimum days=0, days less than 0 will raise an exception.
520
+No maximum days.
521
+
522
+**site_name** (Required).
523
+
524
+Name of the ``site``. The ``site_name`` must match a ``site`` name in the site
525
+repository folder structure.
526
+
527
+Usage:
528
+
529
+::
530
+
531
+    ./pegleg.sh site -r <site_repo> \
532
+      secrets check-pki-certs <site_name> <options>
533
+
534
+Examples
535
+^^^^^^^^
536
+
537
+Example without days specified:
538
+
539
+::
540
+
541
+    ./pegleg.sh site -r <site_repo> secrets check-pki-certs <site_name>
542
+
543
+Example with days specified:
544
+
545
+::
546
+
547
+    ./pegleg.sh site -r <site_repo> secrets check-pki-certs <site_name> -d <days>
548
+
549
+Secrets
550
+-------
551
+
552
+A sub-group of site command group, which allows you to perform secrets
553
+level operations for secrets documents of a site.
554
+
555
+.. note::
556
+
557
+  For the CLI commands ``encrypt`` and ``decrypt`` in the ``secrets`` command
558
+  group, which encrypt or decrypt site secrets, two  environment variables,
559
+  ``PEGLEG_PASSPHRASE``, and ``PEGLEG_SALT``, are  used to capture the
560
+  master passphrase, and the salt needed for encryption and decryption of the
561
+  site secrets. The contents of ``PEGLEG_PASSPHRASE``, and ``PEGLEG_SALT``
562
+  are not generated by Pegleg, but are created externally, and set by a
563
+  deployment engineers or tooling.
564
+
565
+  A minimum length of 24 for master passphrases will be checked by all CLI
566
+  commands, which use the ``PEGLEG_PASSPHRASE``. All other criteria around
567
+  master passphrase strength are assumed to be enforced elsewhere.
568
+
569
+::
570
+
571
+  ./pegleg.sh site -r <site_repo> -e <extra_repo> secrets <command> <options>
572
+
573
+
574
+
499 575
 Encrypt
500 576
 ^^^^^^^
501 577
 

+ 33
- 2
pegleg/cli.py View File

@@ -401,8 +401,15 @@ def secrets():
401 401
          'for tracking provenance information in the PeglegManagedDocuments. '
402 402
          'An attempt is made to automatically determine this value, '
403 403
          'but should be provided.')
404
+@click.option(
405
+    '-d',
406
+    '--days',
407
+    'days',
408
+    default=365,
409
+    help='Duration in days generated certificates should be valid. '
410
+         'Default is 365 days.')
404 411
 @click.argument('site_name')
405
-def generate_pki(site_name, author):
412
+def generate_pki(site_name, author, days):
406 413
     """Generate certificates, certificate authorities and keypairs for a given
407 414
     site.
408 415
 
@@ -410,7 +417,8 @@ def generate_pki(site_name, author):
410 417
 
411 418
     engine.repository.process_repositories(site_name,
412 419
                                            overwrite_existing=True)
413
-    pkigenerator = catalog.pki_generator.PKIGenerator(site_name, author=author)
420
+    pkigenerator = catalog.pki_generator.PKIGenerator(
421
+        site_name, author=author, duration=days)
414 422
     output_paths = pkigenerator.generate()
415 423
 
416 424
     click.echo("Generated PKI files written to:\n%s" % '\n'.join(output_paths))
@@ -509,6 +517,29 @@ def genesis_bundle(*, build_dir, validators, site_name):
509 517
                          site_name)
510 518
 
511 519
 
520
+@secrets.command(
521
+    'check-pki-certs',
522
+    help='Determine if certificates in a sites PKICatalog are expired or '
523
+         'expiring within a specified number of days.')
524
+@click.option(
525
+    '-d',
526
+    '--days',
527
+    'days',
528
+    default=60,
529
+    help='The number of days past today to check if certificates are valid.')
530
+@click.argument('site_name')
531
+def check_pki_certs(site_name, days):
532
+    """Check PKI certificates of a site for expiration."""
533
+
534
+    engine.repository.process_repositories(site_name,
535
+                                           overwrite_existing=True)
536
+
537
+    cert_results = engine.secrets.check_cert_expiry(site_name, duration=days)
538
+
539
+    click.echo("The following certs will expire within {} days: \n{}"
540
+               .format(days, cert_results))
541
+
542
+
512 543
 @main.group(help='Commands related to types')
513 544
 @MAIN_REPOSITORY_OPTION
514 545
 @REPOSITORY_CLONE_PATH_OPTION

+ 6
- 2
pegleg/engine/catalog/pki_generator.py View File

@@ -44,9 +44,12 @@ class PKIGenerator(object):
44 44
 
45 45
     """
46 46
 
47
-    def __init__(self, sitename, block_strings=True, author=None):
47
+    def __init__(self, sitename, block_strings=True, author=None,
48
+                 duration=365):
48 49
         """Constructor for ``PKIGenerator``.
49 50
 
51
+        :param int duration: Duration in days that generated certificates
52
+            are valid.
50 53
         :param str sitename: Site name for which to retrieve documents used for
51 54
             certificate and keypair generation.
52 55
         :param bool block_strings: Whether to dump out certificate data as
@@ -60,7 +63,8 @@ class PKIGenerator(object):
60 63
         self._documents = util.definition.documents_for_site(sitename)
61 64
         self._author = author
62 65
 
63
-        self.keys = pki_utility.PKIUtility(block_strings=block_strings)
66
+        self.keys = pki_utility.PKIUtility(block_strings=block_strings,
67
+                                           duration=duration)
64 68
         self.outputs = collections.defaultdict(dict)
65 69
 
66 70
         # Maps certificates to CAs in order to derive certificate paths.

+ 25
- 11
pegleg/engine/catalog/pki_utility.py View File

@@ -12,7 +12,7 @@
12 12
 # See the License for the specific language governing permissions and
13 13
 # limitations under the License.
14 14
 
15
-from datetime import datetime
15
+import datetime
16 16
 import json
17 17
 import logging
18 18
 import os
@@ -25,11 +25,11 @@ from dateutil import parser
25 25
 import pytz
26 26
 import yaml
27 27
 
28
+from pegleg.engine import exceptions
28 29
 from pegleg.engine.util.pegleg_managed_document import \
29 30
     PeglegManagedSecretsDocument
30 31
 
31 32
 LOG = logging.getLogger(__name__)
32
-_ONE_YEAR_IN_HOURS = '8760h'  # 365 * 24
33 33
 
34 34
 __all__ = ['PKIUtility']
35 35
 
@@ -57,23 +57,27 @@ class PKIUtility(object):
57 57
         except subprocess.CalledProcessError:
58 58
             return False
59 59
 
60
-    def __init__(self, *, block_strings=True):
60
+    def __init__(self, *, block_strings=True, duration=None):
61 61
         self.block_strings = block_strings
62 62
         self._ca_config_string = None
63
+        self.duration = duration
63 64
 
64 65
     @property
65 66
     def ca_config(self):
67
+        if self.duration is not None and self.duration >= 0:
68
+            pass
69
+        else:
70
+            raise exceptions.PKICertificateInvalidDuration()
71
+
66 72
         if not self._ca_config_string:
67 73
             self._ca_config_string = json.dumps({
68 74
                 'signing': {
69 75
                     'default': {
70
-                        # TODO(felipemonteiro): Make this configurable.
71 76
                         'expiry':
72
-                            _ONE_YEAR_IN_HOURS,
77
+                            str(24 * self.duration) + 'h',
73 78
                         'usages': [
74 79
                             'signing', 'key encipherment', 'server auth',
75
-                            'client auth'
76
-                        ],
80
+                            'client auth'],
77 81
                     },
78 82
                 },
79 83
             })
@@ -198,17 +202,27 @@ class PKIUtility(object):
198 202
         """Chek whether a given certificate is expired.
199 203
 
200 204
         :param str cert: Client certificate that contains the public key.
201
-        :returns: True if certificate is expired, else False.
202
-        :rtype: bool
205
+        :returns: In dictionary format returns the expiration date of the cert
206
+            and True if the cert is or will be expired within the next
207
+            expire_in_days
208
+        :rtype: dict
203 209
 
204 210
         """
205 211
 
212
+        if self.duration is not None and self.duration >= 0:
213
+            pass
214
+        else:
215
+            raise exceptions.PKICertificateInvalidDuration()
216
+
206 217
         info = self.cert_info(cert)
207 218
         expiry_str = info['not_after']
208 219
         expiry = parser.parse(expiry_str)
209 220
         # expiry is timezone-aware; do the same for `now`.
210
-        now = pytz.utc.localize(datetime.utcnow())
211
-        return now > expiry
221
+        expiry_window = pytz.utc.localize(datetime.datetime.utcnow()) + \
222
+            datetime.timedelta(days=self.duration)
223
+        expired = expiry_window > expiry
224
+        expiry = expiry.strftime('%d-%b-%Y %H:%M:%S %Z')
225
+        return {'expiry_date': expiry, 'expired': expired}
212 226
 
213 227
     def _cfssl(self, command, *, files=None):
214 228
         """Executes ``cfssl`` command via ``subprocess`` call."""

+ 7
- 0
pegleg/engine/exceptions.py View File

@@ -99,10 +99,17 @@ class GenesisBundleGenerateException(PeglegBaseException):
99 99
     message = 'Bundle generation failed on deckhand validation.'
100 100
 
101 101
 
102
+class PKICertificateInvalidDuration(PeglegBaseException):
103
+    """Exception for invalid duration of PKI Certificate."""
104
+    message = ('Provided duration is invalid. Certificate durations must be '
105
+               'a positive integer.')
106
+
107
+
102 108
 #
103 109
 # CREDENTIALS EXCEPTIONS
104 110
 #
105 111
 
112
+
106 113
 class PassphraseNotFoundException(PeglegBaseException):
107 114
     """Exception raised when passphrase is not set."""
108 115
 

+ 38
- 0
pegleg/engine/secrets.py View File

@@ -16,6 +16,9 @@ import logging
16 16
 import os
17 17
 import yaml
18 18
 
19
+from prettytable import PrettyTable
20
+
21
+from pegleg.engine.catalog.pki_utility import PKIUtility
19 22
 from pegleg.engine.generators.passphrase_generator import PassphraseGenerator
20 23
 from pegleg.engine.util.cryptostring import CryptoString
21 24
 from pegleg.engine.util import definition
@@ -186,3 +189,38 @@ def wrap_secret(author, file_name, output_path, schema,
186 189
         output_doc = managed_secret.pegleg_document
187 190
     with open(output_path, "w") as output_fi:
188 191
         yaml.safe_dump(output_doc, output_fi)
192
+
193
+
194
+def check_cert_expiry(site_name, duration=60):
195
+    """
196
+    Check certs from a sites PKICatalog to determine if they are expired or
197
+    expiring within N days
198
+
199
+    :param str site_name: The site to read from
200
+    :param int duration: Number of days from today to check cert
201
+        expirations
202
+    :rtype: str
203
+    """
204
+
205
+    pki_util = PKIUtility(duration=duration)
206
+    # Create a table to output expired/expiring certs for this site.
207
+    cert_table = PrettyTable()
208
+    cert_table.field_names = ['cert_name', 'expiration_date']
209
+
210
+    s = definition.site_files(site_name)
211
+    for doc in s:
212
+        if 'certificate' in doc:
213
+            with open(doc, 'r') as f:
214
+                results = yaml.safe_load_all(f)  # Validate valid YAML.
215
+                results = PeglegSecretManagement(
216
+                    docs=results).get_decrypted_secrets()
217
+                for result in results:
218
+                    if result['schema'] == \
219
+                            "deckhand/Certificate/v1":
220
+                        cert = result['data']
221
+                        cert_info = pki_util.check_expiry(cert)
222
+                        if cert_info['expired'] is True:
223
+                            cert_table.add_row([doc, cert_info['expiry_date']])
224
+
225
+    # Return table of cert names and expiration dates that are expiring
226
+    return cert_table.get_string()

+ 5
- 5
tests/unit/engine/catalog/test_pki_utility.py View File

@@ -91,7 +91,7 @@ class TestPKIUtility(object):
91 91
         assert PRIVATE_KEY_HEADER in priv_key['data']
92 92
 
93 93
     def test_generate_certificate(self):
94
-        pki_obj = pki_utility.PKIUtility()
94
+        pki_obj = pki_utility.PKIUtility(duration=365)
95 95
         ca_cert_wrapper, ca_key_wrapper = pki_obj.generate_ca(
96 96
             self.__class__.__name__)
97 97
         ca_cert = ca_cert_wrapper['data']['managedDocument']
@@ -121,7 +121,7 @@ class TestPKIUtility(object):
121 121
 
122 122
     def test_check_expiry_is_expired_false(self):
123 123
         """Check that ``check_expiry`` returns False if cert isn't expired."""
124
-        pki_obj = pki_utility.PKIUtility()
124
+        pki_obj = pki_utility.PKIUtility(duration=0)
125 125
 
126 126
         ca_config = json.loads(pki_obj.ca_config)
127 127
         ca_config['signing']['default']['expiry'] = '1h'
@@ -141,7 +141,7 @@ class TestPKIUtility(object):
141 141
         cert = cert_wrapper['data']['managedDocument']
142 142
 
143 143
         # Validate that the cert hasn't expired.
144
-        is_expired = pki_obj.check_expiry(cert=cert['data'])
144
+        is_expired = pki_obj.check_expiry(cert=cert['data'])['expired']
145 145
         assert not is_expired
146 146
 
147 147
     def test_check_expiry_is_expired_true(self):
@@ -149,7 +149,7 @@ class TestPKIUtility(object):
149 149
 
150 150
         Second values are used to demonstrate precision down to the second.
151 151
         """
152
-        pki_obj = pki_utility.PKIUtility()
152
+        pki_obj = pki_utility.PKIUtility(duration=0)
153 153
 
154 154
         ca_config = json.loads(pki_obj.ca_config)
155 155
         ca_config['signing']['default']['expiry'] = '1s'
@@ -171,5 +171,5 @@ class TestPKIUtility(object):
171 171
         time.sleep(2)
172 172
 
173 173
         # Validate that the cert has expired.
174
-        is_expired = pki_obj.check_expiry(cert=cert['data'])
174
+        is_expired = pki_obj.check_expiry(cert=cert['data'])['expired']
175 175
         assert is_expired

+ 7
- 5
tests/unit/engine/test_secrets.py View File

@@ -237,7 +237,7 @@ def test_generate_pki_using_local_repo_path(create_tmp_deployment_files):
237 237
     repo_path = str(git.git_handler(TEST_PARAMS["repo_url"],
238 238
                                     ref=TEST_PARAMS["repo_rev"]))
239 239
     with mock.patch.dict(config.GLOBAL_CONTEXT, {"site_repo": repo_path}):
240
-        pki_generator = PKIGenerator(sitename=TEST_PARAMS["site_name"])
240
+        pki_generator = PKIGenerator(duration=365, sitename=TEST_PARAMS["site_name"])
241 241
         generated_files = pki_generator.generate()
242 242
 
243 243
         assert len(generated_files), 'No secrets were generated'
@@ -259,10 +259,10 @@ def test_check_expiry(create_tmp_deployment_files):
259 259
     repo_path = str(git.git_handler(TEST_PARAMS["repo_url"],
260 260
                                     ref=TEST_PARAMS["repo_rev"]))
261 261
     with mock.patch.dict(config.GLOBAL_CONTEXT, {"site_repo": repo_path}):
262
-        pki_generator = PKIGenerator(sitename=TEST_PARAMS["site_name"])
262
+        pki_generator = PKIGenerator(duration=365, sitename=TEST_PARAMS["site_name"])
263 263
         generated_files = pki_generator.generate()
264 264
 
265
-        pki_util = pki_utility.PKIUtility()
265
+        pki_util = pki_utility.PKIUtility(duration=0)
266 266
 
267 267
         assert len(generated_files), 'No secrets were generated'
268 268
         for generated_file in generated_files:
@@ -276,5 +276,7 @@ def test_check_expiry(create_tmp_deployment_files):
276 276
                     if result['schema'] == \
277 277
                             "deckhand/Certificate/v1":
278 278
                         cert = result['data']
279
-                        assert not pki_util.check_expiry(cert), \
280
-                            "%s is expired!" % generated_file.name
279
+                        cert_info = pki_util.check_expiry(cert)
280
+                        assert cert_info['expired'] is False, \
281
+                            "%s is expired/expiring on %s" % \
282
+                            (generated_file.name, cert_info['expiry_date'])

+ 9
- 1
tests/unit/test_cli.py View File

@@ -562,6 +562,15 @@ class TestSiteSecretsActions(BaseCLIActionTest):
562 562
         result = self.runner.invoke(cli.site, ['-r', repo_path] + secrets_opts)
563 563
         assert result.exit_code == 0, result.output
564 564
 
565
+    @pytest.mark.skipif(
566
+        not pki_utility.PKIUtility.cfssl_exists(),
567
+        reason='cfssl must be installed to execute these tests')
568
+    def test_check_pki_certs(self):
569
+        repo_path = self.treasuremap_path
570
+        secrets_opts = ['secrets', 'check-pki-certs', self.site_name]
571
+        result = self.runner.invoke(cli.site, ['-r', repo_path] + secrets_opts)
572
+        assert result.exit_code == 0, result.output
573
+
565 574
     @mock.patch.dict(os.environ, {
566 575
         "PEGLEG_PASSPHRASE": "123456789012345678901234567890",
567 576
         "PEGLEG_SALT": "123456"
@@ -608,7 +617,6 @@ class TestSiteSecretsActions(BaseCLIActionTest):
608 617
             assert "encrypted" in doc["data"]
609 618
             assert "managedDocument" in doc["data"]
610 619
 
611
-
612 620
 class TestTypeCliActions(BaseCLIActionTest):
613 621
     """Tests type-level CLI actions."""
614 622
 

Loading…
Cancel
Save