Add profiles to passphrase catalog
Change-Id: Id6e7cddd123e31f0df963167ddf3fa8f33e9060c
This commit is contained in:
parent
ee2b822a6d
commit
14f8600e37
|
@ -15,6 +15,7 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pegleg.engine.catalogs.base_catalog import BaseCatalog
|
from pegleg.engine.catalogs.base_catalog import BaseCatalog
|
||||||
|
from pegleg.engine.catalogs import passphrase_profiles
|
||||||
from pegleg.engine import exceptions
|
from pegleg.engine import exceptions
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
@ -34,6 +35,8 @@ P_DEFAULT_REGENERABLE = True
|
||||||
P_DEFAULT_PROMPT = False
|
P_DEFAULT_PROMPT = False
|
||||||
VALID_PASSPHRASE_TYPES = ['passphrase', 'base64', 'uuid']
|
VALID_PASSPHRASE_TYPES = ['passphrase', 'base64', 'uuid']
|
||||||
VALID_BOOLEAN_FIELDS = [True, False]
|
VALID_BOOLEAN_FIELDS = [True, False]
|
||||||
|
P_PROFILE = 'profile'
|
||||||
|
P_DEFAULT_PROFILE = 'default'
|
||||||
|
|
||||||
__all__ = ['PassphraseCatalog']
|
__all__ = ['PassphraseCatalog']
|
||||||
|
|
||||||
|
@ -169,3 +172,25 @@ class PassphraseCatalog(BaseCatalog):
|
||||||
validvalues=VALID_BOOLEAN_FIELDS)
|
validvalues=VALID_BOOLEAN_FIELDS)
|
||||||
else:
|
else:
|
||||||
return passphrase_prompt
|
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
|
||||||
|
|
|
@ -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()
|
|
@ -113,6 +113,13 @@ class InvalidPassphraseRegeneration(PeglegBaseException):
|
||||||
'values are: %(validvalues)s.')
|
'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):
|
class GenesisBundleEncryptionException(PeglegBaseException):
|
||||||
"""Exception raised when encryption of the genesis bundle fails."""
|
"""Exception raised when encryption of the genesis bundle fails."""
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,6 @@ class PassphraseGenerator(BaseGenerator):
|
||||||
self).__init__(sitename, save_location, author)
|
self).__init__(sitename, save_location, author)
|
||||||
self._catalog = PassphraseCatalog(
|
self._catalog = PassphraseCatalog(
|
||||||
self._sitename, documents=self._documents)
|
self._sitename, documents=self._documents)
|
||||||
self._pass_util = CryptoString()
|
|
||||||
|
|
||||||
def generate(self, interactive=False, force_cleartext=False):
|
def generate(self, interactive=False, force_cleartext=False):
|
||||||
"""
|
"""
|
||||||
|
@ -80,6 +79,7 @@ class PassphraseGenerator(BaseGenerator):
|
||||||
passphrase = None
|
passphrase = None
|
||||||
passphrase_type = self._catalog.get_passphrase_type(p_name)
|
passphrase_type = self._catalog.get_passphrase_type(p_name)
|
||||||
prompt = self._catalog.is_passphrase_prompt(p_name)
|
prompt = self._catalog.is_passphrase_prompt(p_name)
|
||||||
|
profile = self._catalog.get_passphrase_profile(p_name)
|
||||||
if interactive and prompt:
|
if interactive and prompt:
|
||||||
auto_allowed = regenerable
|
auto_allowed = regenerable
|
||||||
|
|
||||||
|
@ -111,7 +111,7 @@ class PassphraseGenerator(BaseGenerator):
|
||||||
if passphrase_type == 'uuid': # nosec
|
if passphrase_type == 'uuid': # nosec
|
||||||
passphrase = uuidutils.generate_uuid()
|
passphrase = uuidutils.generate_uuid()
|
||||||
else:
|
else:
|
||||||
passphrase = self._pass_util.get_crypto_string(
|
passphrase = CryptoString(profile).get_crypto_string(
|
||||||
self._catalog.get_length(p_name))
|
self._catalog.get_length(p_name))
|
||||||
if passphrase_type == 'base64': # nosec
|
if passphrase_type == 'base64': # nosec
|
||||||
# Take the randomly generated string and convert to a
|
# Take the randomly generated string and convert to a
|
||||||
|
|
|
@ -15,14 +15,30 @@
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
|
||||||
|
from pegleg.engine.catalogs import passphrase_profiles
|
||||||
|
from pegleg.engine import exceptions
|
||||||
|
|
||||||
__all__ = ['CryptoString']
|
__all__ = ['CryptoString']
|
||||||
|
|
||||||
|
|
||||||
class CryptoString(object):
|
class CryptoString(object):
|
||||||
def __init__(self):
|
def __init__(self, profile=None):
|
||||||
punctuation = '@#&-+=?'
|
if profile and profile.lower() in passphrase_profiles.VALID_PROFILES:
|
||||||
self._pool = string.ascii_letters + string.digits + punctuation
|
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._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):
|
def has_upper(self, crypto_str):
|
||||||
"""Check if string contains an uppercase letter
|
"""Check if string contains an uppercase letter
|
||||||
|
@ -69,13 +85,20 @@ class CryptoString(object):
|
||||||
|
|
||||||
:param str crypto_str: The string to test.
|
:param str crypto_str: The string to test.
|
||||||
:returns: True if string contains at least one each: uppercase letter,
|
: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
|
:rtype: boolean
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for test in [self.has_upper, self.has_lower, self.has_number,
|
test_cases = [
|
||||||
self.has_symbol]:
|
self.test_upper, self.test_lower, self.test_number,
|
||||||
if not test(crypto_str):
|
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 False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -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 = {
|
TEST_REPOSITORIES = {
|
||||||
'repositories': {
|
'repositories': {
|
||||||
'global': {
|
'global': {
|
||||||
|
@ -156,6 +200,7 @@ TEST_GLOBAL_SITE_DOCUMENTS = [
|
||||||
TEST_SITE_DEFINITION, TEST_GLOBAL_PASSPHRASES_CATALOG
|
TEST_SITE_DEFINITION, TEST_GLOBAL_PASSPHRASES_CATALOG
|
||||||
]
|
]
|
||||||
TEST_TYPE_SITE_DOCUMENTS = [TEST_SITE_DEFINITION, TEST_TYPES_CATALOG]
|
TEST_TYPE_SITE_DOCUMENTS = [TEST_SITE_DEFINITION, TEST_TYPES_CATALOG]
|
||||||
|
TEST_PROFILES_SITE_DOCUMENTS = [TEST_SITE_DEFINITION, TEST_PROFILES_CATALOG]
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
|
@ -329,3 +374,93 @@ def test_uuid_passphrase_catalog(*_):
|
||||||
os.environ['PEGLEG_SALT'].encode())
|
os.environ['PEGLEG_SALT'].encode())
|
||||||
if passphrase_file_name == "uuid_passphrase_doc.yaml":
|
if passphrase_file_name == "uuid_passphrase_doc.yaml":
|
||||||
assert uuid.UUID(decrypted_passphrase.decode()).version == 4
|
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
|
||||||
|
|
|
@ -93,3 +93,73 @@ def test_cryptostring_has_all():
|
||||||
assert s_util.validate_crypto_str(crypto_string) is False
|
assert s_util.validate_crypto_str(crypto_string) is False
|
||||||
crypto_string = 'ThisPasswordH4sNoSymbols'
|
crypto_string = 'ThisPasswordH4sNoSymbols'
|
||||||
assert s_util.validate_crypto_str(crypto_string) is False
|
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
|
||||||
|
|
Loading…
Reference in New Issue