Skip to content

Commit

Permalink
Clear clipboard after timeout when copying passwords
Browse files Browse the repository at this point in the history
  • Loading branch information
rebkwok committed Jan 31, 2021
1 parent 5df59d4 commit 350799f
Show file tree
Hide file tree
Showing 5 changed files with 104 additions and 22 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
46 changes: 42 additions & 4 deletions kpcli/cli.py
Original file line number Diff line number Diff line change
@@ -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)


############
Expand Down Expand Up @@ -252,13 +261,40 @@ 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}")
try:
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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions kpcli/datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions kpcli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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),
Expand All @@ -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
35 changes: 22 additions & 13 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
Expand Down

0 comments on commit 350799f

Please sign in to comment.