#!/usr/bin/env python3
"""
KeePass Console Browser, providing a read-only TUI for KeePass databases.
"""
import argparse
import sys
from pathlib import Path
from typing import Optional, List, Tuple, Iterator, Callable
from pykeepass import PyKeePass
from pykeepass.entry import Entry
from pykeepass.exceptions import CredentialsError
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import VerticalScroll, Horizontal, Vertical
from textual.reactive import reactive, Reactive
from textual.screen import Screen
from textual.timer import Timer
from textual.widget import Widget
from textual.widgets import Header, Input, Tree, Label
class KeePassAppError(RuntimeError):
pass
class TallHeader(Header):
def __init__(self) -> None:
super().__init__()
self.tall = True
class StaticLabel(Label):
"""Generic label component for static text."""
DEFAULT_CSS = """
StaticLabel {
min-height: 1;
}
"""
value: Reactive[str] = reactive("")
def __init__(self, id: Optional[str] = None) -> None:
super().__init__(id=id, markup=False)
def watch_value(self) -> None:
self.update(self.value)
class UnlockScreen(Screen[Optional[PyKeePass]]):
"""Modal startup password prompt, result is an opened database."""
BINDINGS = [
Binding("escape", "quit", "quit", show=False),
]
class MiddleCenter(Widget, inherit_bindings=False):
"""Generic container that centers full viewport."""
DEFAULT_CSS = """
MiddleCenter {
align: center middle;
width: 1fr;
height: 1fr;
}
"""
class PasswordInput(Input):
valid_hint: Reactive[Optional[bool]] = reactive(True)
def __init__(self) -> None:
super().__init__(placeholder="Password", password=True)
self.styles.max_width = 32
def watch_valid_hint(self) -> None:
if self.valid_hint is None:
self.set_class(False, "-invalid")
self.set_class(True, "-valid")
self.loading = True
# self.disabled = True
else:
self.set_class(not self.valid_hint, "-invalid")
self.set_class(self.valid_hint, "-valid")
self.loading = False
# self.disabled = False
def __init__(self, filename: Path, keyfile: Optional[Path]) -> None:
self._filename: Path = filename
self._keyfile: Optional[Path] = keyfile
self._input: UnlockScreen.PasswordInput = UnlockScreen.PasswordInput()
super().__init__()
def compose(self) -> ComposeResult:
yield TallHeader()
yield UnlockScreen.MiddleCenter(self._input)
async def on_input_submitted(self, evt: Input.Submitted) -> None:
if self._input.valid_hint is not None:
self._input.valid_hint = None
self._try_unlock(evt.value)
async def on_input_changed(self) -> None:
self._input.valid_hint = True
@work(thread=True)
def _try_unlock(self, password: str) -> None:
try:
keepass: PyKeePass = PyKeePass(self._filename.as_posix(), password=password, keyfile=self._keyfile)
except CredentialsError:
self.app.call_from_thread(self._try_unlock_cb, None)
except Exception as e:
raise KeePassAppError(f"Cannot unlock '{self._filename}': {str(e)}") from None
else:
self.app.call_from_thread(self._try_unlock_cb, keepass)
def _try_unlock_cb(self, keepass: Optional[PyKeePass]) -> None:
if keepass is None:
self._input.valid_hint = False
self._input.focus()
else:
self.dismiss(keepass)
def action_quit(self) -> None:
self.dismiss(None)
class KeePassEntryTags(Widget):
"""Generic tag labels, for selected database entry."""
DEFAULT_CSS = """
KeePassEntryTags {
height: auto;
min-height: 1;
width: 100%;
layout: horizontal;
}
"""
class KeePassEntryTag(Label):
DEFAULT_CSS = """
KeePassEntryTag {
background: $accent-darken-3;
padding: 0;
margin-right: 1;
min-height: 1;
}
"""
tags: Reactive[List[str]] = reactive([])
def _mount_tags(self) -> None:
self.mount_all([KeePassEntryTags.KeePassEntryTag(tag, markup=False) for tag in self.tags])
def on_mount(self) -> None:
self._mount_tags()
def watch_tags(self) -> None:
if self.is_mounted:
self.remove_children() # NB: no need for await, makes copy
self._mount_tags()
class KeePassEntryHeader(Vertical):
"""Details view header, with highlighted title and tags."""
DEFAULT_CSS = """
KeePassEntryHeader {
height: auto;
padding: 1;
background: $accent;
}
KeePassEntryHeader #title {
text-style: bold;
text-align: center;
width: 100%;
border-bottom: solid $accent-lighten-3;
}
"""
class KeePassEntryValue(StaticLabel):
"""Generic string value with border title."""
DEFAULT_CSS = """
KeePassEntryValue {
width: 100%;
margin-top: 1;
border-title-color: $accent;
border: solid $accent;
}
KeePassEntryValue:disabled {
border: solid $panel;
}
"""
password: Reactive[bool] = reactive(False)
def __init__(self, title: str, id: Optional[str] = None) -> None:
super().__init__(id=id)
self.border_title = title
def _set_value(self) -> None:
self.update("•" * len(self.value) if self.password else self.value)
self.disabled = len(self.value) == 0
def watch_value(self) -> None:
self._set_value()
def watch_password(self) -> None:
self._set_value()
class KeePassEntry(Widget):
"""Selected database entry details view."""
DEFAULT_CSS = """
KeePassEntry {
padding: 1;
}
"""
entry: Reactive[Optional[Entry]] = reactive(None)
def __init__(self) -> None:
self._tags: KeePassEntryTags = KeePassEntryTags()
self._title: StaticLabel = StaticLabel(id="title")
self._ctime: StaticLabel = StaticLabel()
self._mtime: StaticLabel = StaticLabel()
self._url: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Url")
self._username: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Username")
self._password: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Password")
self._notes: KeePassEntryHeader. KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Notes")
self._attachments: KeePassEntryHeader.KeePassEntryValue = KeePassEntryHeader.KeePassEntryValue("Attachments")
super().__init__()
def compose(self) -> ComposeResult:
with VerticalScroll():
yield from [KeePassEntryHeader(self._title, self._tags, self._ctime, self._mtime),
self._url, self._username, self._password, self._notes, self._attachments]
def watch_entry(self) -> None:
if self.entry is None:
self.visible = False
return
self.visible = True
self._tags.tags = list(self.get_tags(self.entry))
self._password.password = True
self._title.value = self.get_title(self.entry)
self._ctime.value = "Create: " + self.entry.ctime.strftime('%Y-%m-%d %H:%M:%S %Z')
self._mtime.value = "Modify: " + self.entry.mtime.strftime('%Y-%m-%d %H:%M:%S %Z')
self._url.value = self.entry.url or ""
self._username.value = self.entry.username or ""
self._password.value = self.entry.password or ""
self._notes.value = self.entry.notes or "" if self.entry.notes != self.entry.url else ""
self._attachments.value = "\n".join(_.filename for _ in self.entry.attachments)
def reveal(self) -> None:
self._password.password = not self._password.password
@classmethod
def get_tags(cls, entry: Entry) -> Iterator[str]:
path: List[str] = list(filter(None, entry.path[:-1]))
yield "/".join(path) if len(path) else "Root"
yield from entry.tags or []
if len(entry.attachments):
yield "attachment"
@classmethod
def get_title(cls, entry: Entry) -> str:
return entry.title or entry.username or entry.url or "???"
@classmethod
def get_sort_keys(cls, entry: Entry) -> Tuple:
title: str = cls.get_title(entry)
return title.lower(), title, entry.mtime
@classmethod
def get_search_keys(cls, entry: Entry) -> Iterator[str]:
yield from filter(None, [entry.title, entry.url, entry.username, entry.notes])
yield from cls.get_tags(entry)
yield from [_.filename for _ in entry.attachments]
class SideBar(Vertical):
"""Filter input and database entry selection."""
DEFAULT_CSS = """
SideBar {
dock: left;
max-width: 50%;
border-right: heavy $background;
}
SideBar Input {
margin-top: 1;
}
SideBar KeePassTree {
padding: 0 0 0 1;
margin: 0 1 1 1;
}
"""
class FilterInput(Input):
"""Search term input with rate-limited callback."""
BINDINGS = [
Binding("down", "cursor_down", "Cursor Down", show=False),
]
def __init__(self, callback: Callable[[Optional[str]], None]) -> None:
super().__init__(placeholder="Search")
self._callback: Callable[[Optional[str]], None] = callback
self._timer: Optional[Timer] = None
def _timer_cb(self) -> None:
self._timer = None
self._callback(self.value)
def on_input_changed(self) -> None:
self._callback(None)
if self._timer is None:
self._timer = self.set_timer(0.5, self._timer_cb, name="filter_timer", pause=False)
else:
self._timer.reset()
def action_cursor_down(self) -> None:
self.screen.focus_next()
class KeePassTree(Tree[Entry]):
"""Possibly filtered database entry selection."""
DEFAULT_CSS = """
KeePassTree, KeePassTree LoadingIndicator.-overlay {
background: $panel;
}
"""
def __init__(self, filename: Path, keepass: PyKeePass, details: KeePassEntry) -> None:
super().__init__(filename.stem)
self.show_root = False
self.root.expand()
self._details: KeePassEntry = details
self._keepass: PyKeePass = keepass
self._create_children(None)
def _create_children(self, filter_value: Optional[str]) -> None:
self.clear()
for entry in sorted(self._keepass.entries, key=KeePassEntry.get_sort_keys): # type: Entry
if not filter_value or any(filter_value.lower() in _.lower()
for _ in KeePassEntry.get_search_keys(entry)):
self.root.add_leaf(KeePassEntry.get_title(entry), data=entry)
self.cursor_line = 0
def apply_filter(self, value: Optional[str]) -> None:
if value is None:
self._details.entry = None
self.loading = True
else:
self._create_children(value)
self._details.entry = self.cursor_node.data if self.cursor_node is not None else None
self.loading = False
async def on_tree_node_highlighted(self, node: Tree.NodeHighlighted[Entry]) -> None:
self._details.entry = node.node.data
async def on_tree_node_selected(self) -> None:
self._details.reveal()
def __init__(self, filename: Path, keepass: PyKeePass, details: KeePassEntry) -> None:
self._keepass: PyKeePass = keepass
self._tree: SideBar.KeePassTree = SideBar.KeePassTree(filename, keepass, details)
self._filter: Input = SideBar.FilterInput(self._tree.apply_filter)
super().__init__(self._filter, self._tree)
def reset(self) -> bool:
if not self._filter.has_focus or self._filter.value:
self._filter.value = ""
self._filter.focus()
return True
return False
class MainScreen(Screen[None]):
"""Tree sidebar and database entry viewer."""
BINDINGS = [
Binding("escape", "quit", "back", show=True),
Binding("ctrl+b", "toggle_sidebar", "toggle sidebar", show=True),
]
def __init__(self, filename: Path, keepass: PyKeePass) -> None:
self._details: KeePassEntry = KeePassEntry()
self._tree: SideBar = SideBar(filename, keepass, self._details)
super().__init__()
def compose(self) -> ComposeResult:
yield TallHeader()
yield Horizontal(self._tree, self._details)
def action_quit(self) -> None:
if not self._tree.display:
self._tree.display = True
elif not self._tree.reset():
self.dismiss(None)
def action_toggle_sidebar(self) -> None:
self._tree.display = not self._tree.display
class KeePassApp(App[int]):
"""Main app, switching between password/unlock screen and browser."""
def __init__(self, filename: Path, keyfile: Optional[Path]) -> None:
super().__init__()
self._filename: Path = filename
self._keyfile: Optional[Path] = keyfile
self.title = "KeePass Console Browser"
self.sub_title = filename.name
def on_mount(self) -> None:
self._on_lock(None)
def _on_lock(self, _) -> None:
self.push_screen(UnlockScreen(self._filename, self._keyfile), callback=self._on_unlock)
def _on_unlock(self, keepass: Optional[PyKeePass]) -> None:
if keepass is None:
self.exit(0)
return
self.push_screen(MainScreen(self._filename, keepass), callback=self._on_lock)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--keyfile", type=Path, default=None, help="KeePass database keyfile", metavar="db.key")
parser.add_argument("database", type=Path, help="KeePass database to read", metavar="database.kdbx")
args = parser.parse_args()
# premature checks to avoid huge app stacktraces
if not args.database.is_file():
print(f"Cannot read database '{args.database}': File not found", file=sys.stderr)
return 1
if args.keyfile is not None and not args.keyfile.is_file():
print(f"Cannot read keyfile '{args.keyfile}': File not found", file=sys.stderr)
return 1
rv: Optional[int] = KeePassApp(args.database, args.keyfile).run()
return rv if rv is not None else 1
if __name__ == "__main__":
sys.exit(main())