Panneau lateral masquable et redimensionnable, plus de types de cles

Le panneau des sessions etait declare Movable|Floatable mais pas Closable : il
n'avait donc aucun bouton de fermeture et ne pouvait pas etre masque. Sa largeur
plancher etait par ailleurs imposee par son contenu, ce qui empechait de le
retrecir vraiment.
- ajout de DockWidgetClosable, largeur minimale ramenee a 120 px
- bascule par F9, par le menu Affichage, ou par la croix du panneau, les trois
  restant synchronises
- separateur elargi a 6 px et surligne au survol, pour etre attrape facilement

Generation de cles : ajout d'ECDSA sur les courbes NIST P-256, P-384 et P-521,
en plus d'Ed25519 et RSA. Ed25519 reste le choix par defaut : c'est aussi une
courbe elliptique (Curve25519), plus courte et plus rapide, et sans les
interrogations qui entourent le choix des parametres des courbes NIST. Chaque
type est verifie relisible par paramiko, donc utilisable pour se connecter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-09-04 12:08:28 +02:00
parent 9d346d76ed
commit 1f9aedfdb2
6 changed files with 135 additions and 6 deletions

View File

@@ -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 - 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 memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir
en direct, et recherche dans l'historique avec `Ctrl+F` 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. - **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 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 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 ; `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 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 - 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), - 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 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 (chmod), et file de transferts avec progression. **Aucun favori n'est necessaire pour l'utiliser** — voir ci-dessous

View File

@@ -10,7 +10,7 @@ from PySide6.QtWidgets import (
QDialogButtonBox, QApplication, QLabel, 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): class GenerateKeyDialog(QDialog):
@@ -22,8 +22,14 @@ class GenerateKeyDialog(QDialog):
self.name_edit = QLineEdit() self.name_edit = QLineEdit()
self.type_combo = QComboBox() self.type_combo = QComboBox()
self.type_combo.addItem("Ed25519 (recommande)", KEY_TYPE_ED25519) for key_type, label in KEY_TYPE_LABELS.items():
self.type_combo.addItem("RSA", KEY_TYPE_RSA) 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 = QSpinBox()
self.bits_spin.setRange(2048, 8192) self.bits_spin.setRange(2048, 8192)
self.bits_spin.setSingleStep(1024) self.bits_spin.setSingleStep(1024)

View File

@@ -9,7 +9,7 @@ from pathlib import Path
import paramiko import paramiko
from cryptography.hazmat.primitives import serialization 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) from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
KEYS_FILE = DATA_DIR / "keys.json" KEYS_FILE = DATA_DIR / "keys.json"
@@ -17,6 +17,26 @@ DEFAULT_KEY_DIR = DATA_DIR / "keys"
KEY_TYPE_ED25519 = "ed25519" KEY_TYPE_ED25519 = "ed25519"
KEY_TYPE_RSA = "rsa" 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 @dataclass
@@ -97,6 +117,8 @@ class KeyManager:
if key_type == KEY_TYPE_ED25519: if key_type == KEY_TYPE_ED25519:
private_key = ed25519.Ed25519PrivateKey.generate() private_key = ed25519.Ed25519PrivateKey.generate()
elif key_type in ECDSA_CURVES:
private_key = ec.generate_private_key(ECDSA_CURVES[key_type]())
else: else:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=rsa_bits) private_key = rsa.generate_private_key(public_exponent=65537, key_size=rsa_bits)

View File

@@ -552,6 +552,14 @@ class MainWindow(QMainWindow):
keys_menu.addAction(manage_keys_action) keys_menu.addAction(manage_keys_action)
view_menu = self.menuBar().addMenu("&Affichage") 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") theme_menu = view_menu.addMenu("Theme")
self.theme_group = QActionGroup(self) self.theme_group = QActionGroup(self)
self.theme_group.setExclusive(True) self.theme_group.setExclusive(True)
@@ -574,7 +582,14 @@ class MainWindow(QMainWindow):
def _build_sidebar(self) -> None: def _build_sidebar(self) -> None:
dock = QDockWidget("Sessions", self) 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() container = QWidget()
layout = QVBoxLayout(container) layout = QVBoxLayout(container)
@@ -632,8 +647,15 @@ class MainWindow(QMainWindow):
self.tree.customContextMenuRequested.connect(self._on_tree_context_menu) self.tree.customContextMenuRequested.connect(self._on_tree_context_menu)
layout.addWidget(self.tree) 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) dock.setWidget(container)
self.addDockWidget(Qt.LeftDockWidgetArea, dock) 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: def _build_tabs(self) -> None:
self.tabs = QTabWidget() self.tabs = QTabWidget()
@@ -881,6 +903,13 @@ class MainWindow(QMainWindow):
self.tabs.removeTab(index) self.tabs.removeTab(index)
# ---------- cles / a propos ---------- # ---------- 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: def change_theme(self, theme: str) -> None:
"""Applique un theme a chaud : feuille de style de l'interface + couleurs des """Applique un theme a chaud : feuille de style de l'interface + couleurs des
terminaux deja ouverts (qui ont leur propre palette, hors QSS).""" terminaux deja ouverts (qui ont leur propre palette, hors QSS)."""

View File

@@ -78,6 +78,17 @@ QDockWidget::title {
border-bottom: 1px solid $border; 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 ---------- */ /* ---------- Arborescence des sessions ---------- */
QTreeWidget { QTreeWidget {
background-color: $panel; background-color: $panel;

58
tests/test_keys.py Normal file
View File

@@ -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")