Browse Source

Pegleg encryption of site secrets

Added secret encryption/decryption to pegleg cli.

Change-Id: I95b993748d99fc4398eee1d1c59e74f382497f74
Ahmad Mahmoudi 6 months ago
parent
commit
eb0deeb9e5

+ 99
- 0
doc/source/cli/cli.rst View File

@@ -391,6 +391,105 @@ A more complex example involves excluding certain linting checks:
391 391
 
392 392
 .. _command-line-repository-overrides:
393 393
 
394
+Secrets
395
+-------
396
+
397
+A sub-group of site command group, which allows you to perform secrets
398
+level operations for secrets documents of a site.
399
+
400
+::
401
+
402
+  ./pegleg.sh site -r <site_repo> -e <extra_repo> secrets <command> <options>
403
+
404
+
405
+Encrypt
406
+^^^^^^^
407
+
408
+Encrypt one site's secrets documents, which have the
409
+metadata.storagePolicy set to encrypted, and wrap them in `pegleg managed
410
+documents <https://airship-specs.readthedocs.io/en/latest/specs/approved/pegleg-secrets.html#peglegmanageddocument>`_.
411
+
412
+**Note**: The encrypt command is idempotent. If the command is executed more
413
+than once for a given site, it will skip the files, which are already
414
+encrypted and wrapped in a pegleg managed document, and will only encrypt the
415
+documents not encrypted before.
416
+
417
+**site_name** (Required).
418
+
419
+Name of the site.
420
+
421
+**-a / --author** (Required)
422
+
423
+Identifier for the program or person who is encrypting the secrets documents.
424
+
425
+**-s / --save-location** (Optional).
426
+
427
+Where to output encrypted and wrapped documents. If omitted, the results
428
+will overwrite the original documents.
429
+
430
+Usage:
431
+
432
+::
433
+
434
+    ./pegleg.sh site <options> secrets encrypt <site_name> -a <author_id> -s <save_location>
435
+
436
+Examples
437
+""""""""
438
+
439
+Example with optional save location:
440
+
441
+::
442
+
443
+    ./pegleg.sh site -r /opt/site-manifests \
444
+      -e global=/opt/manifests \
445
+      -e secrets=/opt/security-manifests \
446
+      secrets encrypt <site_name> -a <author_id> -s /workspace
447
+
448
+Example without optional save location:
449
+
450
+::
451
+
452
+    ./pegleg.sh site -r /opt/site-manifests \
453
+      -e global=/opt/manifests \
454
+      -e secrets=/opt/security-manifests \
455
+      secrets encrypt <site_name> -a <author_id>
456
+
457
+Decrypt
458
+^^^^^^^
459
+
460
+Unwrap an encrypted secrets document from a `pegleg managed
461
+document <https://airship-specs.readthedocs.io/en/latest/specs/approved/pegleg-secrets.html#peglegmanageddocument>`_,
462
+decrypt the encrypted secrets, and dump the cleartext secrets file to
463
+``stdout``.
464
+
465
+**site_name** (Required).
466
+
467
+Name of the site.
468
+
469
+**-f / filename** (Required).
470
+
471
+The absolute path to the pegleg managed encrypted secrets file.
472
+
473
+Usage:
474
+
475
+::
476
+
477
+    ./pegleg.sh site <options> secrets decrypt <site_name> -f <file_path>
478
+
479
+Examples
480
+""""""""
481
+
482
+Example:
483
+
484
+::
485
+
486
+    ./pegleg.sh site -r /opt/site-manifests \
487
+      -e global=/opt/manifests \
488
+      -e secrets=/opt/security-manifests \
489
+      secrets decrypt site1 -f \
490
+      /opt/security-manifests/site/site1/passwords/password1.yaml
491
+
492
+
394 493
 CLI Repository Overrides
395 494
 ------------------------
396 495
 

BIN
doc/source/images/architecture-pegleg.png View File


+ 47
- 0
pegleg/cli.py View File

@@ -358,3 +358,50 @@ def list_types(*, output_stream):
358 358
     """List type names for a given repository."""
359 359
     engine.repository.process_site_repository(update_config=True)
360 360
     engine.type.list_types(output_stream)
361
+
362
+
363
+@site.group(name='secrets', help='Commands to manage site secrets documents')
364
+def secrets():
365
+    pass
366
+
367
+
368
+@secrets.command(
369
+    'encrypt',
370
+    help='Command to encrypt and wrap site secrets '
371
+    'documents with metadata.storagePolicy set '
372
+    'to encrypted, in pegleg managed documents.')
373
+@click.option(
374
+    '-s',
375
+    '--save-location',
376
+    'save_location',
377
+    default=None,
378
+    help='Directory to output the encrypted site secrets files. Created '
379
+    'automatically if it does not already exist. '
380
+    'If save_location is not provided, the output encrypted files will '
381
+    'overwrite the original input files (default behavior)')
382
+@click.option(
383
+    '-a',
384
+    '--author',
385
+    'author',
386
+    required=True,
387
+    help='Identifier for the program or person who is encrypting the secrets '
388
+         'documents')
389
+@click.argument('site_name')
390
+def encrypt(*, save_location, author, site_name):
391
+    engine.repository.process_repositories(site_name)
392
+    engine.secrets.encrypt(save_location, author, site_name)
393
+
394
+
395
+@secrets.command(
396
+    'decrypt',
397
+    help='Command to unwrap and decrypt one site '
398
+    'secrets document and print it to stdout.')
399
+@click.option(
400
+    '-f',
401
+    '--filename',
402
+    'file_name',
403
+    help='The file name to decrypt and print out to stdout')
404
+@click.argument('site_name')
405
+def decrypt(*, file_name, site_name):
406
+    engine.repository.process_repositories(site_name)
407
+    engine.secrets.decrypt(file_name, site_name)

+ 1
- 0
pegleg/engine/__init__.py View File

@@ -19,6 +19,7 @@ from pegleg.engine import lint
19 19
 from pegleg.engine import repository
20 20
 from pegleg.engine import site
21 21
 from pegleg.engine import type
22
+from pegleg.engine import secrets
22 23
 
23 24
 
24 25
 def __represent_multiline_yaml_str():

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

@@ -0,0 +1,113 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import logging
16
+import os
17
+
18
+from pegleg.engine.util.pegleg_secret_management import PeglegSecretManagement
19
+from pegleg.engine.util import files
20
+from pegleg.engine.util import definition
21
+
22
+__all__ = ('encrypt', 'decrypt')
23
+
24
+LOG = logging.getLogger(__name__)
25
+
26
+
27
+def encrypt(save_location, author, site_name):
28
+    """
29
+    Encrypt all secrets documents for a site identifies by site_name.
30
+
31
+    Parse through all documents related to site_name and encrypt all
32
+    site documents which have metadata.storagePolicy: encrypted, and which are
33
+    not already encrypted and wrapped in a PeglegManagedDocument.
34
+    Passphrase and salt for the encryption are read from environment
35
+    variables ($PEGLEG_PASSPHRASE and $PEGLEG_SALT respectively).
36
+    By default, the resulting output files will overwrite the original
37
+    unencrypted secrets documents.
38
+    :param save_location: if provided, identifies the base directory to store
39
+    the encrypted secrets files. If not provided the encrypted secrets files
40
+    will overwrite the original unencrypted files (default behavior).
41
+    :type save_location: string
42
+    :param author: The identifier provided by the application or
43
+    the person who requests encrypt the site secrets documents.
44
+    :type author: string
45
+    :param site_name: The name of the site to encrypt its secrets files.
46
+    :type site_name: string
47
+    """
48
+
49
+    files.check_file_save_location(save_location)
50
+    LOG.info('Started encrypting...')
51
+    secrets_found = False
52
+    for repo_base, file_path in definition.site_files_by_repo(site_name):
53
+        secrets_found = True
54
+        PeglegSecretManagement(file_path).encrypt_secrets(
55
+            _get_dest_path(repo_base, file_path, save_location), author)
56
+    if secrets_found:
57
+        LOG.info('Encryption of all secret files was completed.')
58
+    else:
59
+        LOG.warn(
60
+            'No secret documents were found for site: {}'.format(site_name))
61
+
62
+
63
+def decrypt(file_path, site_name):
64
+    """
65
+    Decrypt one secrets file and print the decrypted data to standard out.
66
+
67
+    Search in in secrets file of a site, identified by site_name, for a file
68
+    named file_name.
69
+    If the  file is found and encrypted, unwrap and decrypt it and print the
70
+    result to standard out.
71
+    If the file is found, but it is not encrypted, print the contents of the
72
+    file to standard out.
73
+    Passphrase and salt for the decryption are read from environment variables.
74
+    :param file_path: Path to the file to be unwrapped and decrypted.
75
+    :type file_path: string
76
+    :param site_name: The name of the site to search for the file.
77
+    :type site_name: string providing the site name
78
+    """
79
+
80
+    LOG.info('Started decrypting...')
81
+    if os.path.isfile(file_path) \
82
+        and [s for s in file_path.split(os.path.sep) if s == site_name]:
83
+        PeglegSecretManagement(file_path).decrypt_secrets()
84
+    else:
85
+        LOG.info('File: {} was not found. Check your file path and name, '
86
+                 'and try again.'.format(file_path))
87
+
88
+
89
+def _get_dest_path(repo_base, file_path, save_location):
90
+    """
91
+    Calculate and return the destination base directory path for the
92
+    encrypted or decrypted secrets files.
93
+
94
+    :param repo_base: Base repo of the source secrets file.
95
+    :type repo_base: string
96
+    :param file_path: File path to the source secrets file.
97
+    :type file_path: string
98
+    :param save_location: Base location of destination secrets file
99
+    :type save_location: string
100
+    :return: The file path of the destination secrets file.
101
+    :rtype: string
102
+    """
103
+
104
+    if save_location \
105
+        and save_location != os.path.sep \
106
+        and save_location.endswith(os.path.sep):
107
+        save_location = save_location.rstrip(os.path.sep)
108
+    if repo_base and repo_base.endswith(os.path.sep):
109
+        repo_base = repo_base.rstrip(os.path.sep)
110
+    if save_location:
111
+        return file_path.replace(repo_base, save_location)
112
+    else:
113
+        return file_path

+ 3
- 8
pegleg/engine/site.py View File

@@ -21,6 +21,7 @@ import yaml
21 21
 from prettytable import PrettyTable
22 22
 
23 23
 from pegleg.engine import util
24
+from pegleg.engine.util import files
24 25
 
25 26
 __all__ = ('collect', 'list_', 'show', 'render')
26 27
 
@@ -55,14 +56,8 @@ def _collect_to_file(site_name, save_location):
55 56
     """Collects all documents related to ``site_name`` and outputs them to
56 57
     the file denoted by ``save_location``.
57 58
     """
58
-    if not os.path.exists(save_location):
59
-        LOG.debug("Collection save location %s does not exist. Creating "
60
-                  "automatically.", save_location)
61
-        os.makedirs(save_location)
62
-    # In case save_location already exists and isn't a directory.
63
-    if not os.path.isdir(save_location):
64
-        raise click.ClickException('save_location %s already exists, but must '
65
-                                   'be a directory' % save_location)
59
+
60
+    files.check_file_save_location(save_location)
66 61
 
67 62
     save_files = dict()
68 63
     try:

+ 129
- 0
pegleg/engine/util/encryption.py View File

@@ -0,0 +1,129 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import logging
16
+import base64
17
+from cryptography.fernet import Fernet
18
+from cryptography.hazmat.backends import default_backend
19
+from cryptography.hazmat.primitives import hashes
20
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
21
+from cryptography.exceptions import InvalidSignature
22
+
23
+KEY_LENGTH = 32
24
+ITERATIONS = 10000
25
+LOG = logging.getLogger(__name__)
26
+
27
+
28
+def encrypt(unencrypted_data,
29
+            passphrase,
30
+            salt,
31
+            key_length=KEY_LENGTH,
32
+            iterations=ITERATIONS):
33
+    """
34
+    Encrypt the data, using the provided passphrase and salt,
35
+    and return the encrypted data.
36
+
37
+    :param unencrypted_data: Secret data to encrypt
38
+    :type unencrypted_data: bytes
39
+    :param passphrase: Passphrase to use to generate encryption key. Must be
40
+    at least 24-byte long
41
+    :type passphrase: bytes
42
+    :param salt: salt to use to generate encryption key. Must be randomly
43
+    generated.
44
+    :type salt: bytes
45
+    :param key_length: Length of the encryption key to generate, in bytes.
46
+    Will default to 32, if not provided.
47
+    :type key_length: positive integer.
48
+    :param iterations: A large number, used as seed to increase the entropy
49
+    in randomness of the generated key for encryption, and hence greatly
50
+    increase the security of encrypted data. will default to 10000, if not
51
+    provided.
52
+    :type iterations: positive integer.
53
+    :return: Encrypted secret data
54
+    :rtype: bytes
55
+    """
56
+
57
+    return Fernet(_generate_key(passphrase, salt, key_length,
58
+                                iterations)).encrypt(unencrypted_data)
59
+
60
+
61
+def decrypt(encrypted_data,
62
+            passphrase,
63
+            salt,
64
+            key_length=KEY_LENGTH,
65
+            iterations=ITERATIONS):
66
+    """
67
+    Decrypt the data, using the provided passphrase and salt,
68
+    and return the decrypted data.
69
+
70
+    :param encrypted_data: Encrypted secret data
71
+    :type encrypted_data: bytes
72
+    :param passphrase: Passphrase to use to generate decryption key. Must be
73
+    at least 32-byte long.
74
+    :type passphrase: bytes
75
+    :param salt: salt to use to generate decryption key. Must be randomly
76
+    generated.
77
+    :type salt: bytes
78
+    :param key_length: Length of the decryption key to generate, in bytes.
79
+    will default to 32, if not provided.
80
+    :type key_length: positive integer.
81
+    :param iterations: A large number, used as seed to increase entropy in
82
+    the randomness of the generated key for decryption, and hence greatly
83
+    increase the security of encrypted data. Will default to 10000, if not
84
+    provided.
85
+    :type iterations: positive integer.
86
+    :return: Decrypted secret data
87
+    :rtype: bytes
88
+    :raises InvalidSignature: If the provided passphrase, and/or
89
+    salt does not match the values used to encrypt the data.
90
+    """
91
+
92
+    try:
93
+        return Fernet(_generate_key(passphrase, salt, key_length,
94
+                                    iterations)).decrypt(encrypted_data)
95
+    except InvalidSignature:
96
+        LOG.error('Signature verification to decrypt secrets failed. Please '
97
+                  'check your provided passphrase and salt and try again.')
98
+        raise
99
+
100
+
101
+def _generate_key(passphrase, salt, key_length, iterations):
102
+    """
103
+    Use the passphrase and salt and PBKDF2HMAC key derivation algorithm,
104
+    to generate and and return a Fernet key to be used for encryption and
105
+    decryption of secret data.
106
+
107
+    :param passphrase: Passphrase to use to generate decryption key. Must be
108
+    at least 24-byte long.
109
+    :type passphrase: bytes
110
+    :param salt: salt to use to generate decryption key. Must be randomly
111
+    generated.
112
+    :type salt: bytes
113
+    :param key_length: Length of the decryption key to generate, in bytes.
114
+    Will default to 32, if not provided.
115
+    :type key_length: positive integer.
116
+    :param iterations: A large number, used as seed to increase the entropy
117
+    of the randomness of the generated key. will default to 10000, if not
118
+    provided.
119
+    :type iterations: positive integer.
120
+    :return: base64 encoded, URL safe Fernet key for encryption or decryption
121
+    """
122
+
123
+    kdf = PBKDF2HMAC(
124
+        algorithm=hashes.SHA256(),
125
+        length=key_length,
126
+        salt=salt,
127
+        iterations=iterations,
128
+        backend=default_backend())
129
+    return base64.urlsafe_b64encode(kdf.derive(passphrase))

+ 67
- 0
pegleg/engine/util/files.py View File

@@ -29,9 +29,12 @@ __all__ = [
29 29
     'directories_for',
30 30
     'directory_for',
31 31
     'dump',
32
+    'read',
33
+    'write',
32 34
     'existing_directories',
33 35
     'search',
34 36
     'slurp',
37
+    'check_file_save_location',
35 38
 ]
36 39
 
37 40
 DIR_DEPTHS = {
@@ -234,6 +237,48 @@ def dump(path, data):
234 237
         yaml.dump(data, f, explicit_start=True)
235 238
 
236 239
 
240
+def read(path):
241
+    """
242
+    Read the yaml file ``path`` and return its contents as a list of
243
+    dicts
244
+    """
245
+
246
+    if not os.path.exists(path):
247
+        raise click.ClickException(
248
+            '{} not found. Pegleg must be run from the root of a '
249
+            'configuration repository.'.format(path))
250
+
251
+    with open(path) as stream:
252
+        try:
253
+            return list(yaml.safe_load_all(stream))
254
+        except yaml.YAMLError as e:
255
+            raise click.ClickException('Failed to parse %s:\n%s' % (path, e))
256
+
257
+
258
+def write(file_path, data):
259
+    """
260
+    Write the data to destination file_path.
261
+
262
+    If the directory structure of the file_path should not exist, create it.
263
+    If the file should exit, overwrite it with new data,
264
+
265
+    :param file_path: Destination file for the written data file
266
+    :type file_path: str
267
+    :param data: data to be written to the destination file
268
+    :type data: dict or a list of dicts
269
+    """
270
+
271
+    os.makedirs(os.path.dirname(file_path), exist_ok=True)
272
+
273
+    with open(file_path, 'w') as stream:
274
+        yaml.safe_dump_all(
275
+            data,
276
+            stream,
277
+            explicit_start=True,
278
+            explicit_end=True,
279
+            default_flow_style=False)
280
+
281
+
237 282
 def _recurse_subdirs(search_path, depth):
238 283
     directories = set()
239 284
     try:
@@ -257,3 +302,25 @@ def search(search_paths):
257 302
             for filename in filenames:
258 303
                 if filename.endswith(".yaml"):
259 304
                     yield os.path.join(root, filename)
305
+
306
+
307
+def check_file_save_location(save_location):
308
+    """
309
+    Verify exists and is a valid directory. If it does not exist create it.
310
+
311
+    :param save_location: Base directory to save the result of the
312
+    encryption or decryption of site secrets.
313
+    :type save_location: string, directory path
314
+    :raises click.ClickException: If pre-flight check should fail.
315
+    """
316
+
317
+    if save_location:
318
+        if not os.path.exists(save_location):
319
+            LOG.debug("Save location %s does not exist. Creating "
320
+                      "automatically.", save_location)
321
+            os.makedirs(save_location)
322
+        # In case save_location already exists and isn't a directory.
323
+        if not os.path.isdir(save_location):
324
+            raise click.ClickException(
325
+                'save_location %s already exists, '
326
+                'but is not a directory'.format(save_location))

+ 141
- 0
pegleg/engine/util/pegleg_managed_document.py View File

@@ -0,0 +1,141 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import logging
16
+from datetime import datetime
17
+
18
+PEGLEG_MANAGED_SCHEMA = 'pegleg/PeglegManagedDocument/v1'
19
+ENCRYPTED = 'encrypted'
20
+STORAGE_POLICY = 'storagePolicy'
21
+METADATA = 'metadata'
22
+LOG = logging.getLogger(__name__)
23
+
24
+
25
+class PeglegManagedSecretsDocument():
26
+    """Object representing one Pegleg managed secret document."""
27
+
28
+    def __init__(self, secrets_document):
29
+        """
30
+        Parse and wrap an externally generated document in a
31
+        pegleg managed document.
32
+
33
+        :param secrets_document: The content of the source document
34
+        :type secrets_document: dict
35
+
36
+        """
37
+
38
+        if self.is_pegleg_managed_secret(secrets_document):
39
+            self._pegleg_document = secrets_document
40
+        else:
41
+            self._pegleg_document =\
42
+                self.__wrap(secrets_document)
43
+        self._embedded_document = \
44
+            self._pegleg_document['data']['managedDocument']
45
+
46
+    @staticmethod
47
+    def __wrap(secrets_document):
48
+        """
49
+        Embeds a valid deckhand document in a pegleg managed document.
50
+
51
+        :param secrets_document: secrets document to be embedded in a
52
+        pegleg managed document.
53
+        :type secrets_document: dict
54
+        :return: pegleg manged document with the wrapped original secrets
55
+        document.
56
+        :rtype: dict
57
+        """
58
+
59
+        return {
60
+            'schema': PEGLEG_MANAGED_SCHEMA,
61
+            'metadata': {
62
+                'name': secrets_document['metadata']['name'],
63
+                'schema': 'deckhand/Document/v1',
64
+                'labels': secrets_document['metadata'].get('labels', {}),
65
+                'layeringDefinition': {
66
+                    'abstract': False,
67
+                    # The current requirement only requires site layer.
68
+                    'layer': 'site',
69
+                },
70
+                'storagePolicy': 'cleartext'
71
+            },
72
+            'data': {
73
+                'managedDocument': {
74
+                    'schema': secrets_document['schema'],
75
+                    'metadata': secrets_document['metadata'],
76
+                    'data': secrets_document['data']
77
+                }
78
+            }
79
+        }
80
+
81
+    @staticmethod
82
+    def is_pegleg_managed_secret(secrets_document):
83
+        """"
84
+        Verify if the document is already a pegleg managed secrets document.
85
+
86
+        :return: True if the document is a pegleg managed secrets document,
87
+        False otherwise.
88
+        :rtype: bool
89
+        """
90
+        return PEGLEG_MANAGED_SCHEMA in secrets_document.get('schema')
91
+
92
+    @property
93
+    def embedded_document(self):
94
+        """
95
+        parse the pegleg managed document, and return the embedded document
96
+
97
+        :return: The original secrets document unwrapped from the pegleg
98
+        managed document.
99
+        :rtype: dict
100
+        """
101
+        return self._embedded_document
102
+
103
+    @property
104
+    def name(self):
105
+        return self._pegleg_document.get('metadata', {}).get('name')
106
+
107
+    @property
108
+    def data(self):
109
+        return self._pegleg_document.get('data')
110
+
111
+    @property
112
+    def pegleg_document(self):
113
+        return self._pegleg_document
114
+
115
+    def is_encrypted(self):
116
+        """If the document is already encrypted return True. False
117
+        otherwise."""
118
+        return ENCRYPTED in self.data
119
+
120
+    def is_storage_policy_encrypted(self):
121
+        """If the document's storagePolicy is set to encrypted return True.
122
+        False otherwise."""
123
+        return STORAGE_POLICY in self._embedded_document[METADATA] \
124
+            and ENCRYPTED in self._embedded_document[METADATA][STORAGE_POLICY]
125
+
126
+    def set_encrypted(self, author):
127
+        """Mark the pegleg managed document as encrypted."""
128
+        self.data[ENCRYPTED] = {
129
+            'at': datetime.utcnow().isoformat(),
130
+            'by': author,
131
+        }
132
+
133
+    def set_decrypted(self):
134
+        """Mark the pegleg managed document as un-encrypted."""
135
+        self.data.pop(ENCRYPTED)
136
+
137
+    def set_secret(self, secret):
138
+        self._embedded_document['data'] = secret
139
+
140
+    def get_secret(self):
141
+        return self._embedded_document.get('data')

+ 137
- 0
pegleg/engine/util/pegleg_secret_management.py View File

@@ -0,0 +1,137 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import logging
16
+import os
17
+import yaml
18
+import sys
19
+import re
20
+import click
21
+
22
+from pegleg.engine.util.encryption import encrypt
23
+from pegleg.engine.util.encryption import decrypt
24
+from pegleg.engine.util.pegleg_managed_document import \
25
+    PeglegManagedSecretsDocument as PeglegManagedSecret
26
+from pegleg.engine.util import files
27
+
28
+LOG = logging.getLogger(__name__)
29
+PASSPHRASE_PATTERN = '^.{24,}$'
30
+ENV_PASSPHRASE = 'PEGLEG_PASSPHRASE'
31
+ENV_SALT = 'PEGLEG_SALT'
32
+
33
+
34
+class PeglegSecretManagement():
35
+    """An object to handle operations on of a pegleg managed file."""
36
+
37
+    def __init__(self, file_path):
38
+        """
39
+        Read the source file and the environment data needed to wrap and
40
+        process the file documents as pegleg managed document.
41
+        """
42
+
43
+        self.__check_environment()
44
+        self.file_path = file_path
45
+        self.documents = list()
46
+        for doc in files.read(file_path):
47
+            self.documents.append(PeglegManagedSecret(doc))
48
+
49
+        self.passphrase = os.environ.get(ENV_PASSPHRASE).encode()
50
+        self.salt = os.environ.get(ENV_SALT).encode()
51
+
52
+    @staticmethod
53
+    def __check_environment():
54
+        """
55
+        Validate required environment variables for encryption or decryption.
56
+
57
+        :return None
58
+        :raises click.ClickException: If environment validation should fail.
59
+        """
60
+
61
+        # Verify that passphrase environment variable is defined and is longer
62
+        # than 24 characters.
63
+        if not os.environ.get(ENV_PASSPHRASE) or not re.match(
64
+            PASSPHRASE_PATTERN, os.environ.get(ENV_PASSPHRASE)):
65
+            raise click.ClickException(
66
+                'Environment variable {} is not defined or '
67
+                'is not at least 24-character long.'.format(ENV_PASSPHRASE))
68
+
69
+        if not os.environ.get(ENV_SALT):
70
+            raise click.ClickException(
71
+                'Environment variable {} is not defined or '
72
+                'is an empty string.'.format(ENV_SALT))
73
+
74
+    def encrypt_secrets(self, save_path, author):
75
+        """
76
+        Wrap and encrypt the secrets documents included in the input file,
77
+        into pegleg manage secrets documents, and write the result in
78
+        save_path.
79
+
80
+        if save_path is the same as the source file_path the encrypted file
81
+        will overwrite the source file.
82
+
83
+        :param save_path: Destination path of the encrypted file
84
+        :type save_path: string
85
+        :param author: Identifier for the program or person who is
86
+        encrypting the secrets documents
87
+        :type author: string
88
+        """
89
+
90
+        encrypted_docs = False
91
+        doc_list = []
92
+        for doc in self.documents:
93
+            # do not re-encrypt already encrypted data
94
+            if doc.is_encrypted():
95
+                continue
96
+
97
+            # only encrypt if storagePolicy is set to encrypted.
98
+            if not doc.is_storage_policy_encrypted():
99
+                # case documents in a file have different storage
100
+                # policies
101
+                doc_list.append(doc.embedded_document)
102
+                continue
103
+
104
+            doc.set_secret(
105
+                encrypt(doc.get_secret().encode(), self.passphrase, self.salt))
106
+            doc.set_encrypted(author)
107
+            encrypted_docs = True
108
+            doc_list.append(doc.pegleg_document)
109
+        if encrypted_docs:
110
+            files.write(save_path, doc_list)
111
+            LOG.info('Wrote data to: {}.'.format(save_path))
112
+        else:
113
+            LOG.debug('All documents in file: {} are either already encrypted '
114
+                      'or have cleartext storage policy. '
115
+                      'Skipping.'.format(self.file_path))
116
+
117
+    def decrypt_secrets(self):
118
+        """Decrypt and unwrap pegleg managed encrypted secrets documents
119
+        included in a site secrets file, and print the result to the standard
120
+        out."""
121
+
122
+        doc_list = []
123
+        for doc in self.documents:
124
+            # only decrypt an encrypted document
125
+            if doc.is_encrypted():
126
+                doc.set_secret(
127
+                    decrypt(doc.get_secret(),
128
+                            self.passphrase,
129
+                            self.salt).decode())
130
+                doc.set_decrypted()
131
+            doc_list.append(doc.embedded_document)
132
+        yaml.safe_dump_all(
133
+            doc_list,
134
+            sys.stdout,
135
+            explicit_start=True,
136
+            explicit_end=True,
137
+            default_flow_style=False)

+ 1
- 0
requirements.txt View File

@@ -2,4 +2,5 @@ gitpython
2 2
 click==6.7
3 3
 jsonschema==2.6.0
4 4
 pyyaml==3.12
5
+cryptography==2.3.1
5 6
 git+https://github.com/openstack/airship-deckhand.git@7d697012fcbd868b14670aa9cf895acfad5a7f8d

+ 94
- 0
tests/unit/engine/test_encryption.py View File

@@ -0,0 +1,94 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import click
16
+import os
17
+import tempfile
18
+
19
+import mock
20
+import pytest
21
+import yaml
22
+
23
+from pegleg.engine.util import encryption as crypt
24
+from tests.unit import test_utils
25
+from pegleg.engine import secrets
26
+from pegleg.engine.util.pegleg_managed_document import \
27
+    PeglegManagedSecretsDocument
28
+from pegleg.engine.util.pegleg_secret_management import PeglegSecretManagement
29
+from pegleg.engine.util.pegleg_secret_management import ENV_PASSPHRASE
30
+from pegleg.engine.util.pegleg_secret_management import ENV_SALT
31
+
32
+TEST_DATA = """
33
+---
34
+schema: deckhand/Passphrase/v1
35
+metadata:
36
+  schema: metadata/Document/v1
37
+  name: osh_addons_keystone_ranger-agent_password
38
+  layeringDefinition:
39
+    abstract: false
40
+    layer: site
41
+  storagePolicy: encrypted
42
+data: 512363f37eab654313991174aef9f867d
43
+...
44
+"""
45
+
46
+
47
+def test_encrypt_and_decrypt():
48
+    data = test_utils.rand_name("this is an example of un-encrypted "
49
+                                "data.", "pegleg").encode()
50
+    passphrase = test_utils.rand_name("passphrase1", "pegleg").encode()
51
+    salt = test_utils.rand_name("salt1", "pegleg").encode()
52
+    enc1 = crypt.encrypt(data, passphrase, salt)
53
+    dec1 = crypt.decrypt(enc1, passphrase, salt)
54
+    assert data == dec1
55
+    enc2 = crypt.encrypt(dec1, passphrase, salt)
56
+    dec2 = crypt.decrypt(enc2, passphrase, salt)
57
+    assert data == dec2
58
+
59
+
60
+@mock.patch.dict(os.environ, {ENV_PASSPHRASE:'aShortPassphrase',
61
+                              ENV_SALT: 'MySecretSalt'})
62
+def test_short_passphrase():
63
+    with pytest.raises(click.ClickException,
64
+                       match=r'.*is not at least 24-character long.*'):
65
+        PeglegSecretManagement('file_path')
66
+
67
+
68
+def test_PeglegManagedDocument():
69
+    test_data = yaml.load(TEST_DATA)
70
+    doc = PeglegManagedSecretsDocument(test_data)
71
+    assert doc.is_storage_policy_encrypted() is True
72
+    assert doc.is_encrypted() is False
73
+
74
+
75
+@mock.patch.dict(os.environ, {ENV_PASSPHRASE:'ytrr89erARAiPE34692iwUMvWqqBvC',
76
+                              ENV_SALT: 'MySecretSalt'})
77
+def test_encrypt_document():
78
+    # write the test data to temp file
79
+    test_data = yaml.load(TEST_DATA)
80
+    dir = tempfile.mkdtemp()
81
+    file_path = os.path.join(dir, 'secrets_file.yaml')
82
+    save_path = os.path.join(dir, 'encrypted_secrets_file.yaml')
83
+    with open(file_path, 'w') as stream:
84
+        yaml.dump(test_data,
85
+                  stream,
86
+                  explicit_start=True,
87
+                  explicit_end=True,
88
+                  default_flow_style=False)
89
+    # read back the secrets data file and encrypt it
90
+    doc_mgr = PeglegSecretManagement(file_path)
91
+    doc_mgr.encrypt_secrets(save_path, 'test_author')
92
+    doc = doc_mgr.documents[0]
93
+    assert doc.is_encrypted()
94
+    assert doc.data['encrypted']['by'] == 'test_author'

+ 2
- 0
tools/pegleg.sh View File

@@ -20,5 +20,7 @@ docker run --rm $TERM_OPTS \
20 20
     --workdir="$container_workspace_path" \
21 21
     -v "${HOME}/.ssh:${container_workspace_path}/.ssh" \
22 22
     -v "${WORKSPACE}:$container_workspace_path" \
23
+    -e "PEGLEG_PASSPHRASE=$PEGLEG_PASSPHRASE" \
24
+    -e "PEGLEG_SALT=$PEGLEG_SALT" \
23 25
     "${IMAGE}" \
24 26
     pegleg "${@}"

+ 1
- 1
tox.ini View File

@@ -9,7 +9,7 @@ skipsdist = True
9 9
 setenv = VIRTUAL_ENV={envdir}
10 10
          LANGUAGE=en_US
11 11
          LC_ALL=en_US.utf-8
12
-passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY
12
+passenv = http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
13 13
 deps =
14 14
   -r{toxinidir}/requirements.txt
15 15
   -r{toxinidir}/test-requirements.txt

Loading…
Cancel
Save