diff --git a/memory-update.md b/memory-update.md index b2dc24ca..f5fc00ab 100644 --- a/memory-update.md +++ b/memory-update.md @@ -249,3 +249,38 @@ - `seedpass tui2 --no-fallback-legacy`: preflight failures now return exit code 1 with a clear error. - Updated installer `scripts/install.sh` to run `seedpass tui2 --check` after install and warn when Textual runtime is unavailable, plus print a legacy fallback hint for users seeing blank/gray startup. - Added regression coverage in `src/tests/test_typer_cli.py` for preflight behavior and adapted existing TUI launch tests to mock preflight service creation. + +## 2026-03-01 TUI v2 parity slice: reveal + QR integration +- Added Textual TUI v2 sensitive-data actions: + - Keybindings: `v` reveal selected secret, `g` show QR. + - Palette commands: `reveal`, `qr`. +- Implemented reveal support for entry kinds: `password`, `seed`, `managed_account`, `totp`. +- Implemented QR rendering in-app (ASCII QR panel) for: + - TOTP (`otpauth://` payload) + - Seed / managed account seed (SeedQR digit payload) +- Added a dedicated `#secret-detail` panel in the right pane and reset-on-selection behavior to avoid stale secret display. +- Extended core `EntryService` API with deterministic seed retrieval methods used by TUI v2: + - `get_seed_phrase(entry_id)` + - `get_managed_account_seed(entry_id)` +- Added/updated tests for helper QR rendering, TUI reveal/QR workflows, and service wrappers; targeted suite passed. + +## 2026-03-01 TUI v2 parity slice: SSH/PGP/Nostr + secret mode behavior +- Extended TUI v2 sensitive reveal support to additional kinds: `ssh`, `pgp`, and `nostr`. +- Added legacy-aligned confirmation semantics in TUI v2: + - `reveal confirm` required for high-risk reveals (`seed`, `managed_account`, `ssh`, `pgp`). + - `qr private confirm` required for Nostr private-key QR rendering. +- Added `qr` mode parsing (`qr [public|private] [confirm]`) and retained default safe behavior (`public` for Nostr). +- Integrated secret-mode behavior into TUI reveal flow: + - when secret mode is enabled, reveal copies sensitive value to clipboard and avoids on-screen plaintext output for that reveal. +- Expanded `EntryService` API for TUI parity and safe abstraction from manager internals: + - `get_ssh_key_pair`, `get_pgp_key`, `get_nostr_key_pair` + - `get_secret_mode_enabled`, `get_clipboard_clear_delay`, `copy_to_clipboard` +- Added/updated tests covering confirmation-gated flows, Nostr private QR confirmation, and secret-mode clipboard behavior. + +## 2026-03-01 TUI v2 visual alignment with landing page +- Updated Textual TUI v2 default styling to mirror landing-page palette tokens: + - background: `#080a0c` / panel: `#0d1114` + - primary text: `#97b8a6` / bright text: `#daf2e5` + - accent borders/focus: `#58f29d`, `#2abf75`, `#274533`, `#1a3024` +- Styled Header/Footer/Input/ListView/ListItem focus/selection states to reduce default Textual blue and align with SeedPass mint-green terminal aesthetic. +- Added website-style icon semantics into TUI copy and sensitive panel titles (key/mobile/network/lock/bolt/document/users/seed mappings) to improve visual continuity between landing and app. diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index d7b14b4f..3827fdaa 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from .manager import PasswordManager from .pubsub import bus from .entry_types import EntryType +from utils import copy_to_clipboard class VaultExportRequest(BaseModel): @@ -317,6 +318,76 @@ class EntryService: entry_id, self._manager.parent_seed ) + def get_seed_phrase(self, entry_id: int) -> str: + """Return the derived seed phrase for a seed entry.""" + with self._lock: + return self._manager.entry_manager.get_seed_phrase( + entry_id, self._manager.parent_seed + ) + + def get_managed_account_seed(self, entry_id: int) -> str: + """Return the derived seed phrase for a managed account entry.""" + with self._lock: + return self._manager.entry_manager.get_managed_account_seed( + entry_id, self._manager.parent_seed + ) + + def get_ssh_key_pair(self, entry_id: int) -> tuple[str, str]: + """Return ``(private_key_pem, public_key_pem)`` for an SSH entry.""" + with self._lock: + return self._manager.entry_manager.get_ssh_key_pair( + entry_id, self._manager.parent_seed + ) + + def get_pgp_key(self, entry_id: int) -> tuple[str, str]: + """Return ``(private_key_armored, fingerprint)`` for a PGP entry.""" + with self._lock: + return self._manager.entry_manager.get_pgp_key( + entry_id, self._manager.parent_seed + ) + + def get_nostr_key_pair(self, entry_id: int) -> tuple[str, str]: + """Return ``(npub, nsec)`` for a Nostr entry.""" + with self._lock: + return self._manager.entry_manager.get_nostr_key_pair( + entry_id, self._manager.parent_seed + ) + + def get_secret_mode_enabled(self) -> bool: + """Return whether secret mode is currently enabled.""" + with self._lock: + cfg = getattr(self._manager, "config_manager", None) + if cfg is not None: + try: + return bool(cfg.get_secret_mode_enabled()) + except Exception: + pass + return bool(getattr(self._manager, "secret_mode_enabled", False)) + + def get_clipboard_clear_delay(self) -> int: + """Return clipboard clear delay in seconds.""" + with self._lock: + cfg = getattr(self._manager, "config_manager", None) + if cfg is not None: + try: + return int(cfg.get_clipboard_clear_delay()) + except Exception: + pass + return int(getattr(self._manager, "clipboard_clear_delay", 30)) + + def copy_to_clipboard(self, value: str) -> bool: + """Copy ``value`` to clipboard using configured clear delay.""" + with self._lock: + cfg = getattr(self._manager, "config_manager", None) + if cfg is not None: + try: + delay = int(cfg.get_clipboard_clear_delay()) + except Exception: + delay = int(getattr(self._manager, "clipboard_clear_delay", 30)) + else: + delay = int(getattr(self._manager, "clipboard_clear_delay", 30)) + return copy_to_clipboard(value, delay) + def add_entry( self, label: str, diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 0fb6fc97..bdb08638 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -69,6 +69,20 @@ def truncate_entry_for_display( return payload +def render_qr_ascii(data: str) -> str: + """Render ``data`` as an ASCII QR code.""" + import qrcode + + qr = qrcode.QRCode(border=1) + qr.add_data(data) + qr.make(fit=True) + matrix = qr.get_matrix() + lines: list[str] = [] + for row in matrix: + lines.append("".join("##" if cell else " " for cell in row)) + return "\n".join(lines) + + def launch_tui2( *, fingerprint: str | None = None, @@ -103,35 +117,82 @@ def launch_tui2( DETAIL_CONTENT_PREVIEW_LIMIT = 4000 CSS = """ + Screen { + background: #080a0c; + color: #97b8a6; + } + Header { + background: #0b0f13; + color: #daf2e5; + text-style: bold; + border-bottom: solid #274533; + } + Footer { + background: #0b0f13; + color: #97b8a6; + border-top: solid #274533; + } + Input { + background: #0d1114; + color: #daf2e5; + border: solid #1a3024; + } + Input:focus { + border: heavy #58f29d; + } + ListView { + background: #0d1114; + border: solid #1a3024; + } + ListItem { + color: #97b8a6; + } + ListItem.-highlight { + background: #122019; + color: #daf2e5; + text-style: bold; + } + Static { + color: #97b8a6; + } #command-palette { height: 2; margin: 0 1 1 1; - border: solid $secondary; - background: $panel; + border: solid #2abf75; + background: #0d1114; + color: #daf2e5; } #body { height: 1fr; } #status { height: 1; padding: 0 1; - border: heavy $primary; - background: $panel; + border: heavy #58f29d; + background: #0d1114; + color: #daf2e5; } - #left { width: 34; border: solid $primary; padding: 1; background: $panel; } - #center { width: 1fr; border: solid $primary; padding: 1; background: $panel; } - #right { width: 1fr; border: solid $primary; padding: 1; background: $panel; } + #left { width: 34; border: solid #1a3024; padding: 1; background: #0d1114; } + #center { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; } + #right { width: 1fr; border: solid #1a3024; padding: 1; background: #0d1114; } #search { margin-bottom: 1; } #quick-jump { margin-bottom: 1; } #entry-list { height: 1fr; } #entry-detail { height: 1fr; overflow: auto; - border: solid $boost; + border: solid #274533; padding: 1; } #link-detail { height: 12; overflow: auto; - border: solid $accent; + border: solid #2abf75; + padding: 1; + margin-top: 1; + } + #secret-detail { + height: 12; + overflow: auto; + border: solid #58f29d; padding: 1; margin-top: 1; } @@ -147,18 +208,19 @@ def launch_tui2( layer: overlay; dock: top; margin: 1 2; - border: solid $warning; + border: solid #58f29d; padding: 1; - background: $surface; + background: #0d1114; + color: #daf2e5; } #activity { height: 12; - border: solid $secondary; + border: solid #274533; padding: 1; margin-top: 1; overflow: auto; } - .pane-focus { border: heavy $success; } + .pane-focus { border: heavy #58f29d; } .hidden { display: none; } """ BINDINGS = [ @@ -178,6 +240,8 @@ def launch_tui2( ("[", "prev_link", "Prev Link"), ("]", "next_link", "Next Link"), ("o", "open_link_target", "Open Link"), + ("v", "reveal_selected", "Reveal"), + ("g", "show_qr", "QR"), ("a", "toggle_archive", "Archive/Restore"), ("e", "edit_document", "Edit Document"), ("ctrl+s", "save_document", "Save"), @@ -199,6 +263,7 @@ def launch_tui2( "filter | archive | restore | " "link-add [relation] [note] | " "link-rm [relation] | " + "reveal [confirm] | qr [public|private] [confirm] | " "link-filter | link-next | link-prev | link-open | " "page-next | page-prev | page | retry" ), @@ -220,6 +285,7 @@ def launch_tui2( with Vertical(id="right-view"): yield Static("", id="entry-detail") yield Static("", id="link-detail") + yield Static("", id="secret-detail") with Vertical(id="right-editor", classes="hidden"): yield Input(placeholder="Document title", id="doc-edit-label") yield Input( @@ -279,6 +345,9 @@ def launch_tui2( self._update_help_overlay() self._update_activity_panel() self._apply_focus_style() + self._set_secret_panel( + "Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦." + ) self._load_entries() def _set_status(self, message: str) -> None: @@ -313,6 +382,9 @@ def launch_tui2( text = "\n".join(lines) self.query_one("#activity", Static).update(text) + def _set_secret_panel(self, text: str) -> None: + self.query_one("#secret-detail", Static).update(text) + def _update_help_overlay(self) -> None: box = self.query_one("#help-overlay", Static) if not self.help_open: @@ -325,12 +397,13 @@ def launch_tui2( "", "Core : / search j jump-id p/n page r refresh", "Modes : Ctrl+P palette e edit-doc Ctrl+S save Esc cancel/close", - "Graph : l relation filter brackets link select o open link target", + "Graph ⚯ : l relation filter brackets link select o open link target", + "Secrets 🔑: v reveal selected secret g QR for selected entry", "Resilience: x retry last error", "Pane Focus: 1 left 2 center 3 right", "", "Palette examples", - "help | filter document | open 12 | link-add 7 references note", + "help | filter document | open 12 | reveal confirm | qr private confirm", ] ) box.update(text) @@ -410,7 +483,7 @@ def launch_tui2( ) text = "\n".join( [ - "SeedPass TUI v2", + "SeedPass ◈ TUI v2", fp_line, "", self._selected_summary(), @@ -421,7 +494,7 @@ def launch_tui2( f"Page: {self._result_page + 1}/{self._total_pages()}" ), "", - "Nav", + "Nav ⌘", "- / search", "- j jump to id", "- f cycle kind filter", @@ -431,7 +504,7 @@ def launch_tui2( "- r refresh", "- x retry last error", "", - "Actions", + "Actions ⚡", "- a archive/restore", "- e edit document", "- Ctrl+S save doc", @@ -439,6 +512,8 @@ def launch_tui2( "- l cycle link relation", "- [ / ] select link", "- o open link target", + "- v reveal selected secret", + "- g show selected QR", "- Esc cancel/close", ] ) @@ -468,6 +543,9 @@ def launch_tui2( self.query_one("#link-detail", Static).update( "Links: select an entry first." ) + self._set_secret_panel( + "Sensitive data hidden. Select an entry, then use 'v' (reveal) or 'g' (QR)." + ) self._current_links = [] self._current_link_cursor = 0 self._set_status("No entries match current filter/search") @@ -516,6 +594,9 @@ def launch_tui2( "Entry service unavailable in this runtime." ) self.query_one("#link-detail", Static).update("Links unavailable.") + self._set_secret_panel( + "Sensitive data unavailable: entry service missing." + ) self._all_results = [] self._result_page = 0 self._current_links = [] @@ -532,6 +613,9 @@ def launch_tui2( f"Failed to load entries: {exc}" ) self.query_one("#link-detail", Static).update("Links unavailable.") + self._set_secret_panel( + "Sensitive data unavailable: failed to load entries." + ) self._all_results = [] self._result_page = 0 self._current_links = [] @@ -571,6 +655,9 @@ def launch_tui2( self._selected_entry = dict(entry) body = self._entry_detail_text(entry) self.query_one("#entry-detail", Static).update(body) + self._set_secret_panel( + "Sensitive data hidden. Use 'v' to reveal 🔑 or 'g' for QR ▦." + ) self._update_links_panel() self._set_status(f"Selected entry {entry_index}") except Exception as exc: @@ -578,6 +665,9 @@ def launch_tui2( f"Failed to load entry {entry_index}: {exc}" ) self.query_one("#link-detail", Static).update("Links unavailable.") + self._set_secret_panel( + "Sensitive data unavailable: failed to load selected entry." + ) self._current_links = [] self._current_link_cursor = 0 self._record_failure( @@ -672,6 +762,278 @@ def launch_tui2( ) return kind == "document" + def _selected_kind(self) -> str: + if not isinstance(self._selected_entry, dict): + return "" + return ( + str( + self._selected_entry.get("kind") + or self._selected_entry.get("type") + or "" + ) + .strip() + .lower() + ) + + @staticmethod + def _kind_icon(kind: str) -> str: + return { + "password": "🗝", + "totp": "📱", + "ssh": "🖧", + "pgp": "🔒", + "nostr": "⚡", + "document": "📄", + "managed_account": "👥", + "seed": "🌱", + }.get(kind, "•") + + def _is_secret_mode_enabled(self) -> bool: + if self._service is None: + return False + getter = getattr(self._service, "get_secret_mode_enabled", None) + if not callable(getter): + return False + try: + return bool(getter()) + except Exception: + return False + + def _clipboard_clear_delay(self) -> int: + if self._service is None: + return 30 + getter = getattr(self._service, "get_clipboard_clear_delay", None) + if not callable(getter): + return 30 + try: + return int(getter()) + except Exception: + return 30 + + def _copy_to_clipboard(self, value: str) -> bool: + if self._service is None: + return False + copier = getattr(self._service, "copy_to_clipboard", None) + if not callable(copier): + return False + try: + return bool(copier(value)) + except Exception: + return False + + def _resolve_selected_sensitive_payload( + self, *, qr_mode: str = "default" + ) -> tuple[str, str, str | None, str | None, str]: + if self._service is None: + raise ValueError("Entry service unavailable") + if self._selected_entry_id is None: + raise ValueError("No entry selected") + + entry = self._selected_entry or self._service.retrieve_entry( + self._selected_entry_id + ) + if not isinstance(entry, dict) or not entry: + raise ValueError("Selected entry not found") + + kind = ( + self._selected_kind() + or str(entry.get("kind") or entry.get("type") or "").strip().lower() + ) + label = str(entry.get("label", f"entry-{self._selected_entry_id}")) + icon = self._kind_icon(kind) + + if kind == "password": + length = int(entry.get("length", 16)) + password = self._service.generate_password( + length, self._selected_entry_id + ) + return ( + f"Sensitive {icon}: Password #{self._selected_entry_id}", + f"Label : {label}\nPassword : {password}", + None, + password, + kind, + ) + + if kind == "seed": + phrase = self._service.get_seed_phrase(self._selected_entry_id) + from seedpass.core.seedqr import encode_seedqr + + seedqr = encode_seedqr(phrase) + return ( + f"Sensitive {icon}: Seed #{self._selected_entry_id}", + f"Label : {label}\nSeed Phrase : {phrase}", + seedqr, + phrase, + kind, + ) + + if kind == "managed_account": + phrase = self._service.get_managed_account_seed(self._selected_entry_id) + from seedpass.core.seedqr import encode_seedqr + + seedqr = encode_seedqr(phrase) + return ( + f"Sensitive {icon}: Managed Account Seed #{self._selected_entry_id}", + f"Label : {label}\nSeed Phrase : {phrase}", + seedqr, + phrase, + kind, + ) + + if kind == "totp": + secret = self._service.get_totp_secret(self._selected_entry_id) + code = self._service.get_totp_code(self._selected_entry_id) + period = int(entry.get("period", 30)) + digits = int(entry.get("digits", 6)) + from seedpass.core.totp import TotpManager + + uri = TotpManager.make_otpauth_uri( + label, secret, period=period, digits=digits + ) + return ( + f"Sensitive {icon}: TOTP #{self._selected_entry_id}", + ( + f"Label : {label}\n" + f"Code : {code}\n" + f"Secret : {secret}\n" + f"Period : {period}s\n" + f"Digits : {digits}" + ), + uri, + code, + kind, + ) + + if kind == "ssh": + priv_key, pub_key = self._service.get_ssh_key_pair( + self._selected_entry_id + ) + return ( + f"Sensitive {icon}: SSH #{self._selected_entry_id}", + f"Label : {label}\nPublic Key : {pub_key}\nPrivate Key : {priv_key}", + None, + priv_key, + kind, + ) + + if kind == "pgp": + priv_key, fingerprint_text = self._service.get_pgp_key( + self._selected_entry_id + ) + return ( + f"Sensitive {icon}: PGP #{self._selected_entry_id}", + ( + f"Label : {label}\n" + f"Fingerprint : {fingerprint_text}\n" + f"Private Key :\n{priv_key}" + ), + None, + priv_key, + kind, + ) + + if kind == "nostr": + npub, nsec = self._service.get_nostr_key_pair(self._selected_entry_id) + if qr_mode == "private": + qr_payload = nsec + else: + qr_payload = f"nostr:{npub}" + return ( + f"Sensitive {icon}: Nostr #{self._selected_entry_id}", + f"Label : {label}\nnpub : {npub}\nnsec : {nsec}", + qr_payload, + nsec, + kind, + ) + + raise ValueError( + "Reveal unsupported for kind " + f"'{kind or 'unknown'}'. Supported: password, seed, managed_account, " + "totp, ssh, pgp, nostr." + ) + + def _requires_confirm( + self, *, kind: str, include_qr: bool, qr_mode: str + ) -> bool: + if include_qr: + return kind == "nostr" and qr_mode == "private" + return kind in {"seed", "managed_account", "ssh", "pgp"} + + def _show_sensitive_panel( + self, *, include_qr: bool, qr_mode: str = "default", confirm: bool = False + ) -> None: + try: + title, body, qr_data, secret_value, kind = ( + self._resolve_selected_sensitive_payload(qr_mode=qr_mode) + ) + except Exception as exc: + self._record_failure( + "Sensitive reveal failed", + exc, + retry=( + (lambda: self.action_show_qr(mode=qr_mode, confirm=confirm)) + if include_qr + else (lambda: self.action_reveal_selected(confirm=confirm)) + ), + hint="Press 'x' to retry.", + ) + return + + if ( + self._requires_confirm( + kind=kind, include_qr=include_qr, qr_mode=qr_mode + ) + and not confirm + ): + if include_qr: + self._set_status( + "Sensitive QR requires confirmation. Run: qr private confirm" + ) + else: + self._set_status( + "Sensitive reveal requires confirmation. Run: reveal confirm" + ) + return + + if include_qr: + if not qr_data: + self._set_secret_panel( + f"{title}\n\nQR not supported for this entry." + ) + self._clear_failure() + self._set_status("QR not available for selected entry") + return + try: + qr_ascii = render_qr_ascii(qr_data) + except Exception as exc: + self._record_failure( + "QR render failed", + exc, + retry=self.action_show_qr, + hint="Press 'x' to retry.", + ) + return + self._set_secret_panel( + f"{title} QR\n\n{qr_ascii}\n\nPayload: {qr_data}" + ) + self._clear_failure() + self._set_status("Rendered QR for selected entry") + return + + self._set_secret_panel(f"{title}\n\n{body}") + self._clear_failure() + if self._is_secret_mode_enabled() and secret_value: + if self._copy_to_clipboard(secret_value): + delay = self._clipboard_clear_delay() + self._set_secret_panel( + f"{title}\n\nSecret mode is enabled.\n" + f"Sensitive value copied to clipboard (auto-clear in {delay}s)." + ) + self._set_status("Sensitive value copied to clipboard") + return + self._set_status("Revealed selected sensitive data") + def _set_document_editor_visible(self, visible: bool) -> None: view = self.query_one("#right-view", Vertical) editor = self.query_one("#right-editor", Vertical) @@ -732,8 +1094,9 @@ def launch_tui2( self._set_status( "Palette commands: help, open, search, filter, archive, " "restore, edit-doc, save-doc, cancel-edit, link-add, link-rm, " - "link-filter, link-next, link-prev, link-open, page-next, " - "page-prev, page , retry, jump " + "reveal [confirm], qr [public|private] [confirm], link-filter, " + "link-next, link-prev, link-open, page-next, page-prev, page , " + "retry, jump " ) return if cmd == "retry": @@ -817,6 +1180,28 @@ def launch_tui2( if cmd == "cancel-edit": self.action_cancel_document_edit() return + if cmd == "reveal": + if len(args) > 1 or (len(args) == 1 and args[0].lower() != "confirm"): + self._set_status("Usage: reveal [confirm]") + return + confirm = len(args) == 1 and args[0].lower() == "confirm" + self.action_reveal_selected(confirm=confirm) + return + if cmd == "qr": + mode = "default" + confirm = False + for token in args: + value = token.strip().lower() + if value in {"public", "private"}: + mode = value + continue + if value == "confirm": + confirm = True + continue + self._set_status("Usage: qr [public|private] [confirm]") + return + self.action_show_qr(mode=mode, confirm=confirm) + return if cmd == "link-add": if self._service is None or self._selected_entry_id is None: @@ -1119,6 +1504,12 @@ def launch_tui2( else: self._set_status("Palette closed") + def action_reveal_selected(self, confirm: bool = False) -> None: + self._show_sensitive_panel(include_qr=False, confirm=confirm) + + def action_show_qr(self, mode: str = "default", confirm: bool = False) -> None: + self._show_sensitive_panel(include_qr=True, qr_mode=mode, confirm=confirm) + def action_toggle_archive(self) -> None: if self._service is None or self._selected_entry_id is None: self._set_status("No entry selected") diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py index 335bbf42..f198810d 100644 --- a/src/tests/test_core_api_services.py +++ b/src/tests/test_core_api_services.py @@ -286,6 +286,62 @@ class TestEntryService: mock_manager.entry_manager.get_totp_code.assert_called_once_with(1, b"key") assert code == "123456" + def test_get_seed_phrase(self, service, mock_manager): + mock_manager.entry_manager.get_seed_phrase.return_value = "seed words" + phrase = service.get_seed_phrase(7) + mock_manager.entry_manager.get_seed_phrase.assert_called_once_with( + 7, mock_manager.parent_seed + ) + assert phrase == "seed words" + + def test_get_managed_account_seed(self, service, mock_manager): + mock_manager.entry_manager.get_managed_account_seed.return_value = ( + "managed words" + ) + phrase = service.get_managed_account_seed(8) + mock_manager.entry_manager.get_managed_account_seed.assert_called_once_with( + 8, mock_manager.parent_seed + ) + assert phrase == "managed words" + + def test_get_ssh_key_pair(self, service, mock_manager): + mock_manager.entry_manager.get_ssh_key_pair.return_value = ("priv", "pub") + result = service.get_ssh_key_pair(3) + mock_manager.entry_manager.get_ssh_key_pair.assert_called_once_with( + 3, mock_manager.parent_seed + ) + assert result == ("priv", "pub") + + def test_get_pgp_key(self, service, mock_manager): + mock_manager.entry_manager.get_pgp_key.return_value = ("priv", "fp") + result = service.get_pgp_key(4) + mock_manager.entry_manager.get_pgp_key.assert_called_once_with( + 4, mock_manager.parent_seed + ) + assert result == ("priv", "fp") + + def test_get_nostr_key_pair(self, service, mock_manager): + mock_manager.entry_manager.get_nostr_key_pair.return_value = ("npub", "nsec") + result = service.get_nostr_key_pair(5) + mock_manager.entry_manager.get_nostr_key_pair.assert_called_once_with( + 5, mock_manager.parent_seed + ) + assert result == ("npub", "nsec") + + def test_get_secret_mode_enabled(self, service, mock_manager): + mock_manager.config_manager.get_secret_mode_enabled.return_value = True + assert service.get_secret_mode_enabled() is True + + def test_get_clipboard_clear_delay(self, service, mock_manager): + mock_manager.config_manager.get_clipboard_clear_delay.return_value = 40 + assert service.get_clipboard_clear_delay() == 40 + + def test_copy_to_clipboard(self, service, mock_manager): + mock_manager.config_manager.get_clipboard_clear_delay.return_value = 35 + with patch("seedpass.core.api.copy_to_clipboard", return_value=True) as patched: + assert service.copy_to_clipboard("secret-value") is True + patched.assert_called_once_with("secret-value", 35) + def test_add_entry(self, service, mock_manager): mock_manager.entry_manager.add_entry.return_value = 1 diff --git a/src/tests/test_tui_v2_action_matrix.py b/src/tests/test_tui_v2_action_matrix.py index 5be5c0b2..597d42ce 100644 --- a/src/tests/test_tui_v2_action_matrix.py +++ b/src/tests/test_tui_v2_action_matrix.py @@ -35,6 +35,9 @@ class MatrixEntryService: self.fail_modify = fail_modify self.fail_add_link = fail_add_link self.fail_remove_link = fail_remove_link + self.secret_mode_enabled = False + self.clipboard_delay = 30 + self.clipboard_values: list[str] = [] def search_entries(self, query: str, kinds: list[str] | None = None): if self.fail_search: @@ -74,6 +77,61 @@ class MatrixEntryService: if value is not None: entry[key] = value + def generate_password(self, length: int, entry_id: int) -> str: + return f"pw-{entry_id}-{length}" + + def get_seed_phrase(self, entry_id: int) -> str: + entry = self._entries[int(entry_id)] + return str(entry.get("seed_phrase", "abandon " * 11 + "about")).strip() + + def get_managed_account_seed(self, entry_id: int) -> str: + entry = self._entries[int(entry_id)] + return str( + entry.get( + "seed_phrase", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + ) + ).strip() + + def get_totp_secret(self, entry_id: int) -> str: + entry = self._entries[int(entry_id)] + return str(entry.get("secret", "JBSWY3DPEHPK3PXP")) + + def get_totp_code(self, entry_id: int) -> str: + _ = entry_id + return "123456" + + def get_ssh_key_pair(self, entry_id: int): + entry = self._entries[int(entry_id)] + return ( + str(entry.get("private_key", "SSH_PRIVATE")), + str(entry.get("public_key", "ssh-ed25519 AAAA...")), + ) + + def get_pgp_key(self, entry_id: int): + entry = self._entries[int(entry_id)] + return ( + str(entry.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")), + str(entry.get("fingerprint", "DEADBEEF")), + ) + + def get_nostr_key_pair(self, entry_id: int): + entry = self._entries[int(entry_id)] + return ( + str(entry.get("npub", "npub1example")), + str(entry.get("nsec", "nsec1example")), + ) + + def get_secret_mode_enabled(self) -> bool: + return self.secret_mode_enabled + + def get_clipboard_clear_delay(self) -> int: + return self.clipboard_delay + + def copy_to_clipboard(self, value: str) -> bool: + self.clipboard_values.append(value) + return True + def archive_entry(self, entry_id: int) -> None: if self.fail_archive: raise RuntimeError("archive failed") @@ -245,6 +303,14 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None: app._run_palette_command("jump 2") await pilot.pause() + app._run_palette_command("open 2") + app.action_reveal_selected() + await pilot.pause() + assert "pw-2-" in str(app.query_one("#secret-detail").render()) + app.action_show_qr() + await pilot.pause() + assert "QR not supported" in str(app.query_one("#secret-detail").render()) + app.action_focus_left() app.action_focus_center() app.action_focus_right() diff --git a/src/tests/test_tui_v2_helpers.py b/src/tests/test_tui_v2_helpers.py index 09b692d6..5538c143 100644 --- a/src/tests/test_tui_v2_helpers.py +++ b/src/tests/test_tui_v2_helpers.py @@ -5,6 +5,7 @@ import pytest from seedpass.tui_v2.app import ( pagination_window, parse_palette_command, + render_qr_ascii, truncate_entry_for_display, ) @@ -61,6 +62,13 @@ def test_truncate_entry_for_display_truncates_content() -> None: assert payload["content_truncated"] is True +def test_render_qr_ascii_nonempty() -> None: + rendered = render_qr_ascii("otpauth://totp/example?secret=JBSWY3DPEHPK3PXP") + lines = [line for line in rendered.splitlines() if line.strip()] + assert len(lines) > 4 + assert any("##" in line for line in lines) + + @pytest.mark.parametrize("rows", [1000, 10000, 50000]) def test_pagination_window_large_vault_sizes(rows: int) -> None: # Large-vault smoke: pagination math remains stable at high row counts. diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 9c7701a9..9d6cb387 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -18,6 +18,9 @@ class FakeEntryService: for entry in entries } self.fail_search_times = fail_search_times + self.secret_mode_enabled = False + self.clipboard_delay = 30 + self.clipboard_values: list[str] = [] def search_entries(self, query: str, kinds: list[str] | None = None): if self.fail_search_times > 0: @@ -56,6 +59,61 @@ class FakeEntryService: continue entry[key] = value + def generate_password(self, length: int, entry_id: int) -> str: + return f"pw-{entry_id}-{length}" + + def get_seed_phrase(self, entry_id: int) -> str: + entry = self._entries[int(entry_id)] + return str(entry.get("seed_phrase", "abandon " * 11 + "about")).strip() + + def get_managed_account_seed(self, entry_id: int) -> str: + entry = self._entries[int(entry_id)] + return str( + entry.get( + "seed_phrase", + "legal winner thank year wave sausage worth useful legal winner thank yellow", + ) + ).strip() + + def get_totp_secret(self, entry_id: int) -> str: + entry = self._entries[int(entry_id)] + return str(entry.get("secret", "JBSWY3DPEHPK3PXP")) + + def get_totp_code(self, entry_id: int) -> str: + _ = entry_id + return "123456" + + def get_ssh_key_pair(self, entry_id: int): + entry = self._entries[int(entry_id)] + return ( + str(entry.get("private_key", "SSH_PRIVATE")), + str(entry.get("public_key", "ssh-ed25519 AAAA...")), + ) + + def get_pgp_key(self, entry_id: int): + entry = self._entries[int(entry_id)] + return ( + str(entry.get("private_key", "-----BEGIN PGP PRIVATE KEY BLOCK-----")), + str(entry.get("fingerprint", "DEADBEEF")), + ) + + def get_nostr_key_pair(self, entry_id: int): + entry = self._entries[int(entry_id)] + return ( + str(entry.get("npub", "npub1example")), + str(entry.get("nsec", "nsec1example")), + ) + + def get_secret_mode_enabled(self) -> bool: + return self.secret_mode_enabled + + def get_clipboard_clear_delay(self) -> int: + return self.clipboard_delay + + def copy_to_clipboard(self, value: str) -> bool: + self.clipboard_values.append(value) + return True + def archive_entry(self, entry_id: int) -> None: self._entries[int(entry_id)]["archived"] = True @@ -225,6 +283,101 @@ async def test_tui2_textual_link_commands_and_neighbor_open() -> None: assert "No graph links" in links_text +@pytest.mark.anyio +async def test_tui2_textual_reveal_and_qr_flow() -> None: + service = FakeEntryService( + [ + {"id": 1, "kind": "password", "label": "Login 1", "length": 16}, + { + "id": 2, + "kind": "seed", + "label": "Seed 2", + "seed_phrase": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + }, + { + "id": 3, + "kind": "totp", + "label": "TOTP 3", + "secret": "JBSWY3DPEHPK3PXP", + "period": 30, + "digits": 6, + }, + ] + ) + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + + app._run_palette_command("open 1") + app.action_reveal_selected() + await pilot.pause() + assert "Password : pw-1-16" in _widget_text(app, "#secret-detail") + + app._run_palette_command("open 2") + app.action_show_qr() + await pilot.pause() + qr_text = _widget_text(app, "#secret-detail") + assert "Seed #2 QR" in qr_text + assert "Payload:" in qr_text + + app._run_palette_command("open 3") + app._run_palette_command("reveal") + await pilot.pause() + assert "Secret : JBSWY3DPEHPK3PXP" in _widget_text(app, "#secret-detail") + + app._run_palette_command("qr") + await pilot.pause() + assert "TOTP #3 QR" in _widget_text(app, "#secret-detail") + + +@pytest.mark.anyio +async def test_tui2_textual_sensitive_confirm_and_secret_mode_clipboard() -> None: + service = FakeEntryService( + [ + { + "id": 1, + "kind": "ssh", + "label": "SSH 1", + "private_key": "SSH_PRIVATE_1", + "public_key": "ssh-ed25519 AAAA-1", + }, + { + "id": 2, + "kind": "nostr", + "label": "Nostr 2", + "npub": "npub1abc", + "nsec": "nsec1abc", + }, + ] + ) + service.secret_mode_enabled = True + service.clipboard_delay = 45 + app = _build_app(service) + + async with app.run_test() as pilot: + await pilot.pause() + + app._run_palette_command("open 1") + app._run_palette_command("reveal") + await pilot.pause() + assert "requires confirmation" in _status_text(app) + + app._run_palette_command("reveal confirm") + await pilot.pause() + assert "copied to clipboard" in _widget_text(app, "#secret-detail") + assert service.clipboard_values[-1] == "SSH_PRIVATE_1" + + app._run_palette_command("open 2") + app._run_palette_command("qr private") + await pilot.pause() + assert "requires confirmation" in _status_text(app) + + app._run_palette_command("qr private confirm") + await pilot.pause() + assert "Nostr #2 QR" in _widget_text(app, "#secret-detail") + + @pytest.mark.anyio async def test_tui2_textual_retry_after_search_failure() -> None: service = FakeEntryService(