diff --git a/README.md b/README.md index df67645..3d1d219 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Hush does not require a master password as other password managers do, instead i #### What's new? +- Version 202001.2 (Jan 17, 2020) + + - Added command to initialize the default config + - Version 202001.1 (Jan 16, 2020) - Python 3.8 compatibility @@ -62,8 +66,4 @@ push decrypt password.txt | clip ### configuration -hush requires to know where to find the private and public key files, these locations can be provided either as parameters or set as environment variables. For more options, run - -``` -hush --help -``` +You can pass the options through the command line parameters or use the configuration. `hush init` creates the default configuration, you will still need to provide the private and public RSA keys though. If you don't have them, you can run `hush keygen` to generate a pair. diff --git a/hush/console.py b/hush/console.py index 1047eb4..08b1c6a 100644 --- a/hush/console.py +++ b/hush/console.py @@ -8,6 +8,10 @@ from . import keypairs, passwords, secrets +DEFAULT_CONFIG_FILE = "~/.hush" +DEFAULT_PASSWORD_LENGTH = 8 +DEFAULT_CHARACTER_CLASSES = "aA8#" + class Context: def __init__(self): @@ -28,32 +32,45 @@ def configuration(config_file): def config_callback(ctx, param, value): + null_config = { + "generate.length": DEFAULT_PASSWORD_LENGTH, + "generate.character_classes": DEFAULT_CHARACTER_CLASSES, + } section = ctx.command.name key = param.name + config_key = f"{section}.{key}" if not value: with configuration(ctx.obj.config_file) as conf: if param.type.name == "integer": - value = conf.getint(section, key) + value = ( + conf.getint(section, key) + if conf.has_option(section, key) + else None + ) else: - value = conf.get(section, key) + value = ( + conf.get(section, key) + if conf.has_option(section, key) + else None + ) if not value: - value = param.default + value = null_config.get(config_key, None) if not value: raise click.UsageError( - f"{section}.{key} is missing. " - f"Either set it in the config or pass as an option" + f"{config_key} is missing. " + f"Either set it in the config or supply as an option" ) return value @click.group() -@click.version_option("202001.1") +@click.version_option("202001.2") @click.option( "-c", "--config-file", type=str, default="~/.hush", - help="Config file name, default '~/.hush' ", + help=f"Config file name [default: {DEFAULT_CONFIG_FILE}] ", ) @pass_context def cli(ctx, config_file): @@ -123,7 +140,7 @@ def decrypt(ctx, private_key_file, ask_passphrase, passphrase, file): "-l", "--length", type=int, - help="Password Length", + help=f"Password Length [default: {DEFAULT_PASSWORD_LENGTH}]", callback=config_callback, ) @click.option( @@ -131,7 +148,7 @@ def decrypt(ctx, private_key_file, ask_passphrase, passphrase, file): "-c", type=str, callback=config_callback, - help="Character classes", + help=f"Character classes [default: {DEFAULT_CHARACTER_CLASSES}]", ) @pass_context def generate(ctx, length, character_classes): @@ -296,3 +313,38 @@ def config(ctx, list, set, val): section = parts[0] key = parts[1] click.echo(config.get(section, key)) + + +@cli.command(help="Init the configuration") +@pass_context +@click.option( + "-r", + "--private-key-file", + type=str, + required=True, + help="Private key file", +) +@click.option( + "-p", "--public-key-file", type=str, required=True, help="Public key file", +) +@click.option( + "--yes", type=bool, help="Overwrite the existing config file if exists.", +) +def init(ctx, private_key_file, public_key_file, yes): + if not yes and os.path.exists(ctx.config_file): + yn = click.prompt(f"{ctx.config_file} exists, overwrite? [y/n]") + yes = yn.strip() and yn[0].lower() == "y" + if yes: + with configuration(ctx.config_file) as config: + values = [ + ('generate', 'length', str(DEFAULT_PASSWORD_LENGTH)), + ('generate', 'character_clsses', DEFAULT_CHARACTER_CLASSES), + ('decrypt', 'private_key_file', private_key_file), + ('encrypt', 'public_key_file', public_key_file), + ] + for (section, key, v) in values: + if section not in config.sections(): + config.add_section(section) + config[section][key] = v + + pass diff --git a/pyproject.toml b/pyproject.toml index 35dfbd3..2bd5fbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ push = false [tool.poetry] name = "hush" -version = "202001.1" +version = "202001.2" description = "Minimalistic command line secret management" license = "BSD-3-Clause" authors = ["Lech Gudalewicz "] diff --git a/tests/test_cmd.py b/tests/test_cmd.py index e2bf556..12e3198 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -1,10 +1,12 @@ import os import string from contextlib import contextmanager +from tempfile import mktemp from click.testing import CliRunner from hush import cli +from hush.console import DEFAULT_PASSWORD_LENGTH @contextmanager @@ -21,17 +23,26 @@ def keypair(name="rsa", passphrase=None): os.remove(f"{name}.pri") +@contextmanager +def config_file(lines): + file_name = mktemp() + with open(file_name, "w") as f: + f.writelines(lines) + yield file_name + os.remove(file_name) + + def test_version(): runner = CliRunner() output = runner.invoke(cli, "--version").output.strip() - assert "202001.1" in output + assert "202001.2" in output def test_generate_default(): runner = CliRunner() output = runner.invoke(cli, "generate").output.strip() - assert len(output) == 16 + assert len(output) == DEFAULT_PASSWORD_LENGTH def test_generate_lowercase(): @@ -39,7 +50,7 @@ def test_generate_lowercase(): result = runner.invoke(cli, ["generate", "-c", "a"]) assert result.exit_code == 0 output = result.output.strip() - assert len(output) == 16 + assert len(output) == DEFAULT_PASSWORD_LENGTH assert all([x in string.ascii_lowercase for x in output]) @@ -48,7 +59,7 @@ def test_generate_uppercase(): result = runner.invoke(cli, ["generate", "-c", "A"]) assert result.exit_code == 0 output = result.output.strip() - assert len(output) == 16 + assert len(output) == DEFAULT_PASSWORD_LENGTH assert all([x in string.ascii_uppercase for x in output]) @@ -57,7 +68,7 @@ def test_generate_digits(): result = runner.invoke(cli, ["generate", "-c", "8"]) assert result.exit_code == 0 output = result.output.strip() - assert len(output) == 16 + assert len(output) == DEFAULT_PASSWORD_LENGTH assert all([x in string.digits for x in output]) @@ -67,7 +78,7 @@ def test_generate_nonalphanumeric(): assert result.exit_code == 0 output = result.output.strip() nonalphanumeric = r"~!@#$%^&*_-+=|\(){}[]:;<>,.?/" - assert len(output) == 16 + assert len(output) == 8 assert all([x in nonalphanumeric for x in output]) @@ -216,3 +227,32 @@ def test_change_passphrase_empty(): result = runner.invoke(cli, ["decrypt", "-r", "rsa.pri"], input=output) assert result.exit_code == 0 assert result.output.strip() == "secret" + + +def test_alterantive_config(): + lines = """ + [generate] + length = 40 + character_classes = a + [decrypt] + private_key_file = foo.pri + + [encrypt] + public_key_file = foo.pub + """ + with keypair("foo"): + with config_file(lines) as alternative_config: + runner = CliRunner() + result = runner.invoke(cli, ["-c", alternative_config, "generate"]) + assert result.exit_code == 0 + output = result.output.strip() + assert len(output) == 40 + assert all([x in string.ascii_lowercase for x in output]) + result = runner.invoke( + cli, ["encrypt", "-p", "foo.pub"], input="secret" + ) + assert result.exit_code == 0 + output = result.output + result = runner.invoke(cli, ["decrypt", "-r", "foo.pri"], input=output) + assert result.exit_code == 0 + assert result.output.strip() == "secret"