mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 00:12:04 +00:00
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]>
509 lines
18 KiB
Python
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()
|