Files
SeedPass/src/seedpass_gui/app.py
T
thePR0M3TH3ANandClaude Fable 5 5dcd779a6f fix(python): TOTP codes must honor the entry's period and digits
TotpManager built pyotp.TOTP(secret) with library defaults (6 digits, 30s)
and ignored what the entry recorded, so any imported or derived entry using
other values produced codes the issuing service rejects. The service was
configured from the entry's values -- those govern the code.

- current_code_from_secret and current_code take period/digits
- EntryManager.get_totp_code passes the entry's values for both the
  derived and imported paths
- display_service, menu_handler (totp_list now carries digits) and the GUI
  updated at every call site
- regression tests for imported and deterministic entries with custom
  parameters, asserting against pyotp with the matching interval/digits,
  and confirming default entries are byte-identical to before

Found by scripts/cross_impl_check.py, which flagged it as a divergence
against the TypeScript port. That allowance is now removed: the suite
asserts TOTP equality and reports 30/30 with zero divergences.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:41:04 -04:00

509 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import time
import toga
from toga.style import Pack
from toga.sources import ListSource
from toga.style.pack import COLUMN, ROW
from seedpass.core.entry_types import EntryType
from seedpass.core.manager import PasswordManager
from seedpass.core.totp import TotpManager
from seedpass.core.api import (
VaultService,
EntryService,
NostrService,
UnlockRequest,
)
from seedpass.core.pubsub import bus
class LockScreenWindow(toga.Window):
"""Window prompting for the master password."""
def __init__(
self,
controller: SeedPassApp,
vault: VaultService,
entries: EntryService,
) -> None:
super().__init__("Unlock Vault")
# Store a reference to the SeedPass application instance separately from
# the ``toga`` ``Window.app`` attribute to avoid conflicts.
self.controller = controller
self.vault = vault
self.entries = entries
self.password_input = toga.PasswordInput(style=Pack(flex=1))
self.message = toga.Label("", style=Pack(color="red"))
unlock_button = toga.Button(
"Unlock", on_press=self.handle_unlock, style=Pack(padding_top=10)
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(toga.Label("Master Password:"))
box.add(self.password_input)
box.add(unlock_button)
box.add(self.message)
self.content = box
def handle_unlock(self, widget: toga.Widget) -> None:
password = self.password_input.value or ""
try:
self.vault.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - GUI error handling
self.message.text = str(exc)
return
main = MainWindow(
self.controller,
self.vault,
self.entries,
self.controller.nostr_service,
)
self.controller.main_window = main
main.show()
self.close()
class MainWindow(toga.Window):
"""Main application window showing vault entries."""
def __init__(
self,
controller: SeedPassApp,
vault: VaultService,
entries: EntryService,
nostr: NostrService,
) -> None:
super().__init__("SeedPass", on_close=self.cleanup)
# ``Window.app`` is reserved for the Toga ``App`` instance. Store the
# SeedPass application reference separately.
self.controller = controller
self.vault = vault
self.entries = entries
self.nostr = nostr
bus.subscribe("sync_started", self.sync_started)
bus.subscribe("sync_finished", self.sync_finished)
bus.subscribe("vault_locked", self.vault_locked)
self.last_sync = None
self.entry_source = ListSource(["id", "label", "kind", "info1", "info2"])
self.table = toga.Table(
headings=["ID", "Label", "Kind", "Info 1", "Info 2"],
data=self.entry_source,
style=Pack(flex=1),
)
add_button = toga.Button("Add", on_press=self.add_entry)
edit_button = toga.Button("Edit", on_press=self.edit_entry)
search_button = toga.Button("Search", on_press=self.search_entries)
relay_button = toga.Button("Relays", on_press=self.manage_relays)
totp_button = toga.Button("TOTP", on_press=self.show_totp_codes)
sync_button = toga.Button("Sync", on_press=self.start_vault_sync)
button_box = toga.Box(style=Pack(direction=ROW, padding_top=5))
button_box.add(add_button)
button_box.add(edit_button)
button_box.add(search_button)
button_box.add(relay_button)
button_box.add(totp_button)
button_box.add(sync_button)
self.status = toga.Label("Last sync: never", style=Pack(padding_top=5))
box = toga.Box(style=Pack(direction=COLUMN, padding=10))
box.add(self.table)
box.add(button_box)
box.add(self.status)
self.content = box
self.refresh_entries()
def refresh_entries(self) -> None:
self.entry_source.clear()
for idx, label, username, url, _arch in self.entries.list_entries():
entry = self.entries.retrieve_entry(idx)
kind = (entry or {}).get("kind", (entry or {}).get("type", ""))
info1 = ""
info2 = ""
if kind == EntryType.PASSWORD.value:
info1 = username or ""
info2 = url or ""
elif kind == EntryType.KEY_VALUE.value:
info1 = entry.get("value", "") if entry else ""
else:
info1 = str(entry.get("index", "")) if entry else ""
self.entry_source.append(
{
"id": idx,
"label": label,
"kind": kind,
"info1": info1,
"info2": info2,
}
)
# --- Button handlers -------------------------------------------------
def add_entry(self, widget: toga.Widget) -> None:
dlg = EntryDialog(self, None)
dlg.show()
def edit_entry(self, widget: toga.Widget) -> None:
if self.table.selection is None:
return
entry_id = int(self.table.selection[0])
dlg = EntryDialog(self, entry_id)
dlg.show()
def search_entries(self, widget: toga.Widget) -> None:
dlg = SearchDialog(self)
dlg.show()
def manage_relays(self, widget: toga.Widget) -> None:
dlg = RelayManagerDialog(self, self.nostr)
dlg.show()
def show_totp_codes(self, widget: toga.Widget) -> None:
win = TotpViewerWindow(self.controller, self.entries)
win.show()
def start_vault_sync(self, widget: toga.Widget | None = None) -> None:
"""Schedule a background vault synchronization."""
async def _runner() -> None:
self.nostr.start_background_vault_sync()
self.controller.loop.create_task(_runner())
# --- PubSub callbacks -------------------------------------------------
def sync_started(self, *args: object, **kwargs: object) -> None:
self.status.text = "Syncing..."
def sync_finished(self, *args: object, **kwargs: object) -> None:
self.last_sync = time.strftime("%H:%M:%S")
self.status.text = f"Last sync: {self.last_sync}"
def vault_locked(self, *args: object, **kwargs: object) -> None:
try:
self.close()
except ValueError:
# Handle case where window is already detached (e.g. in tests)
pass
self.controller.main_window = None
self.controller.lock_window.show()
def cleanup(self, *args: object, **kwargs: object) -> None:
bus.unsubscribe("sync_started", self.sync_started)
bus.unsubscribe("sync_finished", self.sync_finished)
bus.unsubscribe("vault_locked", self.vault_locked)
manager = getattr(self.nostr, "_manager", None)
if manager is not None:
manager.cleanup()
class EntryDialog(toga.Window):
"""Dialog for adding or editing an entry."""
def __init__(self, main: MainWindow, entry_id: int | None) -> None:
title = "Add Entry" if entry_id is None else "Edit Entry"
super().__init__(title)
self.main = main
self.entry_id = entry_id
self.label_input = toga.TextInput(style=Pack(flex=1))
self.kind_input = toga.Selection(
items=[e.value for e in EntryType],
style=Pack(flex=1),
)
self.kind_input.value = EntryType.PASSWORD.value
self.username_input = toga.TextInput(style=Pack(flex=1))
self.url_input = toga.TextInput(style=Pack(flex=1))
self.length_input = toga.NumberInput(
min=8, max=128, style=Pack(width=80), value=16
)
self.key_input = toga.TextInput(style=Pack(flex=1))
self.value_input = toga.TextInput(style=Pack(flex=1))
save_button = toga.Button(
"Save", on_press=self.save, style=Pack(padding_top=10)
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(toga.Label("Label"))
box.add(self.label_input)
box.add(toga.Label("Kind"))
box.add(self.kind_input)
box.add(toga.Label("Username"))
box.add(self.username_input)
box.add(toga.Label("URL"))
box.add(self.url_input)
box.add(toga.Label("Length"))
box.add(self.length_input)
box.add(toga.Label("Key"))
box.add(self.key_input)
box.add(toga.Label("Value"))
box.add(self.value_input)
box.add(save_button)
self.content = box
if entry_id is not None:
entry = self.main.entries.retrieve_entry(entry_id)
if entry:
self.label_input.value = entry.get("label", "")
kind = entry.get("kind", entry.get("type", EntryType.PASSWORD.value))
self.kind_input.value = kind
self.kind_input.enabled = False
self.username_input.value = entry.get("username", "") or ""
self.url_input.value = entry.get("url", "") or ""
self.length_input.value = entry.get("length", 16)
self.key_input.value = entry.get("key", "")
self.value_input.value = entry.get("value", "")
def save(self, widget: toga.Widget) -> None:
label = self.label_input.value or ""
username = self.username_input.value or None
url = self.url_input.value or None
length = int(self.length_input.value or 16)
kind = self.kind_input.value
key = self.key_input.value or None
value = self.value_input.value or None
if self.entry_id is None:
if kind == EntryType.PASSWORD.value:
entry_id = self.main.entries.add_entry(
label, length, username=username, url=url
)
elif kind == EntryType.TOTP.value:
entry_id = self.main.entries.add_totp(label)
elif kind == EntryType.SSH.value:
entry_id = self.main.entries.add_ssh_key(label)
elif kind == EntryType.SEED.value:
entry_id = self.main.entries.add_seed(label)
elif kind == EntryType.PGP.value:
entry_id = self.main.entries.add_pgp_key(label)
elif kind == EntryType.NOSTR.value:
entry_id = self.main.entries.add_nostr_key(label)
elif kind == EntryType.KEY_VALUE.value:
entry_id = self.main.entries.add_key_value(
label, key or "", value or ""
)
elif kind == EntryType.MANAGED_ACCOUNT.value:
entry_id = self.main.entries.add_managed_account(label)
else:
entry_id = self.entry_id
kwargs = {"label": label}
if kind == EntryType.PASSWORD.value:
kwargs.update({"username": username, "url": url})
elif kind == EntryType.KEY_VALUE.value:
kwargs.update({"key": key, "value": value})
self.main.entries.modify_entry(entry_id, **kwargs)
entry = self.main.entries.retrieve_entry(entry_id) or {}
kind = entry.get("kind", entry.get("type", kind))
info1 = ""
info2 = ""
if kind == EntryType.PASSWORD.value:
info1 = username or ""
info2 = url or ""
elif kind == EntryType.KEY_VALUE.value:
info1 = entry.get("value", value or "")
else:
info1 = str(entry.get("index", ""))
row = {
"id": entry_id,
"label": label,
"kind": kind,
"info1": info1,
"info2": info2,
}
if self.entry_id is None:
self.main.entry_source.append(row)
else:
for existing in self.main.entry_source:
if getattr(existing, "id", None) == entry_id:
for key, value in row.items():
setattr(existing, key, value)
break
self.close()
# schedule vault sync after saving
getattr(self.main, "start_vault_sync", lambda *_: None)()
class SearchDialog(toga.Window):
"""Dialog for searching entries."""
def __init__(self, main: MainWindow) -> None:
super().__init__("Search Entries")
self.main = main
self.query_input = toga.TextInput(style=Pack(flex=1))
search_button = toga.Button(
"Search", on_press=self.do_search, style=Pack(padding_top=10)
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(toga.Label("Query"))
box.add(self.query_input)
box.add(search_button)
self.content = box
def do_search(self, widget: toga.Widget) -> None:
query = self.query_input.value or ""
results = self.main.entries.search_entries(query)
self.main.entry_source.clear()
for idx, label, username, url, _arch, _etype in results:
self.main.entry_source.append(
{
"id": idx,
"label": label,
"kind": "",
"info1": username or "",
"info2": url or "",
}
)
self.close()
class TotpViewerWindow(toga.Window):
"""Window displaying active TOTP codes."""
def __init__(self, controller: SeedPassApp, entries: EntryService) -> None:
super().__init__("TOTP Codes", on_close=self.cleanup)
self.controller = controller
self.entries = entries
self.table = toga.Table(
headings=["Label", "Code", "Seconds"],
style=Pack(flex=1),
)
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(self.table)
self.content = box
self._secret_cache: dict[int, str] = {}
self._running = True
self.controller.loop.create_task(self._update_loop())
self.refresh_codes()
async def _update_loop(self) -> None:
while self._running:
self.refresh_codes()
await asyncio.sleep(1)
def refresh_codes(self, *args: object, **kwargs: object) -> None:
self.table.data = []
for idx, label, *_rest in self.entries.list_entries(
filter_kinds=[EntryType.TOTP.value]
):
entry = self.entries.retrieve_entry(idx)
period = int(entry.get("period", 30)) if entry else 30
digits = int(entry.get("digits", 6)) if entry else 6
remaining = TotpManager.time_remaining(period)
if hasattr(self.entries, "get_totp_secret"):
if idx in self._secret_cache:
secret = self._secret_cache[idx]
else:
try:
secret = self.entries.get_totp_secret(idx)
self._secret_cache[idx] = secret
except Exception:
# Skip entries where secret cannot be derived (e.g. no seed)
continue
code = TotpManager.current_code_from_secret(
secret, period=period, digits=digits
)
else:
# Compatibility path for services exposing code retrieval directly.
code = self.entries.get_totp_code(idx)
self.table.data.append((label, code, remaining))
def cleanup(self, *args: object, **kwargs: object) -> None:
self._running = False
class RelayManagerDialog(toga.Window):
"""Dialog for managing relay URLs."""
def __init__(self, main: MainWindow, nostr: NostrService) -> None:
super().__init__("Relays")
self.main = main
self.nostr = nostr
self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
self.new_input = toga.TextInput(style=Pack(flex=1))
add_btn = toga.Button("Add", on_press=self.add_relay)
remove_btn = toga.Button("Remove", on_press=self.remove_relay)
self.message = toga.Label("", style=Pack(color="red"))
box = toga.Box(style=Pack(direction=COLUMN, padding=20))
box.add(self.table)
form = toga.Box(style=Pack(direction=ROW, padding_top=5))
form.add(self.new_input)
form.add(add_btn)
form.add(remove_btn)
box.add(form)
box.add(self.message)
self.content = box
self.refresh()
def refresh(self) -> None:
self.table.data = []
for i, url in enumerate(self.nostr.list_relays(), start=1):
self.table.data.append((i, url))
def add_relay(self, widget: toga.Widget) -> None:
url = self.new_input.value or ""
if not url:
return
try:
self.nostr.add_relay(url)
except Exception as exc: # pragma: no cover - pass errors
self.message.text = str(exc)
return
self.new_input.value = ""
self.refresh()
def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
if index is None:
if self.table.selection is None:
return
index = int(self.table.selection[0])
try:
self.nostr.remove_relay(index)
except Exception as exc: # pragma: no cover - pass errors
self.message.text = str(exc)
return
self.refresh()
def build() -> SeedPassApp:
"""Return a configured :class:`SeedPassApp` instance."""
return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
class SeedPassApp(toga.App):
def startup(self) -> None: # pragma: no cover - GUI bootstrap
pm = PasswordManager()
self.vault_service = VaultService(pm)
self.entry_service = EntryService(pm)
self.nostr_service = NostrService(pm)
self.lock_window = LockScreenWindow(
self,
self.vault_service,
self.entry_service,
)
self.main_window = None
self.lock_window.show()
def main() -> None: # pragma: no cover - GUI bootstrap
"""Run the BeeWare application."""
build().main_loop()