Pegleg encryption of site secrets
Added secret encryption/decryption to pegleg cli. Change-Id: I95b993748d99fc4398eee1d1c59e74f382497f74
This commit is contained in:
parent
0fc18c6f19
commit
eb0deeb9e5
@ -391,6 +391,105 @@ A more complex example involves excluding certain linting checks:
|
|||||||
|
|
||||||
.. _command-line-repository-overrides:
|
.. _command-line-repository-overrides:
|
||||||
|
|
||||||
|
Secrets
|
||||||
|
-------
|
||||||
|
|
||||||
|
A sub-group of site command group, which allows you to perform secrets
|
||||||
|
level operations for secrets documents of a site.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
./pegleg.sh site -r <site_repo> -e <extra_repo> secrets <command> <options>
|
||||||
|
|
||||||
|
|
||||||
|
Encrypt
|
||||||
|
^^^^^^^
|
||||||
|
|
||||||
|
Encrypt one site's secrets documents, which have the
|
||||||
|
metadata.storagePolicy set to encrypted, and wrap them in `pegleg managed
|
||||||
|
documents <https://airship-specs.readthedocs.io/en/latest/specs/approved/pegleg-secrets.html#peglegmanageddocument>`_.
|
||||||
|
|
||||||
|
**Note**: The encrypt command is idempotent. If the command is executed more
|
||||||
|
than once for a given site, it will skip the files, which are already
|
||||||
|
encrypted and wrapped in a pegleg managed document, and will only encrypt the
|
||||||
|
documents not encrypted before.
|
||||||
|
|
||||||
|
**site_name** (Required).
|
||||||
|
|
||||||
|
Name of the site.
|
||||||
|
|
||||||
|
**-a / --author** (Required)
|
||||||
|
|
||||||
|
Identifier for the program or person who is encrypting the secrets documents.
|
||||||
|
|
||||||
|
**-s / --save-location** (Optional).
|
||||||
|
|
||||||
|
Where to output encrypted and wrapped documents. If omitted, the results
|
||||||
|
will overwrite the original documents.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
./pegleg.sh site <options> secrets encrypt <site_name> -a <author_id> -s <save_location>
|
||||||
|
|
||||||
|
Examples
|
||||||
|
""""""""
|
||||||
|
|
||||||
|
Example with optional save location:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
./pegleg.sh site -r /opt/site-manifests \
|
||||||
|
-e global=/opt/manifests \
|
||||||
|
-e secrets=/opt/security-manifests \
|
||||||
|
secrets encrypt <site_name> -a <author_id> -s /workspace
|
||||||
|
|
||||||
|
Example without optional save location:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
./pegleg.sh site -r /opt/site-manifests \
|
||||||
|
-e global=/opt/manifests \
|
||||||
|
-e secrets=/opt/security-manifests \
|
||||||
|
secrets encrypt <site_name> -a <author_id>
|
||||||
|
|
||||||
|
Decrypt
|
||||||
|
^^^^^^^
|
||||||
|
|
||||||
|
Unwrap an encrypted secrets document from a `pegleg managed
|
||||||
|
document <https://airship-specs.readthedocs.io/en/latest/specs/approved/pegleg-secrets.html#peglegmanageddocument>`_,
|
||||||
|
decrypt the encrypted secrets, and dump the cleartext secrets file to
|
||||||
|
``stdout``.
|
||||||
|
|
||||||
|
**site_name** (Required).
|
||||||
|
|
||||||
|
Name of the site.
|
||||||
|
|
||||||
|
**-f / filename** (Required).
|
||||||
|
|
||||||
|
The absolute path to the pegleg managed encrypted secrets file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
./pegleg.sh site <options> secrets decrypt <site_name> -f <file_path>
|
||||||
|
|
||||||
|
Examples
|
||||||
|
""""""""
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
./pegleg.sh site -r /opt/site-manifests \
|
||||||
|
-e global=/opt/manifests \
|
||||||
|
-e secrets=/opt/security-manifests \
|
||||||
|
secrets decrypt site1 -f \
|
||||||
|
/opt/security-manifests/site/site1/passwords/password1.yaml
|
||||||
|
|
||||||
|
|
||||||
CLI Repository Overrides
|
CLI Repository Overrides
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
@ -358,3 +358,50 @@ def list_types(*, output_stream):
|
|||||||
"""List type names for a given repository."""
|
"""List type names for a given repository."""
|
||||||
engine.repository.process_site_repository(update_config=True)
|
engine.repository.process_site_repository(update_config=True)
|
||||||
engine.type.list_types(output_stream)
|
engine.type.list_types(output_stream)
|
||||||
|
|
||||||
|
|
||||||
|
@site.group(name='secrets', help='Commands to manage site secrets documents')
|
||||||
|
def secrets():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@secrets.command(
|
||||||
|
'encrypt',
|
||||||
|
help='Command to encrypt and wrap site secrets '
|
||||||
|
'documents with metadata.storagePolicy set '
|
||||||
|
'to encrypted, in pegleg managed documents.')
|
||||||
|
@click.option(
|
||||||
|
'-s',
|
||||||
|
'--save-location',
|
||||||
|
'save_location',
|
||||||
|
default=None,
|
||||||
|
help='Directory to output the encrypted site secrets files. Created '
|
||||||
|
'automatically if it does not already exist. '
|
||||||
|
'If save_location is not provided, the output encrypted files will '
|
||||||
|
'overwrite the original input files (default behavior)')
|
||||||
|
@click.option(
|
||||||
|
'-a',
|
||||||
|
'--author',
|
||||||
|
'author',
|
||||||
|
required=True,
|
||||||
|
help='Identifier for the program or person who is encrypting the secrets '
|
||||||
|
'documents')
|
||||||
|
@click.argument('site_name')
|
||||||
|
def encrypt(*, save_location, author, site_name):
|
||||||
|
engine.repository.process_repositories(site_name)
|
||||||
|
engine.secrets.encrypt(save_location, author, site_name)
|
||||||
|
|
||||||
|
|
||||||
|
@secrets.command(
|
||||||
|
'decrypt',
|
||||||
|
help='Command to unwrap and decrypt one site '
|
||||||
|
'secrets document and print it to stdout.')
|
||||||
|
@click.option(
|
||||||
|
'-f',
|
||||||
|
'--filename',
|
||||||
|
'file_name',
|
||||||
|
help='The file name to decrypt and print out to stdout')
|
||||||
|
@click.argument('site_name')
|
||||||
|
def decrypt(*, file_name, site_name):
|
||||||
|
engine.repository.process_repositories(site_name)
|
||||||
|
engine.secrets.decrypt(file_name, site_name)
|
||||||
|
@ -19,6 +19,7 @@ from pegleg.engine import lint
|
|||||||
from pegleg.engine import repository
|
from pegleg.engine import repository
|
||||||
from pegleg.engine import site
|
from pegleg.engine import site
|
||||||
from pegleg.engine import type
|
from pegleg.engine import type
|
||||||
|
from pegleg.engine import secrets
|
||||||
|
|
||||||
|
|
||||||
def __represent_multiline_yaml_str():
|
def __represent_multiline_yaml_str():
|
||||||
|
113
pegleg/engine/secrets.py
Normal file
113
pegleg/engine/secrets.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pegleg.engine.util.pegleg_secret_management import PeglegSecretManagement
|
||||||
|
from pegleg.engine.util import files
|
||||||
|
from pegleg.engine.util import definition
|
||||||
|
|
||||||
|
__all__ = ('encrypt', 'decrypt')
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(save_location, author, site_name):
|
||||||
|
"""
|
||||||
|
Encrypt all secrets documents for a site identifies by site_name.
|
||||||
|
|
||||||
|
Parse through all documents related to site_name and encrypt all
|
||||||
|
site documents which have metadata.storagePolicy: encrypted, and which are
|
||||||
|
not already encrypted and wrapped in a PeglegManagedDocument.
|
||||||
|
Passphrase and salt for the encryption are read from environment
|
||||||
|
variables ($PEGLEG_PASSPHRASE and $PEGLEG_SALT respectively).
|
||||||
|
By default, the resulting output files will overwrite the original
|
||||||
|
unencrypted secrets documents.
|
||||||
|
:param save_location: if provided, identifies the base directory to store
|
||||||
|
the encrypted secrets files. If not provided the encrypted secrets files
|
||||||
|
will overwrite the original unencrypted files (default behavior).
|
||||||
|
:type save_location: string
|
||||||
|
:param author: The identifier provided by the application or
|
||||||
|
the person who requests encrypt the site secrets documents.
|
||||||
|
:type author: string
|
||||||
|
:param site_name: The name of the site to encrypt its secrets files.
|
||||||
|
:type site_name: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
files.check_file_save_location(save_location)
|
||||||
|
LOG.info('Started encrypting...')
|
||||||
|
secrets_found = False
|
||||||
|
for repo_base, file_path in definition.site_files_by_repo(site_name):
|
||||||
|
secrets_found = True
|
||||||
|
PeglegSecretManagement(file_path).encrypt_secrets(
|
||||||
|
_get_dest_path(repo_base, file_path, save_location), author)
|
||||||
|
if secrets_found:
|
||||||
|
LOG.info('Encryption of all secret files was completed.')
|
||||||
|
else:
|
||||||
|
LOG.warn(
|
||||||
|
'No secret documents were found for site: {}'.format(site_name))
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(file_path, site_name):
|
||||||
|
"""
|
||||||
|
Decrypt one secrets file and print the decrypted data to standard out.
|
||||||
|
|
||||||
|
Search in in secrets file of a site, identified by site_name, for a file
|
||||||
|
named file_name.
|
||||||
|
If the file is found and encrypted, unwrap and decrypt it and print the
|
||||||
|
result to standard out.
|
||||||
|
If the file is found, but it is not encrypted, print the contents of the
|
||||||
|
file to standard out.
|
||||||
|
Passphrase and salt for the decryption are read from environment variables.
|
||||||
|
:param file_path: Path to the file to be unwrapped and decrypted.
|
||||||
|
:type file_path: string
|
||||||
|
:param site_name: The name of the site to search for the file.
|
||||||
|
:type site_name: string providing the site name
|
||||||
|
"""
|
||||||
|
|
||||||
|
LOG.info('Started decrypting...')
|
||||||
|
if os.path.isfile(file_path) \
|
||||||
|
and [s for s in file_path.split(os.path.sep) if s == site_name]:
|
||||||
|
PeglegSecretManagement(file_path).decrypt_secrets()
|
||||||
|
else:
|
||||||
|
LOG.info('File: {} was not found. Check your file path and name, '
|
||||||
|
'and try again.'.format(file_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dest_path(repo_base, file_path, save_location):
|
||||||
|
"""
|
||||||
|
Calculate and return the destination base directory path for the
|
||||||
|
encrypted or decrypted secrets files.
|
||||||
|
|
||||||
|
:param repo_base: Base repo of the source secrets file.
|
||||||
|
:type repo_base: string
|
||||||
|
:param file_path: File path to the source secrets file.
|
||||||
|
:type file_path: string
|
||||||
|
:param save_location: Base location of destination secrets file
|
||||||
|
:type save_location: string
|
||||||
|
:return: The file path of the destination secrets file.
|
||||||
|
:rtype: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
if save_location \
|
||||||
|
and save_location != os.path.sep \
|
||||||
|
and save_location.endswith(os.path.sep):
|
||||||
|
save_location = save_location.rstrip(os.path.sep)
|
||||||
|
if repo_base and repo_base.endswith(os.path.sep):
|
||||||
|
repo_base = repo_base.rstrip(os.path.sep)
|
||||||
|
if save_location:
|
||||||
|
return file_path.replace(repo_base, save_location)
|
||||||
|
else:
|
||||||
|
return file_path
|
@ -21,6 +21,7 @@ import yaml
|
|||||||
from prettytable import PrettyTable
|
from prettytable import PrettyTable
|
||||||
|
|
||||||
from pegleg.engine import util
|
from pegleg.engine import util
|
||||||
|
from pegleg.engine.util import files
|
||||||
|
|
||||||
__all__ = ('collect', 'list_', 'show', 'render')
|
__all__ = ('collect', 'list_', 'show', 'render')
|
||||||
|
|
||||||
@ -55,14 +56,8 @@ def _collect_to_file(site_name, save_location):
|
|||||||
"""Collects all documents related to ``site_name`` and outputs them to
|
"""Collects all documents related to ``site_name`` and outputs them to
|
||||||
the file denoted by ``save_location``.
|
the file denoted by ``save_location``.
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(save_location):
|
|
||||||
LOG.debug("Collection save location %s does not exist. Creating "
|
files.check_file_save_location(save_location)
|
||||||
"automatically.", save_location)
|
|
||||||
os.makedirs(save_location)
|
|
||||||
# In case save_location already exists and isn't a directory.
|
|
||||||
if not os.path.isdir(save_location):
|
|
||||||
raise click.ClickException('save_location %s already exists, but must '
|
|
||||||
'be a directory' % save_location)
|
|
||||||
|
|
||||||
save_files = dict()
|
save_files = dict()
|
||||||
try:
|
try:
|
||||||
|
129
pegleg/engine/util/encryption.py
Normal file
129
pegleg/engine/util/encryption.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
|
||||||
|
KEY_LENGTH = 32
|
||||||
|
ITERATIONS = 10000
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(unencrypted_data,
|
||||||
|
passphrase,
|
||||||
|
salt,
|
||||||
|
key_length=KEY_LENGTH,
|
||||||
|
iterations=ITERATIONS):
|
||||||
|
"""
|
||||||
|
Encrypt the data, using the provided passphrase and salt,
|
||||||
|
and return the encrypted data.
|
||||||
|
|
||||||
|
:param unencrypted_data: Secret data to encrypt
|
||||||
|
:type unencrypted_data: bytes
|
||||||
|
:param passphrase: Passphrase to use to generate encryption key. Must be
|
||||||
|
at least 24-byte long
|
||||||
|
:type passphrase: bytes
|
||||||
|
:param salt: salt to use to generate encryption key. Must be randomly
|
||||||
|
generated.
|
||||||
|
:type salt: bytes
|
||||||
|
:param key_length: Length of the encryption key to generate, in bytes.
|
||||||
|
Will default to 32, if not provided.
|
||||||
|
:type key_length: positive integer.
|
||||||
|
:param iterations: A large number, used as seed to increase the entropy
|
||||||
|
in randomness of the generated key for encryption, and hence greatly
|
||||||
|
increase the security of encrypted data. will default to 10000, if not
|
||||||
|
provided.
|
||||||
|
:type iterations: positive integer.
|
||||||
|
:return: Encrypted secret data
|
||||||
|
:rtype: bytes
|
||||||
|
"""
|
||||||
|
|
||||||
|
return Fernet(_generate_key(passphrase, salt, key_length,
|
||||||
|
iterations)).encrypt(unencrypted_data)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(encrypted_data,
|
||||||
|
passphrase,
|
||||||
|
salt,
|
||||||
|
key_length=KEY_LENGTH,
|
||||||
|
iterations=ITERATIONS):
|
||||||
|
"""
|
||||||
|
Decrypt the data, using the provided passphrase and salt,
|
||||||
|
and return the decrypted data.
|
||||||
|
|
||||||
|
:param encrypted_data: Encrypted secret data
|
||||||
|
:type encrypted_data: bytes
|
||||||
|
:param passphrase: Passphrase to use to generate decryption key. Must be
|
||||||
|
at least 32-byte long.
|
||||||
|
:type passphrase: bytes
|
||||||
|
:param salt: salt to use to generate decryption key. Must be randomly
|
||||||
|
generated.
|
||||||
|
:type salt: bytes
|
||||||
|
:param key_length: Length of the decryption key to generate, in bytes.
|
||||||
|
will default to 32, if not provided.
|
||||||
|
:type key_length: positive integer.
|
||||||
|
:param iterations: A large number, used as seed to increase entropy in
|
||||||
|
the randomness of the generated key for decryption, and hence greatly
|
||||||
|
increase the security of encrypted data. Will default to 10000, if not
|
||||||
|
provided.
|
||||||
|
:type iterations: positive integer.
|
||||||
|
:return: Decrypted secret data
|
||||||
|
:rtype: bytes
|
||||||
|
:raises InvalidSignature: If the provided passphrase, and/or
|
||||||
|
salt does not match the values used to encrypt the data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Fernet(_generate_key(passphrase, salt, key_length,
|
||||||
|
iterations)).decrypt(encrypted_data)
|
||||||
|
except InvalidSignature:
|
||||||
|
LOG.error('Signature verification to decrypt secrets failed. Please '
|
||||||
|
'check your provided passphrase and salt and try again.')
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_key(passphrase, salt, key_length, iterations):
|
||||||
|
"""
|
||||||
|
Use the passphrase and salt and PBKDF2HMAC key derivation algorithm,
|
||||||
|
to generate and and return a Fernet key to be used for encryption and
|
||||||
|
decryption of secret data.
|
||||||
|
|
||||||
|
:param passphrase: Passphrase to use to generate decryption key. Must be
|
||||||
|
at least 24-byte long.
|
||||||
|
:type passphrase: bytes
|
||||||
|
:param salt: salt to use to generate decryption key. Must be randomly
|
||||||
|
generated.
|
||||||
|
:type salt: bytes
|
||||||
|
:param key_length: Length of the decryption key to generate, in bytes.
|
||||||
|
Will default to 32, if not provided.
|
||||||
|
:type key_length: positive integer.
|
||||||
|
:param iterations: A large number, used as seed to increase the entropy
|
||||||
|
of the randomness of the generated key. will default to 10000, if not
|
||||||
|
provided.
|
||||||
|
:type iterations: positive integer.
|
||||||
|
:return: base64 encoded, URL safe Fernet key for encryption or decryption
|
||||||
|
"""
|
||||||
|
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=key_length,
|
||||||
|
salt=salt,
|
||||||
|
iterations=iterations,
|
||||||
|
backend=default_backend())
|
||||||
|
return base64.urlsafe_b64encode(kdf.derive(passphrase))
|
@ -29,9 +29,12 @@ __all__ = [
|
|||||||
'directories_for',
|
'directories_for',
|
||||||
'directory_for',
|
'directory_for',
|
||||||
'dump',
|
'dump',
|
||||||
|
'read',
|
||||||
|
'write',
|
||||||
'existing_directories',
|
'existing_directories',
|
||||||
'search',
|
'search',
|
||||||
'slurp',
|
'slurp',
|
||||||
|
'check_file_save_location',
|
||||||
]
|
]
|
||||||
|
|
||||||
DIR_DEPTHS = {
|
DIR_DEPTHS = {
|
||||||
@ -234,6 +237,48 @@ def dump(path, data):
|
|||||||
yaml.dump(data, f, explicit_start=True)
|
yaml.dump(data, f, explicit_start=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
"""
|
||||||
|
Read the yaml file ``path`` and return its contents as a list of
|
||||||
|
dicts
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise click.ClickException(
|
||||||
|
'{} not found. Pegleg must be run from the root of a '
|
||||||
|
'configuration repository.'.format(path))
|
||||||
|
|
||||||
|
with open(path) as stream:
|
||||||
|
try:
|
||||||
|
return list(yaml.safe_load_all(stream))
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
raise click.ClickException('Failed to parse %s:\n%s' % (path, e))
|
||||||
|
|
||||||
|
|
||||||
|
def write(file_path, data):
|
||||||
|
"""
|
||||||
|
Write the data to destination file_path.
|
||||||
|
|
||||||
|
If the directory structure of the file_path should not exist, create it.
|
||||||
|
If the file should exit, overwrite it with new data,
|
||||||
|
|
||||||
|
:param file_path: Destination file for the written data file
|
||||||
|
:type file_path: str
|
||||||
|
:param data: data to be written to the destination file
|
||||||
|
:type data: dict or a list of dicts
|
||||||
|
"""
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||||
|
|
||||||
|
with open(file_path, 'w') as stream:
|
||||||
|
yaml.safe_dump_all(
|
||||||
|
data,
|
||||||
|
stream,
|
||||||
|
explicit_start=True,
|
||||||
|
explicit_end=True,
|
||||||
|
default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
def _recurse_subdirs(search_path, depth):
|
def _recurse_subdirs(search_path, depth):
|
||||||
directories = set()
|
directories = set()
|
||||||
try:
|
try:
|
||||||
@ -257,3 +302,25 @@ def search(search_paths):
|
|||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
if filename.endswith(".yaml"):
|
if filename.endswith(".yaml"):
|
||||||
yield os.path.join(root, filename)
|
yield os.path.join(root, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def check_file_save_location(save_location):
|
||||||
|
"""
|
||||||
|
Verify exists and is a valid directory. If it does not exist create it.
|
||||||
|
|
||||||
|
:param save_location: Base directory to save the result of the
|
||||||
|
encryption or decryption of site secrets.
|
||||||
|
:type save_location: string, directory path
|
||||||
|
:raises click.ClickException: If pre-flight check should fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if save_location:
|
||||||
|
if not os.path.exists(save_location):
|
||||||
|
LOG.debug("Save location %s does not exist. Creating "
|
||||||
|
"automatically.", save_location)
|
||||||
|
os.makedirs(save_location)
|
||||||
|
# In case save_location already exists and isn't a directory.
|
||||||
|
if not os.path.isdir(save_location):
|
||||||
|
raise click.ClickException(
|
||||||
|
'save_location %s already exists, '
|
||||||
|
'but is not a directory'.format(save_location))
|
||||||
|
141
pegleg/engine/util/pegleg_managed_document.py
Normal file
141
pegleg/engine/util/pegleg_managed_document.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
PEGLEG_MANAGED_SCHEMA = 'pegleg/PeglegManagedDocument/v1'
|
||||||
|
ENCRYPTED = 'encrypted'
|
||||||
|
STORAGE_POLICY = 'storagePolicy'
|
||||||
|
METADATA = 'metadata'
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PeglegManagedSecretsDocument():
|
||||||
|
"""Object representing one Pegleg managed secret document."""
|
||||||
|
|
||||||
|
def __init__(self, secrets_document):
|
||||||
|
"""
|
||||||
|
Parse and wrap an externally generated document in a
|
||||||
|
pegleg managed document.
|
||||||
|
|
||||||
|
:param secrets_document: The content of the source document
|
||||||
|
:type secrets_document: dict
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.is_pegleg_managed_secret(secrets_document):
|
||||||
|
self._pegleg_document = secrets_document
|
||||||
|
else:
|
||||||
|
self._pegleg_document =\
|
||||||
|
self.__wrap(secrets_document)
|
||||||
|
self._embedded_document = \
|
||||||
|
self._pegleg_document['data']['managedDocument']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __wrap(secrets_document):
|
||||||
|
"""
|
||||||
|
Embeds a valid deckhand document in a pegleg managed document.
|
||||||
|
|
||||||
|
:param secrets_document: secrets document to be embedded in a
|
||||||
|
pegleg managed document.
|
||||||
|
:type secrets_document: dict
|
||||||
|
:return: pegleg manged document with the wrapped original secrets
|
||||||
|
document.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
'schema': PEGLEG_MANAGED_SCHEMA,
|
||||||
|
'metadata': {
|
||||||
|
'name': secrets_document['metadata']['name'],
|
||||||
|
'schema': 'deckhand/Document/v1',
|
||||||
|
'labels': secrets_document['metadata'].get('labels', {}),
|
||||||
|
'layeringDefinition': {
|
||||||
|
'abstract': False,
|
||||||
|
# The current requirement only requires site layer.
|
||||||
|
'layer': 'site',
|
||||||
|
},
|
||||||
|
'storagePolicy': 'cleartext'
|
||||||
|
},
|
||||||
|
'data': {
|
||||||
|
'managedDocument': {
|
||||||
|
'schema': secrets_document['schema'],
|
||||||
|
'metadata': secrets_document['metadata'],
|
||||||
|
'data': secrets_document['data']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_pegleg_managed_secret(secrets_document):
|
||||||
|
""""
|
||||||
|
Verify if the document is already a pegleg managed secrets document.
|
||||||
|
|
||||||
|
:return: True if the document is a pegleg managed secrets document,
|
||||||
|
False otherwise.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return PEGLEG_MANAGED_SCHEMA in secrets_document.get('schema')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embedded_document(self):
|
||||||
|
"""
|
||||||
|
parse the pegleg managed document, and return the embedded document
|
||||||
|
|
||||||
|
:return: The original secrets document unwrapped from the pegleg
|
||||||
|
managed document.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
return self._embedded_document
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._pegleg_document.get('metadata', {}).get('name')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
return self._pegleg_document.get('data')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pegleg_document(self):
|
||||||
|
return self._pegleg_document
|
||||||
|
|
||||||
|
def is_encrypted(self):
|
||||||
|
"""If the document is already encrypted return True. False
|
||||||
|
otherwise."""
|
||||||
|
return ENCRYPTED in self.data
|
||||||
|
|
||||||
|
def is_storage_policy_encrypted(self):
|
||||||
|
"""If the document's storagePolicy is set to encrypted return True.
|
||||||
|
False otherwise."""
|
||||||
|
return STORAGE_POLICY in self._embedded_document[METADATA] \
|
||||||
|
and ENCRYPTED in self._embedded_document[METADATA][STORAGE_POLICY]
|
||||||
|
|
||||||
|
def set_encrypted(self, author):
|
||||||
|
"""Mark the pegleg managed document as encrypted."""
|
||||||
|
self.data[ENCRYPTED] = {
|
||||||
|
'at': datetime.utcnow().isoformat(),
|
||||||
|
'by': author,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_decrypted(self):
|
||||||
|
"""Mark the pegleg managed document as un-encrypted."""
|
||||||
|
self.data.pop(ENCRYPTED)
|
||||||
|
|
||||||
|
def set_secret(self, secret):
|
||||||
|
self._embedded_document['data'] = secret
|
||||||
|
|
||||||
|
def get_secret(self):
|
||||||
|
return self._embedded_document.get('data')
|
137
pegleg/engine/util/pegleg_secret_management.py
Normal file
137
pegleg/engine/util/pegleg_secret_management.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import click
|
||||||
|
|
||||||
|
from pegleg.engine.util.encryption import encrypt
|
||||||
|
from pegleg.engine.util.encryption import decrypt
|
||||||
|
from pegleg.engine.util.pegleg_managed_document import \
|
||||||
|
PeglegManagedSecretsDocument as PeglegManagedSecret
|
||||||
|
from pegleg.engine.util import files
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
PASSPHRASE_PATTERN = '^.{24,}$'
|
||||||
|
ENV_PASSPHRASE = 'PEGLEG_PASSPHRASE'
|
||||||
|
ENV_SALT = 'PEGLEG_SALT'
|
||||||
|
|
||||||
|
|
||||||
|
class PeglegSecretManagement():
|
||||||
|
"""An object to handle operations on of a pegleg managed file."""
|
||||||
|
|
||||||
|
def __init__(self, file_path):
|
||||||
|
"""
|
||||||
|
Read the source file and the environment data needed to wrap and
|
||||||
|
process the file documents as pegleg managed document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.__check_environment()
|
||||||
|
self.file_path = file_path
|
||||||
|
self.documents = list()
|
||||||
|
for doc in files.read(file_path):
|
||||||
|
self.documents.append(PeglegManagedSecret(doc))
|
||||||
|
|
||||||
|
self.passphrase = os.environ.get(ENV_PASSPHRASE).encode()
|
||||||
|
self.salt = os.environ.get(ENV_SALT).encode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __check_environment():
|
||||||
|
"""
|
||||||
|
Validate required environment variables for encryption or decryption.
|
||||||
|
|
||||||
|
:return None
|
||||||
|
:raises click.ClickException: If environment validation should fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Verify that passphrase environment variable is defined and is longer
|
||||||
|
# than 24 characters.
|
||||||
|
if not os.environ.get(ENV_PASSPHRASE) or not re.match(
|
||||||
|
PASSPHRASE_PATTERN, os.environ.get(ENV_PASSPHRASE)):
|
||||||
|
raise click.ClickException(
|
||||||
|
'Environment variable {} is not defined or '
|
||||||
|
'is not at least 24-character long.'.format(ENV_PASSPHRASE))
|
||||||
|
|
||||||
|
if not os.environ.get(ENV_SALT):
|
||||||
|
raise click.ClickException(
|
||||||
|
'Environment variable {} is not defined or '
|
||||||
|
'is an empty string.'.format(ENV_SALT))
|
||||||
|
|
||||||
|
def encrypt_secrets(self, save_path, author):
|
||||||
|
"""
|
||||||
|
Wrap and encrypt the secrets documents included in the input file,
|
||||||
|
into pegleg manage secrets documents, and write the result in
|
||||||
|
save_path.
|
||||||
|
|
||||||
|
if save_path is the same as the source file_path the encrypted file
|
||||||
|
will overwrite the source file.
|
||||||
|
|
||||||
|
:param save_path: Destination path of the encrypted file
|
||||||
|
:type save_path: string
|
||||||
|
:param author: Identifier for the program or person who is
|
||||||
|
encrypting the secrets documents
|
||||||
|
:type author: string
|
||||||
|
"""
|
||||||
|
|
||||||
|
encrypted_docs = False
|
||||||
|
doc_list = []
|
||||||
|
for doc in self.documents:
|
||||||
|
# do not re-encrypt already encrypted data
|
||||||
|
if doc.is_encrypted():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# only encrypt if storagePolicy is set to encrypted.
|
||||||
|
if not doc.is_storage_policy_encrypted():
|
||||||
|
# case documents in a file have different storage
|
||||||
|
# policies
|
||||||
|
doc_list.append(doc.embedded_document)
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc.set_secret(
|
||||||
|
encrypt(doc.get_secret().encode(), self.passphrase, self.salt))
|
||||||
|
doc.set_encrypted(author)
|
||||||
|
encrypted_docs = True
|
||||||
|
doc_list.append(doc.pegleg_document)
|
||||||
|
if encrypted_docs:
|
||||||
|
files.write(save_path, doc_list)
|
||||||
|
LOG.info('Wrote data to: {}.'.format(save_path))
|
||||||
|
else:
|
||||||
|
LOG.debug('All documents in file: {} are either already encrypted '
|
||||||
|
'or have cleartext storage policy. '
|
||||||
|
'Skipping.'.format(self.file_path))
|
||||||
|
|
||||||
|
def decrypt_secrets(self):
|
||||||
|
"""Decrypt and unwrap pegleg managed encrypted secrets documents
|
||||||
|
included in a site secrets file, and print the result to the standard
|
||||||
|
out."""
|
||||||
|
|
||||||
|
doc_list = []
|
||||||
|
for doc in self.documents:
|
||||||
|
# only decrypt an encrypted document
|
||||||
|
if doc.is_encrypted():
|
||||||
|
doc.set_secret(
|
||||||
|
decrypt(doc.get_secret(),
|
||||||
|
self.passphrase,
|
||||||
|
self.salt).decode())
|
||||||
|
doc.set_decrypted()
|
||||||
|
doc_list.append(doc.embedded_document)
|
||||||
|
yaml.safe_dump_all(
|
||||||
|
doc_list,
|
||||||
|
sys.stdout,
|
||||||
|
explicit_start=True,
|
||||||
|
explicit_end=True,
|
||||||
|
default_flow_style=False)
|
@ -2,4 +2,5 @@ gitpython
|
|||||||
click==6.7
|
click==6.7
|
||||||
jsonschema==2.6.0
|
jsonschema==2.6.0
|
||||||
pyyaml==3.12
|
pyyaml==3.12
|
||||||
|
cryptography==2.3.1
|
||||||
git+https://github.com/openstack/airship-deckhand.git@7d697012fcbd868b14670aa9cf895acfad5a7f8d
|
git+https://github.com/openstack/airship-deckhand.git@7d697012fcbd868b14670aa9cf895acfad5a7f8d
|
||||||
|
94
tests/unit/engine/test_encryption.py
Normal file
94
tests/unit/engine/test_encryption.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Copyright 2018 AT&T Intellectual Property. All other rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
import click
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import mock
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from pegleg.engine.util import encryption as crypt
|
||||||
|
from tests.unit import test_utils
|
||||||
|
from pegleg.engine import secrets
|
||||||
|
from pegleg.engine.util.pegleg_managed_document import \
|
||||||
|
PeglegManagedSecretsDocument
|
||||||
|
from pegleg.engine.util.pegleg_secret_management import PeglegSecretManagement
|
||||||
|
from pegleg.engine.util.pegleg_secret_management import ENV_PASSPHRASE
|
||||||
|
from pegleg.engine.util.pegleg_secret_management import ENV_SALT
|
||||||
|
|
||||||
|
TEST_DATA = """
|
||||||
|
---
|
||||||
|
schema: deckhand/Passphrase/v1
|
||||||
|
metadata:
|
||||||
|
schema: metadata/Document/v1
|
||||||
|
name: osh_addons_keystone_ranger-agent_password
|
||||||
|
layeringDefinition:
|
||||||
|
abstract: false
|
||||||
|
layer: site
|
||||||
|
storagePolicy: encrypted
|
||||||
|
data: 512363f37eab654313991174aef9f867d
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_and_decrypt():
|
||||||
|
data = test_utils.rand_name("this is an example of un-encrypted "
|
||||||
|
"data.", "pegleg").encode()
|
||||||
|
passphrase = test_utils.rand_name("passphrase1", "pegleg").encode()
|
||||||
|
salt = test_utils.rand_name("salt1", "pegleg").encode()
|
||||||
|
enc1 = crypt.encrypt(data, passphrase, salt)
|
||||||
|
dec1 = crypt.decrypt(enc1, passphrase, salt)
|
||||||
|
assert data == dec1
|
||||||
|
enc2 = crypt.encrypt(dec1, passphrase, salt)
|
||||||
|
dec2 = crypt.decrypt(enc2, passphrase, salt)
|
||||||
|
assert data == dec2
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {ENV_PASSPHRASE:'aShortPassphrase',
|
||||||
|
ENV_SALT: 'MySecretSalt'})
|
||||||
|
def test_short_passphrase():
|
||||||
|
with pytest.raises(click.ClickException,
|
||||||
|
match=r'.*is not at least 24-character long.*'):
|
||||||
|
PeglegSecretManagement('file_path')
|
||||||
|
|
||||||
|
|
||||||
|
def test_PeglegManagedDocument():
|
||||||
|
test_data = yaml.load(TEST_DATA)
|
||||||
|
doc = PeglegManagedSecretsDocument(test_data)
|
||||||
|
assert doc.is_storage_policy_encrypted() is True
|
||||||
|
assert doc.is_encrypted() is False
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {ENV_PASSPHRASE:'ytrr89erARAiPE34692iwUMvWqqBvC',
|
||||||
|
ENV_SALT: 'MySecretSalt'})
|
||||||
|
def test_encrypt_document():
|
||||||
|
# write the test data to temp file
|
||||||
|
test_data = yaml.load(TEST_DATA)
|
||||||
|
dir = tempfile.mkdtemp()
|
||||||
|
file_path = os.path.join(dir, 'secrets_file.yaml')
|
||||||
|
save_path = os.path.join(dir, 'encrypted_secrets_file.yaml')
|
||||||
|
with open(file_path, 'w') as stream:
|
||||||
|
yaml.dump(test_data,
|
||||||
|
stream,
|
||||||
|
explicit_start=True,
|
||||||
|
explicit_end=True,
|
||||||
|
default_flow_style=False)
|
||||||
|
# read back the secrets data file and encrypt it
|
||||||
|
doc_mgr = PeglegSecretManagement(file_path)
|
||||||
|
doc_mgr.encrypt_secrets(save_path, 'test_author')
|
||||||
|
doc = doc_mgr.documents[0]
|
||||||
|
assert doc.is_encrypted()
|
||||||
|
assert doc.data['encrypted']['by'] == 'test_author'
|
@ -20,5 +20,7 @@ docker run --rm $TERM_OPTS \
|
|||||||
--workdir="$container_workspace_path" \
|
--workdir="$container_workspace_path" \
|
||||||
-v "${HOME}/.ssh:${container_workspace_path}/.ssh" \
|
-v "${HOME}/.ssh:${container_workspace_path}/.ssh" \
|
||||||
-v "${WORKSPACE}:$container_workspace_path" \
|
-v "${WORKSPACE}:$container_workspace_path" \
|
||||||
|
-e "PEGLEG_PASSPHRASE=$PEGLEG_PASSPHRASE" \
|
||||||
|
-e "PEGLEG_SALT=$PEGLEG_SALT" \
|
||||||
"${IMAGE}" \
|
"${IMAGE}" \
|
||||||
pegleg "${@}"
|
pegleg "${@}"
|
||||||
|
2
tox.ini
2
tox.ini
@ -9,7 +9,7 @@ skipsdist = True
|
|||||||
setenv = VIRTUAL_ENV={envdir}
|
setenv = VIRTUAL_ENV={envdir}
|
||||||
LANGUAGE=en_US
|
LANGUAGE=en_US
|
||||||
LC_ALL=en_US.utf-8
|
LC_ALL=en_US.utf-8
|
||||||
passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY
|
passenv = http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements.txt
|
-r{toxinidir}/requirements.txt
|
||||||
-r{toxinidir}/test-requirements.txt
|
-r{toxinidir}/test-requirements.txt
|
||||||
|
Loading…
x
Reference in New Issue
Block a user