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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,4 @@ Types: feat, fix, docs, style, refactor, test, chore

- Three-tier password storage: keyring > encrypted vault > none
- Accessibility: all interactive UI elements need aria labels
- SFTP operations use `paramiko`
- SFTP operations use `asyncssh`
4 changes: 1 addition & 3 deletions installer/portkeydrop.spec
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ hiddenimports = [
"keyring.backends.Windows",
"keyring.backends.macOS",
"keyring.backends.SecretService",
"paramiko",
"paramiko.transport",
"paramiko.sftp_client",
"asyncssh",
"prismatoid",
# Generated build-time file (wrapped in try/except, so PyInstaller misses it)
"portkeydrop._build_meta",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.1"
description = "A keyboard-driven file transfer client for FTP, SFTP, FTPS, SCP, and WebDAV"
requires-python = ">=3.11,<3.13"
dependencies = [
"paramiko>=3.0",
"asyncssh>=2.14",
"keyring>=25.0",
"wxPython>=4.2",
"prismatoid",
Expand Down
77 changes: 10 additions & 67 deletions src/portkeydrop/host_key_policy.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,15 @@
"""Interactive Paramiko host key policy backed by a wx dialog."""
"""Host key verification utilities for asyncssh-based SFTP connections.

from __future__ import annotations

import threading
from types import SimpleNamespace
from typing import Any

import paramiko

try:
import wx
except Exception: # pragma: no cover - exercised in headless tests
wx = SimpleNamespace(CallAfter=lambda fn, *a, **kw: fn(*a, **kw))

try:
from portkeydrop.dialogs.host_key_dialog import HostKeyDialog
except Exception: # pragma: no cover - wx may be unavailable in headless tests

class HostKeyDialog: # type: ignore[no-redef]
REJECT = 0
ACCEPT_ONCE = 1
ACCEPT_PERMANENT = 2

def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
With asyncssh, host key policy is handled via the ``known_hosts`` parameter
to ``asyncssh.connect()``. This module provides helpers for managing the
PortkeyDrop known-hosts file.
"""

def ShowModal(self) -> int:
return self.REJECT

def Destroy(self) -> None:
pass


class InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy):
"""
Paramiko host key policy that asks the user whether to trust unknown keys.
"""

def __init__(self, parent_window, known_hosts_path):
self._parent = parent_window
self._known_hosts_path = known_hosts_path

def missing_host_key(self, client, hostname, key):
key_type = key.get_name()
fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint())

event = threading.Event()
result = [HostKeyDialog.REJECT]
from __future__ import annotations

def show_dialog():
dlg = HostKeyDialog(self._parent, hostname, key_type, fingerprint)
try:
result[0] = dlg.ShowModal()
finally:
dlg.Destroy()
event.set()
from pathlib import Path

wx.CallAfter(show_dialog)
event.wait(timeout=120)

if result[0] == HostKeyDialog.ACCEPT_PERMANENT:
client.get_host_keys().add(hostname, key_type, key)
try:
client.save_host_keys(str(self._known_hosts_path))
except Exception:
pass
return
if result[0] == HostKeyDialog.ACCEPT_ONCE:
client.get_host_keys().add(hostname, key_type, key)
return
raise paramiko.SSHException(f"Host key for {hostname!r} was rejected by the user.")
def get_known_hosts_path() -> Path:
"""Return the path to PortkeyDrop's known_hosts file."""
return Path.home() / ".portkeydrop" / "known_hosts"
Loading
Loading