From 5fb965d7c2d340d253eab3b1695c6f4999878c7c Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 01:43:24 +0200 Subject: [PATCH] refactor: unifier le calcul d'empreinte des cles SSH generate_key() calculait une vraie empreinte SHA256, mais import_key() prenait juste pub_text.split()[1][:16] - un fragment de base64, pas une empreinte. - compute_fingerprint(public_key_line) : fonction unique (format "SHA256:xxxx...", comme ssh-keygen -lf), utilisee par generate_key() et import_key(). - KeyManager.load() corrige silencieusement les entrees existantes dont l'empreinte ne correspond pas a compute_fingerprint(public_key_text) (cas des cles importees avant ce correctif), et resauvegarde si necessaire. Ce commit branche aussi dans KeyManager les deux mecanismes ajoutes par les commits precedents : generate_key() ecrit desormais via app.storage.json_store (schema_version=1, .bak, ecriture atomique) et protege la cle privee avec app.security.key_protection.protect_private_key() a la place du chmod(0o600) precedent. Verifie par tests/test_atomic_storage.py (recalcul d'une empreinte tronquee au chargement) et tests/test_keys.py (format SHA256: pour toutes les cles generees). Co-Authored-By: Claude Sonnet 5 --- app/key_manager.py | 85 +++++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/app/key_manager.py b/app/key_manager.py index 1ea2a3d..422f549 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -1,7 +1,8 @@ """Gestion des cles SSH : generation, import, export, registre local.""" from __future__ import annotations -import json +import base64 +import hashlib import shutil import uuid from dataclasses import dataclass, field, asdict @@ -12,8 +13,31 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, ec from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe) +from app.storage.json_store import load_json_document, save_json_document +from app.security.key_protection import protect_private_key + 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" @@ -104,26 +128,39 @@ 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: - DATA_DIR.mkdir(parents=True, exist_ok=True) DEFAULT_KEY_DIR.mkdir(parents=True, exist_ok=True) - if not self.registry_path.exists(): - self.keys = [] + 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() - return - try: - raw = json.loads(self.registry_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - raw = {"keys": []} - self.keys = [KeyEntry.from_dict(k) for k in raw.get("keys", [])] def save(self) -> None: - DATA_DIR.mkdir(parents=True, exist_ok=True) - payload = {"keys": [k.to_dict() for k in self.keys]} - self.registry_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + 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) @@ -180,12 +217,9 @@ class KeyManager: priv_path.write_bytes(priv_bytes) pub_path.write_text(pub_text, encoding="utf-8") - try: - priv_path.chmod(0o600) - except OSError: - pass + protect_private_key(priv_path) - fingerprint = self._fingerprint(pub_bytes) + fingerprint = compute_fingerprint(pub_text) entry = KeyEntry( name=name, key_type=key_type, @@ -213,13 +247,12 @@ class KeyManager: pub_path_str = str(guess) pub_text = guess.read_text(encoding="utf-8").strip() - fingerprint = pub_text.split()[1] if pub_text and len(pub_text.split()) > 1 else "" entry = KeyEntry( name=name, key_type=key_type, private_path=str(priv_path), public_path=pub_path_str, - fingerprint=fingerprint[:16], + fingerprint=compute_fingerprint(pub_text) if pub_text else "", public_key_text=pub_text, ) self.keys.append(entry) @@ -248,15 +281,3 @@ class KeyManager: def export_private_key(self, entry: KeyEntry, destination: str) -> None: shutil.copyfile(entry.private_path, destination) - - @staticmethod - def _fingerprint(pub_bytes: bytes) -> str: - import base64 - import hashlib - - parts = pub_bytes.split() - if len(parts) < 2: - return "" - raw = base64.b64decode(parts[1]) - digest = hashlib.sha256(raw).digest() - return "SHA256:" + base64.b64encode(digest).decode("utf-8").rstrip("=")