Files
pyssh-client/app/key_manager.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00

284 lines
11 KiB
Python

"""Gestion des cles SSH : generation, import, export, registre local."""
from __future__ import annotations
import base64
import hashlib
import shutil
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
import paramiko
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
from app.security.key_protection import protect_private_key
from app.storage.json_store import load_json_document, save_json_document
KEYS_FILE = DATA_DIR / "keys.json"
DEFAULT_KEY_DIR = DATA_DIR / "keys"
SCHEMA_VERSION = 1
def compute_fingerprint(public_key_line: str) -> str:
"""Empreinte SHA256 canonique (format `SHA256:xxxx...`, comme `ssh-keygen -lf`),
a partir d'une ligne de cle publique OpenSSH ("type base64 [commentaire]").
Utilisee pour toute cle, qu'elle soit generee, importee ou relue depuis le
registre : jamais un simple fragment du base64, qui ne serait pas un vrai
condense cryptographique de la cle."""
parts = (public_key_line or "").split()
if len(parts) < 2:
return ""
try:
raw = base64.b64decode(parts[1])
except (ValueError, TypeError):
return ""
digest = hashlib.sha256(raw).digest()
return "SHA256:" + base64.b64encode(digest).decode("utf-8").rstrip("=")
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)",
}
# Resume affiche dans le formulaire de generation, pour choisir en connaissance de cause.
# Les equivalences de robustesse suivent les recommandations du NIST (SP 800-57).
KEY_TYPE_DESCRIPTIONS = {
KEY_TYPE_ED25519: (
"Courbe Curve25519, de la famille des courbes d'Edwards.\n"
"Robustesse equivalente a RSA 3072, pour une cle publique de 68 caracteres seulement.\n"
"Signature rapide et concue pour resister aux erreurs d'implementation. Ses parametres\n"
"sont justifies publiquement, contrairement aux courbes NIST.\n"
"Compatible OpenSSH 6.5 et plus (2014) : tout serveur actuel l'accepte."
),
KEY_TYPE_ECDSA_P256: (
"Courbe NIST P-256 (aussi appelee secp256r1 ou prime256v1), 256 bits.\n"
"Robustesse equivalente a RSA 3072, comme Ed25519.\n"
"Tres repandue et souvent exigee par les politiques de conformite (FIPS).\n"
"Reserve : le NIST n'a jamais explique publiquement le choix de ses parametres,\n"
"ce qui lui vaut une mefiance persistante. A retenir surtout si une regle l'impose.\n"
"Compatible OpenSSH 5.7 et plus (2011)."
),
KEY_TYPE_ECDSA_P384: (
"Courbe NIST P-384 (secp384r1), 384 bits.\n"
"Robustesse equivalente a RSA 7680 : un cran au-dessus de P-256.\n"
"Retenue dans les contextes qui exigent un niveau eleve (par exemple la suite CNSA).\n"
"Memes reserves que P-256 sur l'origine des parametres."
),
KEY_TYPE_ECDSA_P521: (
"Courbe NIST P-521 (secp521r1), 521 bits — et non 512, ce n'est pas une coquille.\n"
"Robustesse equivalente a RSA 15360, le maximum des courbes NIST.\n"
"Sensiblement plus lente, et rarement necessaire en pratique.\n"
"Memes reserves que P-256 sur l'origine des parametres."
),
KEY_TYPE_RSA: (
"Ce n'est pas une courbe : RSA repose sur la difficulte de factoriser de grands nombres.\n"
"Cles nettement plus volumineuses et signatures plus lentes qu'une courbe elliptique.\n"
"2048 bits = robustesse correcte, 3072 bits equivaut a Ed25519, 4096 bits par prudence.\n"
"Son interet est la compatibilite : accepte absolument partout, y compris par des\n"
"equipements anciens (vieux switchs, NAS, systemes embarques)."
),
}
@dataclass
class KeyEntry:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
key_type: str = KEY_TYPE_ED25519
private_path: str = ""
public_path: str = ""
fingerprint: str = ""
public_key_text: str = ""
def to_dict(self) -> dict:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> KeyEntry:
e = KeyEntry()
for k in KeyEntry.__dataclass_fields__:
if k in data:
setattr(e, k, data[k])
return e
class KeyManager:
def __init__(self, registry_path: Path = KEYS_FILE):
self.registry_path = registry_path
self.keys: list[KeyEntry] = []
self.load_warning: str | None = None
self.load()
# ---------- registre ----------
def load(self) -> None:
DEFAULT_KEY_DIR.mkdir(parents=True, exist_ok=True)
was_missing = not self.registry_path.exists()
document, warning = load_json_document(
self.registry_path,
default={"schema_version": SCHEMA_VERSION, "keys": []},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.keys = [KeyEntry.from_dict(k) for k in document.get("keys", [])]
# Uniformise le format des empreintes : les cles importees avant l'ajout de
# compute_fingerprint() portaient un simple fragment du base64 plutot qu'un
# vrai condense SHA256. On les corrige silencieusement au chargement.
changed = False
for entry in self.keys:
if not entry.public_key_text:
continue
correct = compute_fingerprint(entry.public_key_text)
if correct and entry.fingerprint != correct:
entry.fingerprint = correct
changed = True
if was_missing or changed:
self.save()
def save(self) -> None:
payload = {"schema_version": SCHEMA_VERSION, "keys": [k.to_dict() for k in self.keys]}
save_json_document(self.registry_path, payload)
def remove(self, key_id: str, delete_files: bool = False) -> None:
entry = next((k for k in self.keys if k.id == key_id), None)
if entry and delete_files:
for p in (entry.private_path, entry.public_path):
try:
if p and Path(p).exists():
Path(p).unlink()
except OSError:
pass
self.keys = [k for k in self.keys if k.id != key_id]
self.save()
# ---------- generation ----------
def generate_key(
self,
name: str,
key_type: str = KEY_TYPE_ED25519,
rsa_bits: int = 4096,
passphrase: str | None = None,
save_dir: Path | None = None,
comment: str = "",
) -> KeyEntry:
save_dir = save_dir or DEFAULT_KEY_DIR
save_dir.mkdir(parents=True, exist_ok=True)
safe_name = "".join(c for c in name if c.isalnum() or c in ("-", "_")) or "id_key"
filename = f"id_{key_type}_{safe_name}"
priv_path = save_dir / filename
pub_path = save_dir / f"{filename}.pub"
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)
encryption = (
serialization.BestAvailableEncryption(passphrase.encode("utf-8"))
if passphrase
else serialization.NoEncryption()
)
priv_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=encryption,
)
pub_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH,
)
comment_suffix = f" {comment}".rstrip() if comment else ""
pub_text = pub_bytes.decode("utf-8") + comment_suffix + "\n"
priv_path.write_bytes(priv_bytes)
pub_path.write_text(pub_text, encoding="utf-8")
protect_private_key(priv_path)
fingerprint = compute_fingerprint(pub_text)
entry = KeyEntry(
name=name,
key_type=key_type,
private_path=str(priv_path),
public_path=str(pub_path),
fingerprint=fingerprint,
public_key_text=pub_text.strip(),
)
self.keys.append(entry)
self.save()
return entry
# ---------- import ----------
def import_key(self, name: str, private_key_path: str, public_key_path: str | None = None) -> KeyEntry:
priv_path = Path(private_key_path)
key_type, pub_text = self._inspect_private_key(priv_path)
pub_path_str = ""
if public_key_path and Path(public_key_path).exists():
pub_path_str = public_key_path
pub_text = Path(public_key_path).read_text(encoding="utf-8").strip()
else:
guess = priv_path.with_suffix(priv_path.suffix + ".pub")
if guess.exists():
pub_path_str = str(guess)
pub_text = guess.read_text(encoding="utf-8").strip()
entry = KeyEntry(
name=name,
key_type=key_type,
private_path=str(priv_path),
public_path=pub_path_str,
fingerprint=compute_fingerprint(pub_text) if pub_text else "",
public_key_text=pub_text,
)
self.keys.append(entry)
self.save()
return entry
def _inspect_private_key(self, priv_path: Path) -> tuple[str, str]:
"""Tente de determiner le type de cle sans mot de passe (best effort)."""
for loader, ktype in (
(paramiko.Ed25519Key, KEY_TYPE_ED25519),
(paramiko.RSAKey, KEY_TYPE_RSA),
):
try:
pkey = loader.from_private_key_file(str(priv_path))
pub_text = f"{pkey.get_name()} {pkey.get_base64()}"
return ktype, pub_text
except paramiko.PasswordRequiredException:
return ktype, ""
except (paramiko.SSHException, OSError):
continue
return "unknown", ""
# ---------- export ----------
def export_public_key(self, entry: KeyEntry, destination: str) -> None:
Path(destination).write_text(entry.public_key_text + "\n", encoding="utf-8")
def export_private_key(self, entry: KeyEntry, destination: str) -> None:
shutil.copyfile(entry.private_path, destination)