Six majeures d'un coup, mais la decouverte importante est ailleurs. LA « ROTATION DE CLE » N'EN ETAIT PAS UNE. Quatre versions, trois signataires differents — 10.0.0 par B3B1F60A, 12.0.0 par D0A82005, 14.0.0 et 16.0.2 par C4186DF6. Ce ne sont pas des cles distinctes : ce sont des SOUS-CLES de signature sous une primaire stable depuis 2022 (EB114F5E...C5923710, « Forgejo <contact@forgejo.org> »). La sous-cle 0F527CF9...0E1609E5 est bien celle qui avait signe la 12.0.0. D'ou une correction du verificateur : il comparait l'empreinte du SIGNATAIRE, donc une sous-cle, et aurait echoue a chaque rotation LEGITIME — on aurait appris a lever la garde pour avancer, ce qui est la pire chose qui puisse arriver a un controle. Il accepte desormais la cle primaire (dernier champ de VALIDSIG), qui survit aux rotations et refuse quand meme une cle etrangere. FORGEJO A L'ANCRE QUE KEYCLOAK N'A PAS. forgejo.org/download publie l'empreinte, et le binaire vient de codeberg.org : la source de confiance est INDEPENDANTE du canal de livraison. Le projet annonce lui-meme la rotation (« the GPG key is updated on a regular basis »), ce qui confirme qu'epingler la primaire est le bon choix. Somme sha256 egalement publiee et verifiee conforme. Eprouve dans les deux sens : nominal 0 ; binaire altere d'un octet 1 ; empreinte de Keycloak appliquee a Forgejo 1 ; signature d'un autre artefact 1 ; et Keycloak ne regresse pas apres modification du comparateur. Verifie : versions-mesurer 0 en retard, role applique sur forge-01, ansible-lint production sur 50 fichiers, prouver.py 35 OK (code lu sans tube). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
6.4 KiB
Python
125 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Verifie une signature PGP detachee contre une empreinte EPINGLEE.
|
|
|
|
Pourquoi ce script existe. Keycloak signe ses archives, et c'est plus fort qu'une somme de
|
|
controle : une somme prouve que le fichier n'a pas ete corrompu, une signature prouve QUI
|
|
l'a produit. Encore faut-il savoir a quelle cle se fier — sinon on verifie qu'une archive
|
|
est bien signee par celui qui l'a signee, ce qui ne prouve rien.
|
|
|
|
D'ou les trois exigences que ce script impose :
|
|
|
|
1. **La cle publique vit DANS LE DEPOT**, versionnee et relue. Aucune interrogation de
|
|
serveur de cles au moment du deploiement : un serveur de cles n'est pas une autorite,
|
|
et dependre de lui rendrait la verification aussi fragile que ce qu'elle protege.
|
|
2. **L'empreinte attendue est EPINGLEE** a cote de la version. Une cle qui change en
|
|
amont devient alors un echec BRUYANT, qui exige une relecture humaine — au lieu d'un
|
|
remplacement silencieux.
|
|
3. **Un trousseau JETABLE**. `GNUPGHOME` pointe sur un dossier temporaire : le trousseau
|
|
personnel de l'exploitant n'est jamais lu ni modifie, et le resultat ne depend pas de
|
|
ce qu'il contient. Deux executions sur deux machines donnent la meme reponse.
|
|
|
|
CE QUE CA NE PROUVE PAS, et il faut le dire : que l'empreinte epinglee soit la bonne. Ca,
|
|
aucune machine ne peut l'etablir — c'est une decision humaine, prise par un canal
|
|
independant, puis ECRITE ici. Le script garantit seulement qu'on ne s'en ecarte pas.
|
|
|
|
python3 scripts/verifier_signature.py --artefact X --signature X.asc \\
|
|
--cle roles/<role>/files/<cle>.asc --empreinte <40 hex>
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
def _gpg(maison: Path, *args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["gpg", "--homedir", str(maison), "--batch", "--no-tty",
|
|
"--status-fd", "1", *args],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
def verifier(artefact: Path, signature: Path, cle: Path, empreinte: str) -> tuple[bool, str]:
|
|
empreinte = re.sub(r"\s+", "", empreinte).upper()
|
|
if not re.fullmatch(r"[0-9A-F]{40}", empreinte):
|
|
return False, f"empreinte epinglee invalide : « {empreinte} » (40 hexa attendus)"
|
|
for f in (artefact, signature, cle):
|
|
if not f.is_file():
|
|
return False, f"fichier absent : {f}"
|
|
if shutil.which("gpg") is None:
|
|
return False, "gpg est absent du controleur — impossible de verifier quoi que ce soit"
|
|
|
|
with tempfile.TemporaryDirectory(prefix="setops-gpg-") as tmp:
|
|
maison = Path(tmp)
|
|
maison.chmod(0o700)
|
|
|
|
imp = _gpg(maison, "--import", str(cle))
|
|
if imp.returncode != 0:
|
|
return False, f"cle publique illisible : {imp.stderr.strip()[:160]}"
|
|
|
|
# La cle du depot doit ETRE celle qu'on a epinglee. Sans ce controle, remplacer le
|
|
# fichier de cle suffirait a faire passer n'importe quelle signature.
|
|
montre = _gpg(maison, "--with-colons", "--fingerprint", "--list-keys")
|
|
empreintes = [l.split(":")[9] for l in montre.stdout.splitlines()
|
|
if l.startswith("fpr:")]
|
|
if empreinte not in empreintes:
|
|
return False, (f"la cle du depot ne porte pas l'empreinte epinglee.\n"
|
|
f" epinglee : {empreinte}\n"
|
|
f" trouvee : {', '.join(empreintes) or '(aucune)'}")
|
|
|
|
v = _gpg(maison, "--verify", str(signature), str(artefact))
|
|
etat = v.stdout + v.stderr
|
|
if "GOODSIG" not in etat and "[GNUPG:] VALIDSIG" not in etat:
|
|
motif = re.search(r"\[GNUPG:\] (BADSIG|ERRSIG|NO_PUBKEY|EXPKEYSIG|REVKEYSIG)\S*.*",
|
|
etat)
|
|
return False, ("signature INVALIDE : "
|
|
+ (motif.group(0) if motif else etat.strip()[:200]))
|
|
# La signature est bonne — mais par QUELLE cle ? `VALIDSIG` porte l'empreinte
|
|
# reelle du signataire ; s'en remettre a « bonne signature » sans la lire
|
|
# laisserait passer une signature valide faite par une autre cle du trousseau.
|
|
# `VALIDSIG` porte l'empreinte de la SOUS-CLE qui a signe, et — en dernier champ —
|
|
# celle de la cle PRIMAIRE. Les deux sont acceptees, et c'est essentiel :
|
|
#
|
|
# Forgejo fait tourner ses sous-cles de signature (0F527CF9… pour la 12.0.0,
|
|
# 3BF4E813… pour la 16.0.2) sous une cle primaire stable depuis 2022, et le dit
|
|
# explicitement — « the GPG key is updated on a regular basis ». Epingler la
|
|
# sous-cle ferait echouer la verification a chaque rotation legitime, et on
|
|
# apprendrait a lever la garde pour avancer. **Epingler la PRIMAIRE survit aux
|
|
# rotations tout en refusant une cle etrangere.**
|
|
vs = re.search(r"\[GNUPG:\] VALIDSIG ([0-9A-F]{40})(?:\s+\S+)*?\s+([0-9A-F]{40})\s*$",
|
|
etat, re.M)
|
|
simple = re.search(r"\[GNUPG:\] VALIDSIG ([0-9A-F]{40})", etat)
|
|
if not simple:
|
|
return False, "signature acceptee mais sans empreinte lisible — refuse"
|
|
signataire = simple.group(1)
|
|
primaire = vs.group(2) if vs else signataire
|
|
if empreinte not in (signataire, primaire):
|
|
return False, (f"signee par une AUTRE cle que celle epinglee.\n"
|
|
f" epinglee : {empreinte}\n"
|
|
f" signataire : {signataire}\n"
|
|
f" primaire : {primaire}")
|
|
via = "" if signataire == empreinte else f" (sous-cle {signataire})"
|
|
return True, f"signature valide, faite par {empreinte}{via}"
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--artefact", required=True, type=Path)
|
|
ap.add_argument("--signature", required=True, type=Path)
|
|
ap.add_argument("--cle", required=True, type=Path)
|
|
ap.add_argument("--empreinte", required=True)
|
|
a = ap.parse_args(argv)
|
|
ok, message = verifier(a.artefact, a.signature, a.cle, a.empreinte)
|
|
print((" OK " if ok else " REFUS ") + message)
|
|
if not ok:
|
|
print("\n L'artefact n'est PAS installe. Si le changement est legitime (rotation de\n"
|
|
" cle en amont), il faut RELIRE la nouvelle empreinte par un canal\n"
|
|
" independant, puis mettre a jour la cle du depot et l'epinglage.")
|
|
return 0 if ok else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|