diff --git a/README.md b/README.md index 1cf6b03..6a895fa 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ KEYPASSDB_KEYFILE=/path/to/mykeyfile.key KEEPASSDB=/path/to/workdb.kdbx ``` +By default, passwords copied to the clipboard will timeout after 5 seconds. To change the +timeout, provide a `KEYPASSDB_TIMEOUT` config or environment variable. + ### Environment Variables If no config.ini file exists, **kpcli** will attempt to find config in the environment variables `KEEPASSDB`, `KEYPASSDB_KEYFILE` and `KEEPASSDB_PASSWORD` (falling back to a prompt for the password). diff --git a/kpcli/cli.py b/kpcli/cli.py index 3c2d4c0..c74ea62 100755 --- a/kpcli/cli.py +++ b/kpcli/cli.py @@ -1,19 +1,28 @@ #!/usr/bin/env python3 # standards import logging +import signal from typing import Optional # third parties from pykeepass.exceptions import CredentialsError +import pyperclip import typer from kpcli.comparator import KpDatabaseComparator from kpcli.datastructures import CopyOption, EditOption, KpContext from kpcli.connector import KpDatabaseConnector -from kpcli.utils import echo_banner, get_config +from kpcli.utils import ( + echo_banner, + get_config, + get_timeout, + inputTimeOutHandler, + InputTimedOut, +) logger = logging.getLogger(__name__) app = typer.Typer() +signal.signal(signal.SIGALRM, inputTimeOutHandler) ############ @@ -252,6 +261,7 @@ def copy_entry_attribute( ): """ Copy entry attribute to clipboard (username, password, url, notes) + Password is kept on clipboard until user confirms, or timeout is reached (5 seconds by default) """ entry = get_or_prompt_single_entry(ctx, name) typer.echo(f"Entry: {entry.group.name}/{entry.title}") @@ -259,6 +269,32 @@ def copy_entry_attribute( ctx_connector(ctx).copy_to_clipboard(entry, str(item)) except ValueError as e: typer.secho(str(e), fg=typer.colors.RED) + raise typer.Exit() + + if item == CopyOption.password: + # Clear the clipboard after a timeout unless the user indicates they're done with it earlier + timeout = ctx.obj.paste_timeout + try: + typer.secho( + f"Password copied to clipboard; timeout in {timeout} seconds", + fg=typer.colors.YELLOW, + ) + signal.alarm(timeout) + typer.prompt( + typer.style( + f"Press any key to clear clipboard and exit", + fg=typer.colors.MAGENTA, + bold=True, + ) + ) + typer.secho(f"Clipboard cleared", fg=typer.colors.GREEN) + except InputTimedOut: + typer.secho("\nTimed out, clipboard cleared", fg=typer.colors.RED) + except typer.Abort: # occurs on Ctrl+C + typer.secho(f"\nAborted, clipboard cleared", fg=typer.colors.RED) + raise typer.Exit() + finally: + pyperclip.copy("") else: typer.secho(f"{str(item)} copied to clipboard", fg=typer.colors.GREEN) @@ -300,7 +336,7 @@ def delete_entry( """ entry = get_or_prompt_single_entry(ctx, name) entry_string = f"{entry.group.name}/{entry.title}" - typer.secho(f"Deleting entry: entry_string", fg=typer.colors.RED) + typer.secho(f"Deleting entry: {entry_string}", fg=typer.colors.RED) # confirm or abort typer.confirm("Are you sure?:", abort=True) ctx_connector(ctx).delete_entry(entry) @@ -351,12 +387,14 @@ def main( if config.password is None: # If a password wasn't found in the config file or environment, prompt the use for it config.password = typer.prompt("Database password", hide_input=True) - try: if ctx.invoked_subcommand == "compare": ctx.obj = KpDatabaseComparator(config) else: - ctx.obj = KpContext(connector=KpDatabaseConnector(config)) + paste_timeout = get_timeout(profile=profile) + ctx.obj = KpContext( + connector=KpDatabaseConnector(config), paste_timeout=paste_timeout + ) except CredentialsError: typer.secho( f"Invalid credentials for database {config.filename}", fg=typer.colors.RED diff --git a/kpcli/datastructures.py b/kpcli/datastructures.py index 68fd294..c7e05be 100644 --- a/kpcli/datastructures.py +++ b/kpcli/datastructures.py @@ -17,6 +17,7 @@ class KpContext: connector = attr.ib(type=KpDatabaseConnector) group = attr.ib(type=Optional[Group], default=None) + paste_timeout = attr.ib(type=int, default=5) @attr.s diff --git a/kpcli/utils.py b/kpcli/utils.py index 20c7fa0..e01e26a 100644 --- a/kpcli/utils.py +++ b/kpcli/utils.py @@ -14,14 +14,14 @@ REQUIRED_CONFIG = ["KEEPASSDB"] -def _get_config_var(var, config_dict): - return config_dict.get(var) +def _get_config_var(var, config_dict, default=None): + return config_dict.get(var, default) -def get_config(profile="default"): +def get_config_location(profile="default"): """ - Find database config from a config.ini file or relevant environment variables - returns a KPConfig instance + Identify config location + Returns a config parser or environ """ config_file = Path(environ["HOME"]) / ".kp" / "config.ini" if config_file.exists(): @@ -42,6 +42,16 @@ def get_config(profile="default"): if missing_config: logger.error("Missing config variable(s): %s", ", ".join(missing_config)) raise typer.Exit(1) + + return config_location + + +def get_config(profile="default"): + """ + Find database config from a config.ini file or relevant environment variables + returns a KPConfig instance + """ + config_location = get_config_location(profile) db_config = KpConfig( filename=Path(_get_config_var("KEEPASSDB", config_location)), password=_get_config_var("KEEPASSDB_PASSWORD", config_location), @@ -54,7 +64,28 @@ def get_config(profile="default"): return db_config +def get_timeout(profile="default"): + config_location = get_config_location(profile) + try: + return int(_get_config_var("KEEPASSDB_TIMEOUT", config_location, 5)) + except ValueError: + typer.secho( + "Invalid timeout found, defaulting to 5 seconds", + fg=typer.colors.RED, + bold=True, + ) + return 5 + + def echo_banner(message: str, **style_options): """Helper function to print a banner style message""" banner = "=" * 80 typer.secho(f"{banner}\n{message}\n{banner}", **style_options) + + +class InputTimedOut(Exception): + pass + + +def inputTimeOutHandler(signum, frame): + raise InputTimedOut diff --git a/tests/test_cli.py b/tests/test_cli.py index 6cbba7c..1c46776 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 from os import environ from pathlib import Path -from unittest.mock import patch +from unittest.mock import call, patch +import pytest from typer.testing import CliRunner from kpcli.cli import app @@ -102,20 +103,28 @@ def test_get_with_password(): assert "********" not in result.stdout +@pytest.mark.parametrize( + "command,expected_args", + [ + ( + ["cp", "gmail"], + ["testpass", ""], + ), # copies password by default, then copies empty string + (["cp", "gmail", "username"], ["test@test.com"]), # copy username + (["cp", "gmail", "u"], ["test@test.com"]), # copy username with abbreviation + ], +) @patch.dict(environ, get_env_vars("test_db")) @patch("kpcli.connector.pyperclip.copy") -def test_copy(mock_copy): - # copies password by default - runner.invoke(app, ["cp", "gmail"]) - mock_copy.assert_called_with("testpass") - - # copy username - runner.invoke(app, ["cp", "gmail", "username"]) - mock_copy.assert_called_with("test@test.com") - - # copy username with abbreviation - runner.invoke(app, ["cp", "gmail", "u"]) - mock_copy.assert_called_with("test@test.com") +@patch("kpcli.cli.typer.prompt") +@patch("kpcli.cli.signal.alarm") +def test_copy(mock_alarm, mock_prompt, mock_copy, command, expected_args): + # mock prompt for confirmation after password copy - this will trigger the clipboard to be cleared + # also mock the alarm signal so it doesn't pollute other tests + mock_prompt.return_value = "y" + runner.invoke(app, command) + calls = [call(arg) for arg in expected_args] + mock_copy.assert_has_calls(calls) @patch.dict(environ, get_env_vars("temp_db"))