diff --git a/.gitattributes b/.gitattributes index fb7c1a4..d4313eb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ -* text=auto *.png filter=lfs diff=lfs merge=lfs -text *.gif filter=lfs diff=lfs merge=lfs -text *.ico filter=lfs diff=lfs merge=lfs -text +* text=auto \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8826aae..ed92c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ # Git !.*github +!.gitignore + +# Ignore VS Code settings +!.vscode/ # Python files to ignore __pycache__ diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..be210ca --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,40 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run ssltui", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [], + "windows": { + "command": "wt", + "args": [ + "uv", + "run", + "ssltui" + ], + "presentation": { + "reveal": "never" + } + }, + "linux": { + "command": "uv", + "args": [ + "run", + "ssltui" + ], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + } + } + } + ] +} diff --git a/ssltui/__main__.py b/ssltui/__main__.py index 73488fe..669f549 100644 --- a/ssltui/__main__.py +++ b/ssltui/__main__.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import shutil import sys from pathlib import Path @@ -111,6 +112,14 @@ def _build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> None: + if shutil.which("openssl") is None: + print( + "Error: 'openssl' was not found on PATH. " + "Please install OpenSSL and ensure it is available in your PATH.", + file=sys.stderr, + ) + sys.exit(1) + raw: list[str] = list(argv) if argv is not None else sys.argv[1:] # Allow "ssltui " as a shorthand for "ssltui --dir ". diff --git a/ssltui/api.py b/ssltui/api.py index 8829885..c5d7a84 100644 --- a/ssltui/api.py +++ b/ssltui/api.py @@ -1327,7 +1327,13 @@ def stop(self) -> None: def create_app(root: Path, token: str, event_log: EventLog | None = None) -> Flask: app = Flask(__name__) - app.secret_key = hashlib.sha256(token.encode()).hexdigest() + # Derive a distinct session-cookie signing key from the API token via HMAC + # key separation, not password hashing. The token is a 256-bit random secret + # (secrets.token_hex(32)), so a fast hash is correct here; a slow password + # hash (bcrypt/argon2) would only matter for low-entropy human passwords. + app.secret_key = hmac.new( + token.encode(), b"ssltui-session-cookie-v1", hashlib.sha256 + ).digest() app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" _event_log = event_log if event_log is not None else EventLog() diff --git a/ssltui/tui.py b/ssltui/tui.py index feeadc5..b82f567 100644 --- a/ssltui/tui.py +++ b/ssltui/tui.py @@ -901,11 +901,11 @@ class MainScreen(Screen): BINDINGS = [ Binding("i", "init_ca", "Init CA"), Binding("n", "issue_cert", "New cert"), - Binding("c", "view_ca_cert", "Trusted CA root"), + Binding("c", "view_ca_cert", "Trusted root"), Binding("d", "view_cert", "View cert"), Binding("x", "revoke_selected", "Revoke"), Binding("t", "view_token", "API Token"), - Binding("a", "export_audit", "Export audit"), + Binding("a", "export_audit", "Audit"), Binding("q", "quit", "Quit"), ] @@ -930,7 +930,7 @@ def compose(self) -> ComposeResult: yield Button("Init CA [i]", id="btn-init", variant="primary") yield Button("New Cert [n]", id="btn-new") yield Button("Trusted CA [c]", id="btn-ca-cert") - yield Button("Export Audit [a]", id="btn-audit") + yield Button("Audit [a]", id="btn-audit") yield DataTable(id="cert-table", cursor_type="row", zebra_stripes=True) yield Footer() @@ -975,7 +975,7 @@ def _update_ca_status(self) -> None: expiry = ca_expiry(root) suffix = store.get_name_suffix(root) suffix_part = ( - f" names [b].{suffix}[/b]" if suffix else " names [dim]any[/dim]" + f" Names [b].{suffix}[/b]" if suffix else " Names [dim]any[/dim]" ) status.update( f"[green]CA ready[/green] [b]{subj}[/b] " @@ -994,15 +994,18 @@ def _build_table(self) -> None: # the header labels alone when called outside a fresh mount, which # collapses the column widths. if not table.columns: - table.add_columns("CN", "SANs", "Key", "Expires", "Days left") + keys = table.add_columns("CN", "SANs", "Key", "Expires", "Days left") + self._sans_col_key = keys[1] table.clear() + sans_displays: list[str] = [] for cert in store.list_certs(_root()): days = days_until_expiry(cert["expiry"]) style = _expiry_style(days) sans_display = ", ".join( s.replace("DNS:", "").replace("IP:", "") for s in cert["sans"] ) + sans_displays.append(sans_display) table.add_row( cert["cn"], sans_display, @@ -1012,6 +1015,16 @@ def _build_table(self) -> None: key=cert["cn"], ) + # Render the SANs column 20% narrower than its natural content width. + # content_width is only computed during the deferred layout pass, so we + # measure the data here and pin an explicit (non-auto) width. + header_w = len("SANs") + natural = max([header_w, *(len(s) for s in sans_displays)]) + sans_col = table.columns[self._sans_col_key] + sans_col.auto_width = False + sans_col.width = max(header_w, round(natural * 0.8)) + table._require_update_dimensions = True + def _selected_cn(self) -> str | None: """Return the CN of the currently highlighted table row, or None.""" table = self.query_one("#cert-table", DataTable) @@ -1170,14 +1183,6 @@ def _on_init_done(self, result: bool) -> None: self._update_ca_status() self._build_table() self.refresh_bindings() - if result: - token_path = config.api_token_path(_root()) - if token_path.exists(): - self.app.push_screen( - TokenScreen(token_path.read_text().strip()), - lambda _: self.query_one("#cert-table", DataTable).focus(), - ) - return self.query_one("#cert-table", DataTable).focus() def _on_issue_done(self, meta: dict | None) -> None: