diff --git a/README.md b/README.md index 0aadd25..de0deb6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com - Confort d'affichage : zoom `Ctrl+molette` ou `Ctrl+ +/-` (`Ctrl+0` remet a zero, la taille est memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir en direct, et recherche dans l'historique avec `Ctrl+F` +- Panneau des sessions redimensionnable en attrapant le separateur, et masquable entierement + (`F9`, menu Affichage, ou la croix du panneau) pour donner tout l'ecran au terminal - **Tunnels SSH** (redirection de ports) : bouton "Tunnels" dans la barre d'un onglet SSH. Redirection locale (`-L`) pour atteindre depuis votre poste un service qui n'ecoute que sur la boucle locale du serveur, et redirection distante (`-R`) dans l'autre sens @@ -28,7 +30,8 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com `vi`, `scp`... sous Windows. **Un shell Unix est choisi par defaut** quand il y en a un ; sinon PowerShell. Rien n'est embarque : l'application reutilise ce qui est deja installe - Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic -- Gestion des cles SSH : generation (Ed25519 / RSA), import de cles existantes, export (cle publique/privee) +- Gestion des cles SSH : generation **Ed25519, ECDSA (courbes NIST P-256 / P-384 / P-521) et RSA**, + import de cles existantes, export (cle publique/privee), protection par passphrase - Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom, taille, date, droits), glisser-deposer entre les panneaux, renommage (F2), suppression (Suppr), creation de dossier, permissions distantes (chmod), et file de transferts avec progression. **Aucun favori n'est necessaire pour l'utiliser** — voir ci-dessous diff --git a/app/dialogs/key_dialog.py b/app/dialogs/key_dialog.py index cb212cc..d21dc62 100644 --- a/app/dialogs/key_dialog.py +++ b/app/dialogs/key_dialog.py @@ -10,7 +10,7 @@ from PySide6.QtWidgets import ( QDialogButtonBox, QApplication, QLabel, ) -from app.key_manager import KeyManager, KEY_TYPE_ED25519, KEY_TYPE_RSA +from app.key_manager import KeyManager, KEY_TYPE_ED25519, KEY_TYPE_RSA, KEY_TYPE_LABELS class GenerateKeyDialog(QDialog): @@ -22,8 +22,14 @@ class GenerateKeyDialog(QDialog): self.name_edit = QLineEdit() self.type_combo = QComboBox() - self.type_combo.addItem("Ed25519 (recommande)", KEY_TYPE_ED25519) - self.type_combo.addItem("RSA", KEY_TYPE_RSA) + for key_type, label in KEY_TYPE_LABELS.items(): + self.type_combo.addItem(label, key_type) + self.type_combo.setToolTip( + "Ed25519 : courbe elliptique moderne, courte et rapide — le choix par defaut.\n" + "ECDSA (P-256/384/521) : egalement sur courbe elliptique, utile si une politique\n" + "interne impose les courbes NIST.\n" + "RSA : le plus universel, a privilegier pour un serveur tres ancien." + ) self.bits_spin = QSpinBox() self.bits_spin.setRange(2048, 8192) self.bits_spin.setSingleStep(1024) diff --git a/app/key_manager.py b/app/key_manager.py index f829b51..4d5d789 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -9,7 +9,7 @@ from pathlib import Path import paramiko from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa, ed25519 +from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, ec from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe) KEYS_FILE = DATA_DIR / "keys.json" @@ -17,6 +17,26 @@ DEFAULT_KEY_DIR = DATA_DIR / "keys" KEY_TYPE_ED25519 = "ed25519" KEY_TYPE_RSA = "rsa" +KEY_TYPE_ECDSA_P256 = "ecdsa-p256" +KEY_TYPE_ECDSA_P384 = "ecdsa-p384" +KEY_TYPE_ECDSA_P521 = "ecdsa-p521" + +# Courbes elliptiques NIST utilisees par ECDSA. Ed25519 est aussi une courbe +# elliptique (Curve25519) et reste le choix recommande : plus court, plus rapide, +# et sans les interrogations qui entourent le choix des parametres des courbes NIST. +ECDSA_CURVES = { + KEY_TYPE_ECDSA_P256: ec.SECP256R1, + KEY_TYPE_ECDSA_P384: ec.SECP384R1, + KEY_TYPE_ECDSA_P521: ec.SECP521R1, +} + +KEY_TYPE_LABELS = { + KEY_TYPE_ED25519: "Ed25519 (recommande)", + KEY_TYPE_ECDSA_P256: "ECDSA — courbe NIST P-256", + KEY_TYPE_ECDSA_P384: "ECDSA — courbe NIST P-384", + KEY_TYPE_ECDSA_P521: "ECDSA — courbe NIST P-521", + KEY_TYPE_RSA: "RSA (compatibilite maximale)", +} @dataclass @@ -97,6 +117,8 @@ class KeyManager: if key_type == KEY_TYPE_ED25519: private_key = ed25519.Ed25519PrivateKey.generate() + elif key_type in ECDSA_CURVES: + private_key = ec.generate_private_key(ECDSA_CURVES[key_type]()) else: private_key = rsa.generate_private_key(public_exponent=65537, key_size=rsa_bits) diff --git a/app/main_window.py b/app/main_window.py index 0363831..12f465a 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -552,6 +552,14 @@ class MainWindow(QMainWindow): keys_menu.addAction(manage_keys_action) view_menu = self.menuBar().addMenu("&Affichage") + + self.toggle_sidebar_action = QAction("Panneau des sessions", self, checkable=True) + self.toggle_sidebar_action.setChecked(True) + self.toggle_sidebar_action.setShortcut(QKeySequence("F9")) + self.toggle_sidebar_action.triggered.connect(self.toggle_sidebar) + view_menu.addAction(self.toggle_sidebar_action) + view_menu.addSeparator() + theme_menu = view_menu.addMenu("Theme") self.theme_group = QActionGroup(self) self.theme_group.setExclusive(True) @@ -574,7 +582,14 @@ class MainWindow(QMainWindow): def _build_sidebar(self) -> None: dock = QDockWidget("Sessions", self) - dock.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable) + # Closable : sans cette option, le panneau n'a pas de bouton de fermeture + # et ne peut pas etre masque. + dock.setFeatures( + QDockWidget.DockWidgetMovable + | QDockWidget.DockWidgetFloatable + | QDockWidget.DockWidgetClosable + ) + self.sessions_dock = dock container = QWidget() layout = QVBoxLayout(container) @@ -632,8 +647,15 @@ class MainWindow(QMainWindow): self.tree.customContextMenuRequested.connect(self._on_tree_context_menu) layout.addWidget(self.tree) + # Sans minimum explicite, la largeur plancher est imposee par le contenu + # (les noms de sessions), ce qui empeche de retrecir vraiment le panneau. + container.setMinimumWidth(120) + self.tree.setMinimumWidth(0) dock.setWidget(container) self.addDockWidget(Qt.LeftDockWidgetArea, dock) + self.resizeDocks([dock], [300], Qt.Horizontal) + # garde la case du menu Affichage synchronisee si on ferme le panneau par sa croix + dock.visibilityChanged.connect(self.toggle_sidebar_action.setChecked) def _build_tabs(self) -> None: self.tabs = QTabWidget() @@ -881,6 +903,13 @@ class MainWindow(QMainWindow): self.tabs.removeTab(index) # ---------- cles / a propos ---------- + def toggle_sidebar(self, visible: bool | None = None) -> None: + """Affiche ou masque le panneau des sessions (F9).""" + if visible is None: + visible = not self.sessions_dock.isVisible() + self.sessions_dock.setVisible(visible) + self.toggle_sidebar_action.setChecked(visible) + def change_theme(self, theme: str) -> None: """Applique un theme a chaud : feuille de style de l'interface + couleurs des terminaux deja ouverts (qui ont leur propre palette, hors QSS).""" diff --git a/resources/theme.qss b/resources/theme.qss index 8358fa4..16f56f7 100644 --- a/resources/theme.qss +++ b/resources/theme.qss @@ -78,6 +78,17 @@ QDockWidget::title { border-bottom: 1px solid $border; } +/* Separateur entre le panneau lateral et les onglets : assez large pour etre + attrape facilement a la souris, et surligne au survol. */ +QMainWindow::separator { + background-color: $border; + width: 6px; + height: 6px; +} +QMainWindow::separator:hover { + background-color: $accent; +} + /* ---------- Arborescence des sessions ---------- */ QTreeWidget { background-color: $panel; diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 0000000..72213a2 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,58 @@ +"""Generation de cles SSH : tous les types proposes doivent produire une cle +relisible par paramiko, donc utilisable pour se connecter.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path + +import paramiko + +from app.key_manager import ( + KeyManager, KEY_TYPE_LABELS, KEY_TYPE_RSA, KEY_TYPE_ED25519, ECDSA_CURVES, +) + +tmp = Path(tempfile.mkdtemp()) +km = KeyManager(registry_path=tmp / "keys.json") + +LOADERS = (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey) + + +def recharge(chemin: str, mot_de_passe=None): + for loader in LOADERS: + try: + return loader.from_private_key_file(chemin, password=mot_de_passe) + except paramiko.SSHException: + continue + return None + + +print(f"{'type':<12} {'libelle':<32} {'algorithme relu':<22} bits") +print("-" * 78) +for key_type, label in KEY_TYPE_LABELS.items(): + bits = 2048 if key_type == KEY_TYPE_RSA else 4096 + entry = km.generate_key(f"test_{key_type}", key_type, rsa_bits=bits, save_dir=tmp) + pkey = recharge(entry.private_path) + assert pkey is not None, f"{key_type} : cle illisible par paramiko" + assert entry.public_key_text.startswith(pkey.get_name()), (entry.public_key_text[:30], pkey.get_name()) + assert entry.fingerprint.startswith("SHA256:"), entry.fingerprint + print(f"{key_type:<12} {label:<32} {pkey.get_name():<22} {pkey.get_bits()}") + +print() +print("=== les courbes NIST sont bien distinctes ===") +noms = {} +for key_type in ECDSA_CURVES: + entry = next(k for k in km.keys if k.key_type == key_type) + noms[key_type] = recharge(entry.private_path).get_name() + print(f" {key_type:<12} -> {noms[key_type]}") +assert len(set(noms.values())) == 3, noms + +print() +print("=== cle protegee par une passphrase ===") +entry = km.generate_key("test_avec_passphrase", KEY_TYPE_ED25519, passphrase="secret", save_dir=tmp) +assert recharge(entry.private_path) is None, "sans la passphrase, la cle ne doit pas se charger" +assert recharge(entry.private_path, "secret") is not None, "avec la passphrase, elle doit se charger" +print(" chargement refuse sans passphrase, accepte avec : OK") + +print() +print("GENERATION DE CLES : OK")