summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAhmad Mahmoudi <am495p@att.com>2018-10-04 17:05:34 -0500
committerFelipe Monteiro <felipe.monteiro@att.com>2018-10-30 16:53:51 +0000
commiteb0deeb9e587e948ee2c8f02b85f9271ccda2cff (patch)
tree4b6ffbb321f57437a83fc37be229bcc4a8b174d4
parent0fc18c6f192a767a624dc97b12370956b6eb47ee (diff)
Pegleg encryption of site secrets
Added secret encryption/decryption to pegleg cli. Change-Id: I95b993748d99fc4398eee1d1c59e74f382497f74
Notes
Notes (review): Code-Review+1: Bryan Strassner <bryan.strassner@gmail.com> Code-Review+1: Nishant Kumar <nishant.e.kumar@ericsson.com> Code-Review+2: Scott Hussey <sthussey@att.com> Code-Review+2: Matt McEuen <matt.mceuen@att.com> Workflow+1: Matt McEuen <matt.mceuen@att.com> Code-Review+2: Felipe Monteiro <felipe.monteiro@att.com> Verified+2: Zuul Submitted-by: Zuul Submitted-at: Tue, 30 Oct 2018 21:18:39 +0000 Reviewed-on: https://review.openstack.org/608110 Project: openstack/airship-pegleg Branch: refs/heads/master
-rw-r--r--doc/source/cli/cli.rst99
-rw-r--r--doc/source/images/architecture-pegleg.pngbin37600 -> 37602 bytes
-rw-r--r--pegleg/cli.py47
-rw-r--r--pegleg/engine/__init__.py1
-rw-r--r--pegleg/engine/secrets.py113
-rw-r--r--pegleg/engine/site.py11
-rw-r--r--pegleg/engine/util/encryption.py129
-rw-r--r--pegleg/engine/util/files.py67
-rw-r--r--pegleg/engine/util/pegleg_managed_document.py141
-rw-r--r--pegleg/engine/util/pegleg_secret_management.py137
-rw-r--r--requirements.txt1
-rw-r--r--tests/unit/engine/test_encryption.py94
-rwxr-xr-xtools/pegleg.sh2
-rw-r--r--tox.ini2
14 files changed, 835 insertions, 9 deletions
diff --git a/doc/source/cli/cli.rst b/doc/source/cli/cli.rst
index 6820a34..192cc95 100644
--- a/doc/source/cli/cli.rst
+++ b/doc/source/cli/cli.rst
@@ -391,6 +391,105 @@ A more complex example involves excluding certain linting checks:
391 391
392.. _command-line-repository-overrides: 392.. _command-line-repository-overrides:
393 393
394Secrets
395-------
396
397A sub-group of site command group, which allows you to perform secrets
398level 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
405Encrypt
406^^^^^^^
407
408Encrypt one site's secrets documents, which have the
409metadata.storagePolicy set to encrypted, and wrap them in `pegleg managed
410documents <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
413than once for a given site, it will skip the files, which are already
414encrypted and wrapped in a pegleg managed document, and will only encrypt the
415documents not encrypted before.
416
417**site_name** (Required).
418
419Name of the site.
420
421**-a / --author** (Required)
422
423Identifier for the program or person who is encrypting the secrets documents.
424
425**-s / --save-location** (Optional).
426
427Where to output encrypted and wrapped documents. If omitted, the results
428will overwrite the original documents.
429
430Usage:
431
432::
433
434 ./pegleg.sh site <options> secrets encrypt <site_name> -a <author_id> -s <save_location>
435
436Examples
437""""""""
438
439Example 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
448Example 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
457Decrypt
458^^^^^^^
459
460Unwrap an encrypted secrets document from a `pegleg managed
461document <https://airship-specs.readthedocs.io/en/latest/specs/approved/pegleg-secrets.html#peglegmanageddocument>`_,
462decrypt the encrypted secrets, and dump the cleartext secrets file to
463``stdout``.
464
465**site_name** (Required).
466
467Name of the site.
468
469**-f / filename** (Required).
470
471The absolute path to the pegleg managed encrypted secrets file.
472
473Usage:
474
475::
476
477 ./pegleg.sh site <options> secrets decrypt <site_name> -f <file_path>
478
479Examples
480""""""""
481
482Example:
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
394CLI Repository Overrides 493CLI Repository Overrides
395------------------------ 494------------------------
396 495
diff --git a/doc/source/images/architecture-pegleg.png b/doc/source/images/architecture-pegleg.png
index ed0a368..85428fb 100644
--- a/doc/source/images/architecture-pegleg.png
+++ b/doc/source/images/architecture-pegleg.png
Binary files differ
diff --git a/pegleg/cli.py b/pegleg/cli.py
index 78c31f8..0e3614d 100644
--- a/pegleg/cli.py
+++ b/pegleg/cli.py
@@ -358,3 +358,50 @@ def list_types(*, output_stream):
358 """List type names for a given repository.""" 358 """List type names for a given repository."""
359 engine.repository.process_site_repository(update_config=True) 359 engine.repository.process_site_repository(update_config=True)
360 engine.type.list_types(output_stream) 360 engine.type.list_types(output_stream)
361
362
363@site.group(name='secrets', help='Commands to manage site secrets documents')
364def 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')
390def 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')
405def decrypt(*, file_name, site_name):
406 engine.repository.process_repositories(site_name)
407 engine.secrets.decrypt(file_name, site_name)
diff --git a/pegleg/engine/__init__.py b/pegleg/engine/__init__.py
index f4c42b7..1396f9c 100644
--- a/pegleg/engine/__init__.py
+++ b/pegleg/engine/__init__.py
@@ -19,6 +19,7 @@ from pegleg.engine import lint
19from pegleg.engine import repository 19from pegleg.engine import repository
20from pegleg.engine import site 20from pegleg.engine import site
21from pegleg.engine import type 21from pegleg.engine import type
22from pegleg.engine import secrets
22 23
23 24
24def __represent_multiline_yaml_str(): 25def __represent_multiline_yaml_str():
diff --git a/pegleg/engine/secrets.py b/pegleg/engine/secrets.py
new file mode 100644
index 0000000..61b5e6e
--- /dev/null
+++ b/pegleg/engine/secrets.py
@@ -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
15import logging
16import os
17
18from pegleg.engine.util.pegleg_secret_management import PeglegSecretManagement
19from pegleg.engine.util import files
20from pegleg.engine.util import definition
21
22__all__ = ('encrypt', 'decrypt')
23
24LOG = logging.getLogger(__name__)
25
26
27def 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
63def 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
89def _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
diff --git a/pegleg/engine/site.py b/pegleg/engine/site.py
index 902e1aa..2a03074 100644
--- a/pegleg/engine/site.py
+++ b/pegleg/engine/site.py
@@ -21,6 +21,7 @@ import yaml
21from prettytable import PrettyTable 21from prettytable import PrettyTable
22 22
23from pegleg.engine import util 23from pegleg.engine import util
24from pegleg.engine.util import files
24 25
25__all__ = ('collect', 'list_', 'show', 'render') 26__all__ = ('collect', 'list_', 'show', 'render')
26 27
@@ -55,14 +56,8 @@ def _collect_to_file(site_name, save_location):
55 """Collects all documents related to ``site_name`` and outputs them to 56 """Collects all documents related to ``site_name`` and outputs them to
56 the file denoted by ``save_location``. 57 the file denoted by ``save_location``.
57 """ 58 """
58 if not os.path.exists(save_location): 59
59 LOG.debug("Collection save location %s does not exist. Creating " 60 files.check_file_save_location(save_location)
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)
66 61
67 save_files = dict() 62 save_files = dict()
68 try: 63 try:
diff --git a/pegleg/engine/util/encryption.py b/pegleg/engine/util/encryption.py
new file mode 100644
index 0000000..a434702
--- /dev/null
+++ b/pegleg/engine/util/encryption.py
@@ -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
15import logging
16import base64
17from cryptography.fernet import Fernet
18from cryptography.hazmat.backends import default_backend
19from cryptography.hazmat.primitives import hashes
20from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
21from cryptography.exceptions import InvalidSignature
22
23KEY_LENGTH = 32
24ITERATIONS = 10000
25LOG = logging.getLogger(__name__)
26
27
28def 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
61def 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
101def _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))
diff --git a/pegleg/engine/util/files.py b/pegleg/engine/util/files.py
index 8f578a3..5281dbb 100644
--- a/pegleg/engine/util/files.py
+++ b/pegleg/engine/util/files.py
@@ -29,9 +29,12 @@ __all__ = [
29 'directories_for', 29 'directories_for',
30 'directory_for', 30 'directory_for',
31 'dump', 31 'dump',
32 'read',
33 'write',
32 'existing_directories', 34 'existing_directories',
33 'search', 35 'search',
34 'slurp', 36 'slurp',
37 'check_file_save_location',
35] 38]
36 39
37DIR_DEPTHS = { 40DIR_DEPTHS = {
@@ -234,6 +237,48 @@ def dump(path, data):
234 yaml.dump(data, f, explicit_start=True) 237 yaml.dump(data, f, explicit_start=True)
235 238
236 239
240def 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
258def 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
237def _recurse_subdirs(search_path, depth): 282def _recurse_subdirs(search_path, depth):
238 directories = set() 283 directories = set()
239 try: 284 try:
@@ -257,3 +302,25 @@ def search(search_paths):
257 for filename in filenames: 302 for filename in filenames:
258 if filename.endswith(".yaml"): 303 if filename.endswith(".yaml"):
259 yield os.path.join(root, filename) 304 yield os.path.join(root, filename)
305
306
307def 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))
diff --git a/pegleg/engine/util/pegleg_managed_document.py b/pegleg/engine/util/pegleg_managed_document.py
new file mode 100644
index 0000000..94527be
--- /dev/null
+++ b/pegleg/engine/util/pegleg_managed_document.py
@@ -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
15import logging
16from datetime import datetime
17
18PEGLEG_MANAGED_SCHEMA = 'pegleg/PeglegManagedDocument/v1'
19ENCRYPTED = 'encrypted'
20STORAGE_POLICY = 'storagePolicy'
21METADATA = 'metadata'
22LOG = logging.getLogger(__name__)
23
24
25class 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')
diff --git a/pegleg/engine/util/pegleg_secret_management.py b/pegleg/engine/util/pegleg_secret_management.py
new file mode 100644
index 0000000..313bdff
--- /dev/null
+++ b/pegleg/engine/util/pegleg_secret_management.py
@@ -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
15import logging
16import os
17import yaml
18import sys
19import re
20import click
21
22from pegleg.engine.util.encryption import encrypt
23from pegleg.engine.util.encryption import decrypt
24from pegleg.engine.util.pegleg_managed_document import \
25 PeglegManagedSecretsDocument as PeglegManagedSecret
26from pegleg.engine.util import files
27
28LOG = logging.getLogger(__name__)
29PASSPHRASE_PATTERN = '^.{24,}$'
30ENV_PASSPHRASE = 'PEGLEG_PASSPHRASE'
31ENV_SALT = 'PEGLEG_SALT'
32
33
34class 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)
diff --git a/requirements.txt b/requirements.txt
index 30cc46d..ca8d659 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,5 @@ gitpython
2click==6.7 2click==6.7
3jsonschema==2.6.0 3jsonschema==2.6.0
4pyyaml==3.12 4pyyaml==3.12
5cryptography==2.3.1
5git+https://github.com/openstack/airship-deckhand.git@7d697012fcbd868b14670aa9cf895acfad5a7f8d 6git+https://github.com/openstack/airship-deckhand.git@7d697012fcbd868b14670aa9cf895acfad5a7f8d
diff --git a/tests/unit/engine/test_encryption.py b/tests/unit/engine/test_encryption.py
new file mode 100644
index 0000000..5967efc
--- /dev/null
+++ b/tests/unit/engine/test_encryption.py
@@ -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
15import click
16import os
17import tempfile
18
19import mock
20import pytest
21import yaml
22
23from pegleg.engine.util import encryption as crypt
24from tests.unit import test_utils
25from pegleg.engine import secrets
26from pegleg.engine.util.pegleg_managed_document import \
27 PeglegManagedSecretsDocument
28from pegleg.engine.util.pegleg_secret_management import PeglegSecretManagement
29from pegleg.engine.util.pegleg_secret_management import ENV_PASSPHRASE
30from pegleg.engine.util.pegleg_secret_management import ENV_SALT
31
32TEST_DATA = """
33---
34schema: deckhand/Passphrase/v1
35metadata:
36 schema: metadata/Document/v1
37 name: osh_addons_keystone_ranger-agent_password
38 layeringDefinition:
39 abstract: false
40 layer: site
41 storagePolicy: encrypted
42data: 512363f37eab654313991174aef9f867d
43...
44"""
45
46
47def 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'})
62def 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
68def 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'})
77def 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'
diff --git a/tools/pegleg.sh b/tools/pegleg.sh
index 4d83dcb..ca75a33 100755
--- a/tools/pegleg.sh
+++ b/tools/pegleg.sh
@@ -20,5 +20,7 @@ docker run --rm $TERM_OPTS \
20 --workdir="$container_workspace_path" \ 20 --workdir="$container_workspace_path" \
21 -v "${HOME}/.ssh:${container_workspace_path}/.ssh" \ 21 -v "${HOME}/.ssh:${container_workspace_path}/.ssh" \
22 -v "${WORKSPACE}:$container_workspace_path" \ 22 -v "${WORKSPACE}:$container_workspace_path" \
23 -e "PEGLEG_PASSPHRASE=$PEGLEG_PASSPHRASE" \
24 -e "PEGLEG_SALT=$PEGLEG_SALT" \
23 "${IMAGE}" \ 25 "${IMAGE}" \
24 pegleg "${@}" 26 pegleg "${@}"
diff --git a/tox.ini b/tox.ini
index c4db8da..1df364f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,7 +9,7 @@ skipsdist = True
9setenv = VIRTUAL_ENV={envdir} 9setenv = VIRTUAL_ENV={envdir}
10 LANGUAGE=en_US 10 LANGUAGE=en_US
11 LC_ALL=en_US.utf-8 11 LC_ALL=en_US.utf-8
12passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY 12passenv = http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
13deps = 13deps =
14 -r{toxinidir}/requirements.txt 14 -r{toxinidir}/requirements.txt
15 -r{toxinidir}/test-requirements.txt 15 -r{toxinidir}/test-requirements.txt