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 <noreply@anthropic.com>
284 lines
11 KiB
Python
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 dataclass, field, asdict
|
|
from pathlib import Path
|
|
|
|
import paramiko
|
|
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"
|
|
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)
|