mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-30 09:22:03 +00:00
Advance TUI v2 parity with reveal/QR and landing theme
This commit is contained in:
1 parent
f02b360062
commit
92f17355ad
7 files changed
+800
-20
No files matched your search
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
+411
-20
@@ -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 <kind> | archive | restore | "
|
||||
"link-add <target> [relation] [note] | "
|
||||
"link-rm <target> [relation] | "
|
||||
"reveal [confirm] | qr [public|private] [confirm] | "
|
||||
"link-filter <relation|all> | link-next | link-prev | link-open | "
|
||||
"page-next | page-prev | page <n> | 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 <n>, retry, jump <id>"
|
||||
"reveal [confirm], qr [public|private] [confirm], link-filter, "
|
||||
"link-next, link-prev, link-open, page-next, page-prev, page <n>, "
|
||||
"retry, jump <id>"
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user