Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

# Git
!.*github
!.gitignore

# Ignore VS Code settings
!.vscode/

# Python files to ignore
__pycache__
Expand Down
40 changes: 40 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
]
}
9 changes: 9 additions & 0 deletions ssltui/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import shutil
import sys
from pathlib import Path

Expand Down Expand Up @@ -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)
Comment on lines +115 to +121

raw: list[str] = list(argv) if argv is not None else sys.argv[1:]

# Allow "ssltui <PATH>" as a shorthand for "ssltui --dir <PATH>".
Expand Down
8 changes: 7 additions & 1 deletion ssltui/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
31 changes: 18 additions & 13 deletions ssltui/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]

Expand All @@ -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()

Expand Down Expand Up @@ -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] "
Expand All @@ -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,
Expand All @@ -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
Comment on lines +1023 to +1026

def _selected_cn(self) -> str | None:
"""Return the CN of the currently highlighted table row, or None."""
table = self.query_one("#cert-table", DataTable)
Expand Down Expand Up @@ -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:
Expand Down
Loading