From dea7769ff3b45bfdaa1d568fd9de000c2244bee5 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 10 Aug 2026 23:35:32 -0400 Subject: [PATCH] keycloak : verifier la signature PGP contre une empreinte epinglee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « J'ai besoin d'une confiance reelle. Keycloak est probablement l'element le plus dangereux de cet ecosysteme. » C'est exact : il signe les jetons de TOUT l'ecosysteme, une archive substituee la et l'identite entiere tombe. CORRECTION D'ABORD. J'avais ecrit que Keycloak ne publie aucune somme de controle. Faux, et l'exploitant l'a releve. Mesure : .sha1 et .md5 existaient jusqu'a 26.6.2 puis ont disparu a partir de 26.7.0 ; le .asc, lui, est present sur toutes les versions — et je l'avais rate, sans meme le chercher. Une somme prouve qu'un fichier n'est pas corrompu ; une signature prouve QUI l'a produit. ETABLI : la meme cle 861AB50E...6FD6EEBA a signe 26.0.7 (alors en production), 26.3.0, 26.6.2 et 26.7.1. NON ETABLI : aucune source independante ne publie cette empreinte — ni keycloak.org, ni SECURITY.md, ni un fichier KEYS ; absente de keys.openpgp.org, trouvee sur keyserver.ubuntu.com qui n'est pas une autorite. On prouve la continuite, pas l'origine. L'ancre reste une decision humaine — desormais ecrite, versionnee, et verifiee a chaque telechargement. scripts/verifier_signature.py impose trois choses, chacune contre un contournement precis : la cle publique vit DANS LE DEPOT (aucun serveur de cles au deploiement) ; l'empreinte est EPINGLEE, donc une rotation amont devient un echec bruyant ; trousseau JETABLE, donc le resultat ne depend pas du trousseau personnel. Il lit VALIDSIG et compare l'empreinte du signataire REEL — « bonne signature » seule laisserait passer une signature valide faite par une autre cle du trousseau. Eprouve sur cinq cas : nominal 0 ; artefact altere d'un octet 1 ; empreinte differente 1 ; cle du depot corrompue 1 ; signature absente 1. Ce que ca ne prouve PAS : que l'empreinte epinglee soit la bonne. Aucune machine ne peut l'etablir ; le script garantit qu'on ne s'en ecarte plus sans le voir. Verifie : role applique de bout en bout sur idm-01, ansible-lint production, prouver.py 35 OK. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 59 ++++++++++ roles/serveur_keycloak/defaults/main.yml | 19 +++ .../files/keycloak-release.asc | 14 +++ roles/serveur_keycloak/tasks/main.yml | 38 ++++++ scripts/verifier_signature.py | 110 ++++++++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 roles/serveur_keycloak/files/keycloak-release.asc create mode 100644 scripts/verifier_signature.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fcb3db..b9d4ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # CHANGELOG — Set-OPS +## 2026-08-10 — Keycloak : vérifier QUI a produit l'archive, pas seulement qu'elle est intacte + +L'exploitant : *« j'ai besoin d'une confiance réelle. Keycloak est probablement l'élément le +plus dangereux de cet écosystème. »* C'est exact — Keycloak signe les jetons de **tout** +l'écosystème. Une archive substituée là, et l'identité entière tombe. + +### Une correction, d'abord + +J'avais écrit que « Keycloak ne publie aucune somme de contrôle ». **Faux, et l'exploitant +l'a relevé.** Mesuré ensuite : + +``` +26.6.2 : .sha1 200 .md5 200 .asc 200 +26.7.0 : .sha1 404 .md5 404 .asc 200 +26.7.1 : .sha1 404 .md5 404 .asc 200 +``` + +Les sommes existaient **jusqu'à 26.6.2**, puis ont disparu. Et surtout : j'avais raté le +`.asc` — une **signature PGP**, présente sur toutes les versions, et plus forte qu'une somme. +Une somme prouve qu'un fichier n'a pas été corrompu ; une signature prouve **qui l'a +produit**. + +### Ce qui est établi, et ce qui ne l'est pas + +La même clé `861AB50E…6FD6EEBA` a signé **26.0.7** (la version alors en production), +**26.3.0**, **26.6.2** et **26.7.1**. C'est une continuité réelle. + +Mais **aucune source indépendante ne publie cette empreinte** : ni `keycloak.org/downloads`, +ni la page *getting started*, ni `SECURITY.md`, ni un fichier `KEYS`. Elle est absente de +`keys.openpgp.org` ; on la trouve sur `keyserver.ubuntu.com`, qui n'est pas une autorité. Et +la somme `.sha1` n'ajoute rien : même canal que l'archive et la signature. + +**On peut donc prouver la continuité, pas l'origine.** L'ancre est une décision humaine — et +elle est maintenant *écrite*, versionnée, et vérifiée à chaque téléchargement. + +### `scripts/verifier_signature.py` + +Trois exigences, chacune contre un contournement précis : + +- **la clé publique vit dans le dépôt** (`roles/serveur_keycloak/files/keycloak-release.asc`), + versionnée et relue — aucune interrogation de serveur de clés au déploiement ; +- **l'empreinte est épinglée** à côté de la version : une rotation de clé en amont devient un + échec bruyant qui exige une relecture, pas un remplacement silencieux ; +- **trousseau jetable** (`GNUPGHOME` temporaire) : le trousseau personnel n'est ni lu ni + modifié, et deux machines donnent la même réponse. + +Il lit `VALIDSIG` et compare l'empreinte du **signataire réel** à celle épinglée — se +contenter de « bonne signature » laisserait passer une signature valide faite par une autre +clé du trousseau. + +**Éprouvé sur cinq cas** : nominal `0` ; artefact altéré d'un octet `1` ; empreinte épinglée +différente `1` ; clé du dépôt corrompue `1` ; signature absente `1`. + +### Ce que ça ne prouve pas + +Que l'empreinte épinglée soit la bonne. Aucune machine ne peut l'établir. Le script garantit +seulement qu'on ne s'en écarte plus sans le voir. + + ## 2026-08-10 — `make versions-mesurer` : l'écart avec l'amont devient lisible Question de l'exploitant : *« ne devrait-on pas prendre les versions les plus récentes ? »* diff --git a/roles/serveur_keycloak/defaults/main.yml b/roles/serveur_keycloak/defaults/main.yml index e9c1f7d..b1ca55a 100644 --- a/roles/serveur_keycloak/defaults/main.yml +++ b/roles/serveur_keycloak/defaults/main.yml @@ -187,3 +187,22 @@ serveur_keycloak_journal_retention: 1209600 # puis pousse par SSH : aucun flux nouveau, et l'artefact devient deployable hors ligne. # Surchargeable par `setops_cache_artefacts` pour partager un cache commun. serveur_keycloak_cache_local: "{{ setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops') }}" + +# SIGNATURE DE L'ARCHIVE — Keycloak signe ses releases (PGP detachee, `.asc`). +# +# L'empreinte ci-dessous est une DECISION HUMAINE, pas une valeur decouverte : aucune +# machine ne peut etablir qu'une cle appartient bien a un projet. Elle doit etre confirmee +# par un canal INDEPENDANT du telechargement, puis ecrite ici — et la cle publique +# correspondante vit dans `files/keycloak-release.asc`, versionnee et relue. +# +# Ce que ca protege : Keycloak signe les jetons de tout l'ecosysteme. Une archive +# substituee la, et l'identite entiere tombe. C'est le composant ou une verification +# d'authenticite compte le plus. +# +# Continuite constatee le 2026-08-10 : cette meme cle a signe 26.0.7 (la version alors en +# production), 26.3.0, 26.6.2 et 26.7.1. +# +# Un changement d'empreinte en amont fera ECHOUER le remplissage du cache — c'est voulu : +# il exige une relecture humaine, pas un remplacement silencieux. +serveur_keycloak_signature_empreinte: "861AB50E8CC6611FB6BC01A6B8F12EA26FD6EEBA" +serveur_keycloak_signature_url: "{{ serveur_keycloak_url }}.asc" diff --git a/roles/serveur_keycloak/files/keycloak-release.asc b/roles/serveur_keycloak/files/keycloak-release.asc new file mode 100644 index 0000000..afb81f7 --- /dev/null +++ b/roles/serveur_keycloak/files/keycloak-release.asc @@ -0,0 +1,14 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEZcsYGxYJKwYBBAHaRw8BAQdAA32swlQ3Z3tyI0iJAY0XMQEVwUXe7nHnjKWs +OCdIPNi0JUtleWNsb2FrIEJvdCA8a2V5Y2xvYWsuYm90QGdtYWlsLmNvbT6ImQQT +FgoAQRYhBIYatQ6MxmEftrwBprjxLqJv1u66BQJlyxgbAhsDBQkFo5qABQsJCAcC +AiICBhUKCQgLAgQWAgMBAh4HAheAAAoJELjxLqJv1u66mOwBANRuVTHzqTknxFKQ +pi5Mrb2oBbImTEgLkiUYzKidRMlxAP9C01g/l1/6EqGoW8EVwx5ZpjvoyPa9xGrH +oYk8lSh8Drg4BGXLGBsSCisGAQQBl1UBBQEBB0BP0g9y7SpLR8mx+JvOKLactT1O +ukxvjuc25/TqxVKDJQMBCAeIfgQYFgoAJhYhBIYatQ6MxmEftrwBprjxLqJv1u66 +BQJlyxgbAhsMBQkFo5qAAAoJELjxLqJv1u66L0sBANSOIz9YtRp6lKeUf/MFp7aX +F3SbVD+A1zcxAkG7zb8UAP9wEZEe9LJRj+vvMA//B2dkldazB5XSd8kQMzbCZHwL +Cw== +=eS/r +-----END PGP PUBLIC KEY BLOCK----- diff --git a/roles/serveur_keycloak/tasks/main.yml b/roles/serveur_keycloak/tasks/main.yml index 40862b5..e7765f3 100644 --- a/roles/serveur_keycloak/tasks/main.yml +++ b/roles/serveur_keycloak/tasks/main.yml @@ -115,6 +115,44 @@ - not serveur_keycloak_archive_present.stat.exists - not (serveur_keycloak_cache_present.stat.exists | default(false)) +# VERIFIER LA SIGNATURE AVANT DE POSER QUOI QUE CE SOIT SUR UN HOTE. +# +# Keycloak signe les jetons de tout l'ecosysteme : une archive substituee ici, et +# l'identite entiere tombe. C'est le seul composant ou l'authenticite — QUI a produit ce +# fichier — compte plus que l'integrite. +# +# La verification tourne sur le CONTROLEUR, contre la cle versionnee du depot et +# l'empreinte epinglee. Elle ne consulte aucun serveur de cles : un serveur de cles n'est +# pas une autorite, et en dependre au moment du deploiement rendrait la garde aussi +# fragile que ce qu'elle protege. +- name: Récupérer la signature détachée de l archive + ansible.builtin.get_url: + url: "{{ serveur_keycloak_signature_url }}" + dest: "{{ serveur_keycloak_cache_local }}/keycloak-{{ serveur_keycloak_version }}.tar.gz.asc" + mode: "0644" + force: true + delegate_to: localhost + become: false + when: not serveur_keycloak_archive_present.stat.exists + +- name: Vérifier la signature de l archive contre l empreinte épinglée + ansible.builtin.command: + argv: + - python3 + - "{{ role_path }}/../../scripts/verifier_signature.py" + - --artefact + - "{{ serveur_keycloak_cache_local }}/keycloak-{{ serveur_keycloak_version }}.tar.gz" + - --signature + - "{{ serveur_keycloak_cache_local }}/keycloak-{{ serveur_keycloak_version }}.tar.gz.asc" + - --cle + - "{{ role_path }}/files/keycloak-release.asc" + - --empreinte + - "{{ serveur_keycloak_signature_empreinte }}" + delegate_to: localhost + become: false + changed_when: false + when: not serveur_keycloak_archive_present.stat.exists + - name: Déposer la distribution Keycloak sur l hôte depuis le cache ansible.builtin.copy: src: "{{ serveur_keycloak_cache_local }}/keycloak-{{ serveur_keycloak_version }}.tar.gz" diff --git a/scripts/verifier_signature.py b/scripts/verifier_signature.py new file mode 100644 index 0000000..c1a891b --- /dev/null +++ b/scripts/verifier_signature.py @@ -0,0 +1,110 @@ +#!/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//files/.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. + vs = re.search(r"\[GNUPG:\] VALIDSIG ([0-9A-F]{40})", etat) + if not vs: + return False, "signature acceptee mais sans empreinte lisible — refuse" + if vs.group(1) != empreinte: + return False, (f"signee par une AUTRE cle que celle epinglee.\n" + f" epinglee : {empreinte}\n" + f" signataire : {vs.group(1)}") + return True, f"signature valide, faite par {empreinte}" + + +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())