Skip to content

Commit ccb7356

Browse files
committed
Replace WarningPolicy with interactive TOFU SSH host key verification
1 parent 656c5ff commit ccb7356

6 files changed

Lines changed: 135 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ All code must follow secure-by-default principles. Review every change against t
113113

114114
### Network requests (TLS / SSH)
115115
- All HTTPS requests must use default TLS verification — never set `verify=False`
116-
- SSH connections: `paramiko.AutoAddPolicy()` accepts any host key and is vulnerable to MITM. Document it as a known limitation in the SSH GUI. Prefer `paramiko.RejectPolicy()` or `paramiko.WarningPolicy()` when non-interactive verification is possible; at minimum, warn the user on first connection to an unknown host
116+
- SSH connections: never use `paramiko.AutoAddPolicy()` or `paramiko.WarningPolicy()` — both silently accept unknown host keys and are vulnerable to MITM. Use `InteractiveHostKeyPolicy` from `pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy` (via `apply_host_key_policy(client, parent_widget)`), which prompts the user with the SHA256 fingerprint on first connection and persists confirmed keys to `~/.pybreeze/ssh_known_hosts`
117117

118118
### Subprocess execution
119119
- Always pass argument lists to `subprocess.Popen` / `subprocess.run` — never `shell=True`

pybreeze/extend_multi_language/extend_english.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@
101101
"test_pioneer_create_template_label": "Create TestPioneer Yaml template",
102102
"test_pioneer_run_yaml": "Execute Test Pioneer Yaml",
103103
"test_pioneer_not_choose_yaml": "Please choose a Yaml file",
104+
# SSH host key verification
105+
"ssh_host_key_policy_dialog_title_verify_host": "Verify SSH host key",
106+
"ssh_host_key_policy_dialog_message_verify_host": (
107+
"The authenticity of host '{host}' cannot be established.\n"
108+
"{key_type} key fingerprint is {fingerprint}.\n\n"
109+
"Do you want to trust this host and continue connecting?"
110+
),
104111
# SSH command widget
105112
"ssh_command_widget_window_title_ssh_command_widget": "SSH Command Widget",
106113
"ssh_command_widget_button_label_send_command": "Send",

pybreeze/extend_multi_language/extend_traditional_chinese.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@
101101
"test_pioneer_create_template_label": "建立 TestPioneer Yaml 模板",
102102
"test_pioneer_run_yaml": "執行 Test Pioneer Yaml",
103103
"test_pioneer_not_choose_yaml": "請選擇 Yaml 檔案",
104+
# SSH host key verification
105+
"ssh_host_key_policy_dialog_title_verify_host": "驗證 SSH 主機金鑰",
106+
"ssh_host_key_policy_dialog_message_verify_host": (
107+
"無法驗證主機 '{host}' 的真實性。\n"
108+
"{key_type} 金鑰指紋為 {fingerprint}。\n\n"
109+
"是否信任此主機並繼續連線?"
110+
),
104111
# SSH command widget
105112
"ssh_command_widget_window_title_ssh_command_widget": "SSH 指令介面",
106113
"ssh_command_widget_button_label_send_command": "送出",

pybreeze/pybreeze_ui/connect_gui/ssh/ssh_command_widget.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from je_editor import language_wrapper
1414

15+
from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy
1516
from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget
1617
from pybreeze.utils.logging.logger import pybreeze_logger
1718

@@ -139,11 +140,8 @@ def connect_ssh(self):
139140

140141
try:
141142
self.ssh_client = paramiko.SSHClient()
142-
self.ssh_client.load_system_host_keys()
143-
self.ssh_client.set_missing_host_key_policy(paramiko.WarningPolicy())
144-
pybreeze_logger.warning(
145-
f"SSH connecting to {host}:{port} — host key will be accepted without verification"
146-
)
143+
apply_host_key_policy(self.ssh_client, self)
144+
pybreeze_logger.info("SSH connecting to %s:%s", host, port)
147145

148146
if use_key:
149147
if not os.path.exists(key_path):

pybreeze/pybreeze_ui/connect_gui/ssh/ssh_file_viewer_widget.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from je_editor import language_wrapper
1414

15+
from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_host_key_policy import apply_host_key_policy
1516
from pybreeze.pybreeze_ui.connect_gui.ssh.ssh_login_widget import LoginWidget
1617
from pybreeze.utils.logging.logger import pybreeze_logger
1718

@@ -29,18 +30,16 @@ def __init__(self):
2930
self.root_path: str = "/"
3031

3132
def connect(self, host: str, port: int, username: str, password: str,
32-
use_key: bool = False, key_path: str = ""):
33+
use_key: bool = False, key_path: str = "",
34+
parent_widget: QWidget | None = None):
3335
"""
3436
Establish SSH + SFTP connection.
3537
建立 SSH + SFTP 連線。
3638
"""
3739
self.close()
3840
self._ssh = paramiko.SSHClient()
39-
self._ssh.load_system_host_keys()
40-
self._ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
41-
pybreeze_logger.warning(
42-
f"SFTP connecting to {host}:{port} — host key will be accepted without verification"
43-
)
41+
apply_host_key_policy(self._ssh, parent_widget)
42+
pybreeze_logger.info("SFTP connecting to %s:%s", host, port)
4443
if use_key and key_path:
4544
pkey = None
4645
for KeyType in (paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey):
@@ -216,7 +215,7 @@ def _connect(self):
216215
self.word_dict.get("ssh_file_viewer_dialog_message_missing_input"))
217216
return
218217
try:
219-
self.client.connect(host, port, user, pwd, use_key, key_path)
218+
self.client.connect(host, port, user, pwd, use_key, key_path, parent_widget=self)
220219
self.load_root("/")
221220
except Exception as e:
222221
QMessageBox.critical(
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Interactive SSH host key policy with persistent trust-on-first-use (TOFU).
2+
3+
Replaces the MITM-prone ``paramiko.AutoAddPolicy`` / ``paramiko.WarningPolicy``:
4+
unknown host keys are shown to the user via a Qt dialog with their SHA256
5+
fingerprint, and only accepted on explicit confirmation. Confirmed hosts are
6+
persisted to ``~/.pybreeze/ssh_known_hosts`` so subsequent connections verify
7+
automatically.
8+
"""
9+
from __future__ import annotations
10+
11+
import base64
12+
import hashlib
13+
from pathlib import Path
14+
from typing import TYPE_CHECKING
15+
16+
import paramiko
17+
from je_editor import language_wrapper
18+
from PySide6.QtWidgets import QMessageBox
19+
20+
from pybreeze.utils.logging.logger import pybreeze_logger
21+
22+
if TYPE_CHECKING:
23+
from PySide6.QtWidgets import QWidget
24+
25+
26+
def _known_hosts_path() -> Path:
27+
"""Return the PyBreeze-managed known_hosts file path, ensuring the parent dir exists."""
28+
home_dir = Path.home() / ".pybreeze"
29+
home_dir.mkdir(parents=True, exist_ok=True)
30+
return home_dir / "ssh_known_hosts"
31+
32+
33+
def _fingerprint_sha256(key: paramiko.PKey) -> str:
34+
"""Return an OpenSSH-style SHA256 fingerprint (``SHA256:base64`` without padding)."""
35+
digest = hashlib.sha256(key.asbytes()).digest()
36+
return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode("ascii")
37+
38+
39+
class InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy):
40+
"""Policy that prompts the user to verify unknown host keys.
41+
42+
Accepted keys are persisted so later connections pass through ``RejectPolicy``-like
43+
strictness automatically. Declined keys abort the connection with ``SSHException``.
44+
"""
45+
46+
def __init__(self, parent: QWidget | None = None) -> None:
47+
super().__init__()
48+
self._parent = parent
49+
self._word_dict = language_wrapper.language_word_dict
50+
51+
def missing_host_key(
52+
self,
53+
client: paramiko.SSHClient,
54+
hostname: str,
55+
key: paramiko.PKey,
56+
) -> None:
57+
fingerprint = _fingerprint_sha256(key)
58+
key_type = key.get_name()
59+
60+
title = self._word_dict.get(
61+
"ssh_host_key_policy_dialog_title_verify_host",
62+
"Verify SSH host key",
63+
)
64+
message_template = self._word_dict.get(
65+
"ssh_host_key_policy_dialog_message_verify_host",
66+
"The authenticity of host '{host}' cannot be established.\n"
67+
"{key_type} key fingerprint is {fingerprint}.\n\n"
68+
"Do you want to trust this host and continue connecting?",
69+
)
70+
message = message_template.format(
71+
host=hostname, key_type=key_type, fingerprint=fingerprint
72+
)
73+
74+
box = QMessageBox(self._parent)
75+
box.setIcon(QMessageBox.Icon.Warning)
76+
box.setWindowTitle(title)
77+
box.setText(message)
78+
box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
79+
box.setDefaultButton(QMessageBox.StandardButton.No)
80+
response = box.exec()
81+
82+
if response != QMessageBox.StandardButton.Yes:
83+
pybreeze_logger.warning(
84+
"SSH host key for %s rejected by user (%s)", hostname, fingerprint
85+
)
86+
raise paramiko.SSHException(
87+
f"Host key for {hostname} rejected by user."
88+
)
89+
90+
client.get_host_keys().add(hostname, key_type, key)
91+
try:
92+
client.save_host_keys(str(_known_hosts_path()))
93+
except OSError as err:
94+
pybreeze_logger.warning(
95+
"Failed to persist SSH host key for %s: %s", hostname, err
96+
)
97+
pybreeze_logger.info(
98+
"SSH host key for %s accepted and stored (%s)", hostname, fingerprint
99+
)
100+
101+
102+
def apply_host_key_policy(client: paramiko.SSHClient, parent: QWidget | None) -> None:
103+
"""Load known hosts and attach the interactive TOFU policy to *client*."""
104+
client.load_system_host_keys()
105+
known_hosts = _known_hosts_path()
106+
if known_hosts.is_file():
107+
try:
108+
client.load_host_keys(str(known_hosts))
109+
except OSError as err:
110+
pybreeze_logger.warning("Failed to load PyBreeze known_hosts: %s", err)
111+
client.set_missing_host_key_policy(InteractiveHostKeyPolicy(parent))

0 commit comments

Comments
 (0)