keycloak : verifier la signature PGP contre une empreinte epinglee

« 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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-10 23:35:32 -04:00
parent 3d62a044f7
commit dea7769ff3
5 changed files with 240 additions and 0 deletions

View file

@ -1,5 +1,64 @@
# CHANGELOG — Set-OPS # 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 ## 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 ? »* Question de l'exploitant : *« ne devrait-on pas prendre les versions les plus récentes ? »*

View file

@ -187,3 +187,22 @@ serveur_keycloak_journal_retention: 1209600
# puis pousse par SSH : aucun flux nouveau, et l'artefact devient deployable hors ligne. # puis pousse par SSH : aucun flux nouveau, et l'artefact devient deployable hors ligne.
# Surchargeable par `setops_cache_artefacts` pour partager un cache commun. # Surchargeable par `setops_cache_artefacts` pour partager un cache commun.
serveur_keycloak_cache_local: "{{ setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops') }}" 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"

View file

@ -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-----

View file

@ -115,6 +115,44 @@
- not serveur_keycloak_archive_present.stat.exists - not serveur_keycloak_archive_present.stat.exists
- not (serveur_keycloak_cache_present.stat.exists | default(false)) - 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 - name: Déposer la distribution Keycloak sur l hôte depuis le cache
ansible.builtin.copy: ansible.builtin.copy:
src: "{{ serveur_keycloak_cache_local }}/keycloak-{{ serveur_keycloak_version }}.tar.gz" src: "{{ serveur_keycloak_cache_local }}/keycloak-{{ serveur_keycloak_version }}.tar.gz"

View file

@ -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/<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.
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())