diff --git a/pegleg/engine/catalogs/passphrase_catalog.py b/pegleg/engine/catalogs/passphrase_catalog.py index 906271cb..f3cfebcb 100644 --- a/pegleg/engine/catalogs/passphrase_catalog.py +++ b/pegleg/engine/catalogs/passphrase_catalog.py @@ -15,6 +15,7 @@ import logging from pegleg.engine.catalogs.base_catalog import BaseCatalog +from pegleg.engine.catalogs import passphrase_profiles from pegleg.engine import exceptions LOG = logging.getLogger(__name__) @@ -34,6 +35,8 @@ P_DEFAULT_REGENERABLE = True P_DEFAULT_PROMPT = False VALID_PASSPHRASE_TYPES = ['passphrase', 'base64', 'uuid'] VALID_BOOLEAN_FIELDS = [True, False] +P_PROFILE = 'profile' +P_DEFAULT_PROFILE = 'default' __all__ = ['PassphraseCatalog'] @@ -169,3 +172,25 @@ class PassphraseCatalog(BaseCatalog): validvalues=VALID_BOOLEAN_FIELDS) else: return passphrase_prompt + + def get_passphrase_profile(self, passphrase_name): + """Return the profile field of the ``passphrase_name``. + + Determine which profile this passphrase should use when selecting + the pool to generate passphrase from. + If no option is specified, use default profile. See + pegleg.engine.catalogs.passphrase_profiles for default and valid + options. + """ + + for c_doc in self._catalog_docs: + for passphrase in c_doc['data']['passphrases']: + if passphrase[P_DOCUMENT_NAME] == passphrase_name: + profile = passphrase.get(P_PROFILE, + P_DEFAULT_PROFILE).lower() + if profile not in passphrase_profiles.VALID_PROFILES: + raise exceptions.InvalidPassphraseProfile( + pprofile=profile, + validvalues=passphrase_profiles.VALID_PROFILES) + else: + return profile diff --git a/pegleg/engine/catalogs/passphrase_profiles.py b/pegleg/engine/catalogs/passphrase_profiles.py new file mode 100644 index 00000000..97c01484 --- /dev/null +++ b/pegleg/engine/catalogs/passphrase_profiles.py @@ -0,0 +1,27 @@ +# Copyright 2019 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 string + +PROFILES = { + 'default': string.ascii_letters + string.digits + '@#&-+=?', + 'alphanumeric': string.ascii_letters + string.digits, + 'alphanumeric_lower': string.ascii_lowercase + string.digits, + 'alphanumeric_upper': string.ascii_uppercase + string.digits, + 'all': string.ascii_letters + string.digits + string.punctuation, + 'hex_lower': 'abcdef0123456789', + 'hex_upper': 'ABCDEF0123456789' +} + +VALID_PROFILES = PROFILES.keys() diff --git a/pegleg/engine/exceptions.py b/pegleg/engine/exceptions.py index 6265b55b..068f358b 100644 --- a/pegleg/engine/exceptions.py +++ b/pegleg/engine/exceptions.py @@ -113,6 +113,13 @@ class InvalidPassphraseRegeneration(PeglegBaseException): 'values are: %(validvalues)s.') +class InvalidPassphraseProfile(PeglegBaseException): + """Invalid Passphrase profile field""" + message = ( + 'Invalid Passphrase profile %(pprofile)s specified. ' + 'Valid values are: %(validvalues)s.') + + class GenesisBundleEncryptionException(PeglegBaseException): """Exception raised when encryption of the genesis bundle fails.""" diff --git a/pegleg/engine/generators/passphrase_generator.py b/pegleg/engine/generators/passphrase_generator.py index 8adee2d0..4d2f79ee 100644 --- a/pegleg/engine/generators/passphrase_generator.py +++ b/pegleg/engine/generators/passphrase_generator.py @@ -54,7 +54,6 @@ class PassphraseGenerator(BaseGenerator): self).__init__(sitename, save_location, author) self._catalog = PassphraseCatalog( self._sitename, documents=self._documents) - self._pass_util = CryptoString() def generate(self, interactive=False, force_cleartext=False): """ @@ -80,6 +79,7 @@ class PassphraseGenerator(BaseGenerator): passphrase = None passphrase_type = self._catalog.get_passphrase_type(p_name) prompt = self._catalog.is_passphrase_prompt(p_name) + profile = self._catalog.get_passphrase_profile(p_name) if interactive and prompt: auto_allowed = regenerable @@ -111,7 +111,7 @@ class PassphraseGenerator(BaseGenerator): if passphrase_type == 'uuid': # nosec passphrase = uuidutils.generate_uuid() else: - passphrase = self._pass_util.get_crypto_string( + passphrase = CryptoString(profile).get_crypto_string( self._catalog.get_length(p_name)) if passphrase_type == 'base64': # nosec # Take the randomly generated string and convert to a diff --git a/pegleg/engine/util/cryptostring.py b/pegleg/engine/util/cryptostring.py index d908ab89..9e90b723 100644 --- a/pegleg/engine/util/cryptostring.py +++ b/pegleg/engine/util/cryptostring.py @@ -15,14 +15,30 @@ import random import string +from pegleg.engine.catalogs import passphrase_profiles +from pegleg.engine import exceptions + __all__ = ['CryptoString'] class CryptoString(object): - def __init__(self): - punctuation = '@#&-+=?' - self._pool = string.ascii_letters + string.digits + punctuation + def __init__(self, profile=None): + if profile and profile.lower() in passphrase_profiles.VALID_PROFILES: + self._pool = passphrase_profiles.PROFILES[profile.lower()] + elif profile: + raise exceptions.InvalidPassphraseProfile( + pprofile=profile.lower(), + validvalues=passphrase_profiles.VALID_PROFILES) + else: + self._pool = passphrase_profiles.PROFILES['default'] self._random = random.SystemRandom() + self.determine_char_sets() + + def determine_char_sets(self): + self.test_upper = self.has_upper(self._pool) + self.test_lower = self.has_lower(self._pool) + self.test_number = self.has_number(self._pool) + self.test_symbol = self.has_symbol(self._pool) def has_upper(self, crypto_str): """Check if string contains an uppercase letter @@ -69,13 +85,20 @@ class CryptoString(object): :param str crypto_str: The string to test. :returns: True if string contains at least one each: uppercase letter, - lowercase letter, number and symbol + lowercase letter, number and symbol if that character set is + present in the original passphrase pool. :rtype: boolean """ - for test in [self.has_upper, self.has_lower, self.has_number, - self.has_symbol]: - if not test(crypto_str): + test_cases = [ + self.test_upper, self.test_lower, self.test_number, + self.test_symbol + ] + tests = [ + self.has_upper, self.has_lower, self.has_number, self.has_symbol + ] + for (test_case, test) in zip(test_cases, tests): + if test_case and not test(crypto_str): return False return True diff --git a/tests/unit/engine/test_generate_passphrases.py b/tests/unit/engine/test_generate_passphrases.py index 22b0a41a..29fec0a6 100644 --- a/tests/unit/engine/test_generate_passphrases.py +++ b/tests/unit/engine/test_generate_passphrases.py @@ -119,6 +119,50 @@ data: ... """) +TEST_PROFILES_CATALOG = yaml.safe_load( + """ +--- +schema: pegleg/PassphraseCatalog/v1 +metadata: + schema: metadata/Document/v1 + name: cluster-passphrases + layeringDefinition: + abstract: false + layer: global + storagePolicy: cleartext +data: + passphrases: + - description: 'default profile' + document_name: default_passphrase + encrypted: true + profile: default + - description: 'alphanumeric profile' + document_name: alphanumeric_passphrase + encrypted: true + profile: alphanumeric + - description: 'alphanumeric_lower profile' + document_name: alphanumeric_lower_passphrase + encrypted: true + profile: alphanumeric_lower + - description: 'alphanumeric_upper profile' + document_name: alphanumeric_upper_passphrase + encrypted: true + profile: alphanumeric_upper + - description: 'all profile' + document_name: all_passphrase + encrypted: true + profile: all + - description: 'hex_lower profile' + document_name: hex_lower_passphrase + encrypted: true + profile: hex_lower + - description: 'hex_upper profile' + document_name: hex_upper_passphrase + encrypted: true + profile: hex_upper +... +""") + TEST_REPOSITORIES = { 'repositories': { 'global': { @@ -156,6 +200,7 @@ TEST_GLOBAL_SITE_DOCUMENTS = [ TEST_SITE_DEFINITION, TEST_GLOBAL_PASSPHRASES_CATALOG ] TEST_TYPE_SITE_DOCUMENTS = [TEST_SITE_DEFINITION, TEST_TYPES_CATALOG] +TEST_PROFILES_SITE_DOCUMENTS = [TEST_SITE_DEFINITION, TEST_PROFILES_CATALOG] @mock.patch.object( @@ -329,3 +374,93 @@ def test_uuid_passphrase_catalog(*_): os.environ['PEGLEG_SALT'].encode()) if passphrase_file_name == "uuid_passphrase_doc.yaml": assert uuid.UUID(decrypted_passphrase.decode()).version == 4 + + +@mock.patch.object( + util.definition, + 'documents_for_site', + autospec=True, + return_value=TEST_PROFILES_SITE_DOCUMENTS) +@mock.patch.object( + pegleg.config, + 'get_site_repo', + autospec=True, + return_value='cicd_site_repo') +@mock.patch.object( + util.definition, + 'site_files', + autospec=True, + return_value=[ + 'cicd_global_repo/site/cicd/passphrases/passphrase-catalog.yaml', + ]) +@mock.patch.dict( + os.environ, { + 'PEGLEG_PASSPHRASE': 'ytrr89erARAiPE34692iwUMvWqqBvC', + 'PEGLEG_SALT': 'MySecretSalt1234567890][' + }) +def test_profiles_catalog(*_): + _dir = tempfile.mkdtemp() + os.makedirs(os.path.join(_dir, 'cicd_site_repo'), exist_ok=True) + PassphraseGenerator('cicd', _dir, 'test_author').generate() + s_util = CryptoString() + + for passphrase in TEST_PROFILES_CATALOG['data']['passphrases']: + passphrase_file_name = '{}.yaml'.format(passphrase['document_name']) + passphrase_file_path = os.path.join( + _dir, 'site', 'cicd', 'secrets', 'passphrases', + passphrase_file_name) + assert os.path.isfile(passphrase_file_path) + with open(passphrase_file_path) as stream: + doc = yaml.safe_load(stream) + decrypted_passphrase = encryption.decrypt( + doc['data']['managedDocument']['data'], + os.environ['PEGLEG_PASSPHRASE'].encode(), + os.environ['PEGLEG_SALT'].encode()).decode() + assert len(decrypted_passphrase) == 24 + if passphrase_file_name == "default_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is True + assert s_util.has_upper(decrypted_passphrase) is True + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is True + bad_symbols = any( + char in '!"$%()*,./:;<>[]^_`{|}~\'' + for char in decrypted_passphrase) + assert not bad_symbols + elif passphrase_file_name == "alphanumeric_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is True + assert s_util.has_upper(decrypted_passphrase) is True + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is False + elif passphrase_file_name == "alphanumeric_lower_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is True + assert s_util.has_upper(decrypted_passphrase) is False + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is False + elif passphrase_file_name == "alphanumeric_upper_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is False + assert s_util.has_upper(decrypted_passphrase) is True + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is False + elif passphrase_file_name == "all_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is True + assert s_util.has_upper(decrypted_passphrase) is True + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is True + elif passphrase_file_name == "hex_lower_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is True + assert s_util.has_upper(decrypted_passphrase) is False + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is False + bad_letters = any( + char in 'ghijklmnopqrstuvwxyz' + for char in decrypted_passphrase) + assert not bad_letters + elif passphrase_file_name == "hex_upper_passphrase.yaml": + assert s_util.has_lower(decrypted_passphrase) is False + assert s_util.has_upper(decrypted_passphrase) is True + assert s_util.has_number(decrypted_passphrase) is True + assert s_util.has_symbol(decrypted_passphrase) is False + bad_letters = any( + char in 'GHIJKLMNOPQRSTUVWXYZ' + for char in decrypted_passphrase) + assert not bad_letters diff --git a/tests/unit/engine/util/test_cryptostring.py b/tests/unit/engine/util/test_cryptostring.py index e3dfd5d4..1615fed4 100644 --- a/tests/unit/engine/util/test_cryptostring.py +++ b/tests/unit/engine/util/test_cryptostring.py @@ -93,3 +93,73 @@ def test_cryptostring_has_all(): assert s_util.validate_crypto_str(crypto_string) is False crypto_string = 'ThisPasswordH4sNoSymbols' assert s_util.validate_crypto_str(crypto_string) is False + + +def test_cryptostring_default_profile(): + s_util = CryptoString(profile='default') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is True + assert s_util.has_upper(crypto_string) is True + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is True + bad_symbols = any( + char in '!"$%()*,./:;<>[]^_`{|}~\'' for char in crypto_string) + assert not bad_symbols + + +def test_cryptostring_alphanumeric_profile(): + s_util = CryptoString(profile='alphanumeric') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is True + assert s_util.has_upper(crypto_string) is True + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is False + + +def test_cryptostring_alphanumeric_lower_profile(): + s_util = CryptoString(profile='alphanumeric_lower') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is True + assert s_util.has_upper(crypto_string) is False + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is False + + +def test_cryptostring_alphanumeric_upper_profile(): + s_util = CryptoString(profile='alphanumeric_upper') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is False + assert s_util.has_upper(crypto_string) is True + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is False + + +def test_cryptostring_all_profile(): + s_util = CryptoString(profile='all') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is True + assert s_util.has_upper(crypto_string) is True + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is True + + +def test_cryptostring_hex_lower_profile(): + s_util = CryptoString(profile='hex_lower') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is True + assert s_util.has_upper(crypto_string) is False + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is False + bad_letters = any(char in 'ghijklmnopqrstuvwxyz' for char in crypto_string) + assert not bad_letters + + +def test_cryptostring_hex_upper_profile(): + s_util = CryptoString(profile='hex_upper') + crypto_string = s_util.get_crypto_string() + assert s_util.has_lower(crypto_string) is False + assert s_util.has_upper(crypto_string) is True + assert s_util.has_number(crypto_string) is True + assert s_util.has_symbol(crypto_string) is False + bad_letters = any(char in 'GHIJKLMNOPQRSTUVWXYZ' for char in crypto_string) + assert not bad_letters