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())