From 9670f95f69f9a7bb81e568ee486de55b1941ced2 Mon Sep 17 00:00:00 2001 From: Guillaume Vincent Date: Thu, 16 Jul 2020 22:58:39 +0200 Subject: [PATCH] Introduce --exclude parameter in cli Allow user to exclude some parameters. Fixes https://github.com/lesspass/lesspass/issues/528 --- cli/README.md | 4 +++- cli/lesspass/cli.py | 5 +++++ cli/lesspass/core.py | 13 +++++++++---- cli/lesspass/exceptions.py | 2 ++ cli/lesspass/password.py | 29 +++++++++++++++++++++-------- cli/lesspass/profile.py | 1 + cli/lesspass/version.py | 2 +- cli/tests/test_cli.py | 9 +++++++++ cli/tests/test_functional.py | 18 +++++++++++++++++- cli/tests/test_password_generation.py | 6 ++++++ cli/tests/test_profile.py | 1 + 11 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 cli/lesspass/exceptions.py diff --git a/cli/README.md b/cli/README.md index 34634d2..7100dbf 100644 --- a/cli/README.md +++ b/cli/README.md @@ -29,11 +29,13 @@ --no-uppercase remove uppercase from password --no-digits remove digits from password --no-symbols remove symbols from password + --exclude remove chars from password -c, --clipboard copy generated password to clipboard rather than displaying it. - Need pbcopy (OSX), xsel or xclip (Linux) or clip (Windows). + Need pbcopy (OSX), xsel or xclip (Linux) or clip (Windows). -v, --version lesspass version number ## Examples + ### no symbols lesspass site login masterpassword --no-symbols diff --git a/cli/lesspass/cli.py b/cli/lesspass/cli.py index e9d5ad1..a320149 100644 --- a/cli/lesspass/cli.py +++ b/cli/lesspass/cli.py @@ -81,6 +81,11 @@ def parse_args(args): action="store_true", help="copy the password to clipboard", ) + parser.add_argument( + "--exclude", + default=None, + help="exclude char from generated password", + ) lowercase_group = parser.add_mutually_exclusive_group() lowercase_group.add_argument( diff --git a/cli/lesspass/core.py b/cli/lesspass/core.py index ed1d5e1..d1e65e3 100644 --- a/cli/lesspass/core.py +++ b/cli/lesspass/core.py @@ -4,6 +4,7 @@ import sys import traceback import signal +from lesspass import exceptions from lesspass.version import __version__ from lesspass.cli import parse_args from lesspass.profile import create_profile @@ -17,7 +18,7 @@ def main(args=sys.argv[1:]): args = parse_args(args) if args.clipboard and not get_system_copy_command(): print( - "ERROR To use the option -c (--copy) you need pbcopy on OSX, " + "error: To use the option -c (--copy) you need pbcopy on OSX, " + "xsel, xclip, or wl-clipboard on Linux, and clip on Windows" ) sys.exit(3) @@ -32,15 +33,19 @@ def main(args=sys.argv[1:]): args.master_password = getpass.getpass("Master Password: ") if not args.site: - print("ERROR argument SITE is required but was not provided.") + print("error: argument SITE is required but was not provided.") sys.exit(4) if not args.master_password: - print("ERROR argument MASTER_PASSWORD is required but was not provided") + print("error: argument MASTER_PASSWORD is required but was not provided") sys.exit(5) profile, master_password = create_profile(args) - generated_password = generate_password(profile, master_password) + try: + generated_password = generate_password(profile, master_password) + except exceptions.ExcludeAllCharsAvailable: + print("error: you can't exclude all chars available") + sys.exit(6) if args.clipboard: try: diff --git a/cli/lesspass/exceptions.py b/cli/lesspass/exceptions.py new file mode 100644 index 0000000..5dd8c39 --- /dev/null +++ b/cli/lesspass/exceptions.py @@ -0,0 +1,2 @@ +class ExcludeAllCharsAvailable(Exception): + pass diff --git a/cli/lesspass/password.py b/cli/lesspass/password.py index 6f056c5..44b5bd7 100644 --- a/cli/lesspass/password.py +++ b/cli/lesspass/password.py @@ -20,6 +20,8 @@ import hashlib +from lesspass import exceptions + CHARACTER_SUBSETS = { "lowercase": "abcdefghijklmnopqrstuvwxyz", "uppercase": "ABCDEFGHIJKLMNOPQRSTUVWXYZ", @@ -40,7 +42,14 @@ def _calc_entropy(password_profile, master_password): return int(hex_entropy, 16) -def _get_set_of_characters(rules=None): +def _remove_excluded_chars(string, exclude): + new_string = "".join(c for c in string if c not in exclude) + if len(new_string) == 0: + raise exceptions.ExcludeAllCharsAvailable + return new_string + + +def _get_set_of_characters(rules=None, exclude=""): if rules is None: return ( CHARACTER_SUBSETS["lowercase"] @@ -48,10 +57,10 @@ def _get_set_of_characters(rules=None): + CHARACTER_SUBSETS["digits"] + CHARACTER_SUBSETS["symbols"] ) - set_of_chars = "" + pool_of_chars = "" for rule in rules: - set_of_chars += CHARACTER_SUBSETS[rule] - return set_of_chars + pool_of_chars += CHARACTER_SUBSETS[rule] + return _remove_excluded_chars(pool_of_chars, exclude) def _consume_entropy(generated_password, quotient, set_of_characters, max_length): @@ -72,10 +81,11 @@ def _insert_string_pseudo_randomly(generated_password, entropy, string): return generated_password -def _get_one_char_per_rule(entropy, rules): +def _get_one_char_per_rule(entropy, rules, exclude=""): one_char_per_rules = "" for rule in rules: - value, entropy = _consume_entropy("", entropy, CHARACTER_SUBSETS[rule], 1) + available_chars = _remove_excluded_chars(CHARACTER_SUBSETS[rule], exclude) + value, entropy = _consume_entropy("", entropy, available_chars, 1) one_char_per_rules += value return [one_char_per_rules, entropy] @@ -89,12 +99,15 @@ def _get_configured_rules(password_profile): def _render_password(entropy, password_profile): rules = _get_configured_rules(password_profile) - set_of_characters = _get_set_of_characters(rules) + excluded_chars = ( + password_profile["exclude"] if "exclude" in password_profile else "" + ) + set_of_characters = _get_set_of_characters(rules, excluded_chars) password, password_entropy = _consume_entropy( "", entropy, set_of_characters, password_profile["length"] - len(rules) ) characters_to_add, character_entropy = _get_one_char_per_rule( - password_entropy, rules + password_entropy, rules, excluded_chars ) return _insert_string_pseudo_randomly( password, character_entropy, characters_to_add diff --git a/cli/lesspass/profile.py b/cli/lesspass/profile.py index 714e6d5..1c92d19 100644 --- a/cli/lesspass/profile.py +++ b/cli/lesspass/profile.py @@ -8,6 +8,7 @@ def create_profile(args): "counter": args.counter, "site": args.site, "login": args.login or "", + "exclude": args.exclude or "", } if args.l or args.u or args.d or args.s: profile["lowercase"] = args.l diff --git a/cli/lesspass/version.py b/cli/lesspass/version.py index 4fe0bdd..62ad3ee 100644 --- a/cli/lesspass/version.py +++ b/cli/lesspass/version.py @@ -1 +1 @@ -__version__ = "9.1.9" +__version__ = "9.2.0" diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 0a00118..6b84851 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -105,3 +105,12 @@ class TestParseArgs(unittest.TestCase): def test_parse_args_prompt_short(self): self.assertTrue(parse_args(["-p"]).prompt) + + def test_parse_args_exclude_default(self): + self.assertEqual(parse_args(["site"]).exclude, None) + + def test_parse_args_exclude(self): + self.assertEqual(parse_args(["site", "--exclude", "!@$*+-"]).exclude, "!@$*+-") + + def test_parse_args_exclude_single_and_double_quote(self): + self.assertEqual(parse_args(["site", "--exclude", "\"'"]).exclude, "\"'") diff --git a/cli/tests/test_functional.py b/cli/tests/test_functional.py index 0bd6498..5ccad7c 100644 --- a/cli/tests/test_functional.py +++ b/cli/tests/test_functional.py @@ -21,4 +21,20 @@ class TestFunctional(unittest.TestCase): self.assertEqual(range_type('5'), 5) self.assertEqual(range_type('35'), 35) with self.assertRaises(argparse.ArgumentTypeError): - range_type('2') \ No newline at end of file + range_type('2') + + def test_exclude(self): + p = pexpect.spawn( + 'python3 lesspass/core.py site login masterpassword --exclude "!@$*+-8"' + ) + output = p.read().decode() + for c in "!@$*+-8": + self.assertTrue(c not in output) + + def test_exclude(self): + p = pexpect.spawn( + 'python3 lesspass/core.py site login masterpassword -d -L6 --exclude "0123456789"' + ) + output = p.read().decode() + + self.assertTrue("error: you can't exclude all chars available" in output) diff --git a/cli/tests/test_password_generation.py b/cli/tests/test_password_generation.py index 809c66b..4d2e532 100644 --- a/cli/tests/test_password_generation.py +++ b/cli/tests/test_password_generation.py @@ -154,6 +154,12 @@ class TestPassword(unittest.TestCase): "abcdefghijklmnopqrstuvwxyz0123456789", ) + def test_get_set_of_characters_with_several_rules_and_exclude(self): + self.assertEqual( + password._get_set_of_characters(["lowercase", "digits"], 'iy4!'), + "abcdefghjklmnopqrstuvwxz012356789", + ) + def test_consume_entropy(self): entropy = b"dc33d431bce2b01182c613382483ccdb0e2f66482cbba5e9d07dab34acc7eb1e" diff --git a/cli/tests/test_profile.py b/cli/tests/test_profile.py index e243676..b36e199 100644 --- a/cli/tests/test_profile.py +++ b/cli/tests/test_profile.py @@ -15,6 +15,7 @@ class TestProfile(unittest.TestCase): self.assertEqual(profile["counter"], 1) self.assertEqual(profile["site"], "site") self.assertEqual(profile["login"], "login") + self.assertEqual(profile["exclude"], "") self.assertIsNone(master_password) def test_create_profile_login(self):