Set-OPS-Public/scripts/exporter_cles.py
Daniel Allaire bebdb84212
Some checks are pending
verifier / verifier (push) Waiting to run
cles : sortir du poste ce qui n existe qu au poste
Le code est replique trois fois (eregion, forge du site, patient 0) et les
voutes chiffrees y sont aussi — le coffre est solide. Les CLES qui l ouvrent
vivaient dans neuf fichiers, 1644 octets, sans copie ailleurs.

Poste seul : les mots de passe restic restent lisibles sur les machines vivantes,
donc recuperable mais douloureux. Poste + une machine : l etat de cette machine
devient illisible. Poste + site : terminal.

make cles-recenser montre ce qui sortirait sans rien ecrire — nom, taille,
empreinte, JAMAIS le contenu. make cles-exporter chiffre en AES256 puis
REDECHIFFRE ce qu il vient d ecrire et compare les empreintes une a une : une
sauvegarde de cles qu on n a pas rouverte n est pas une sauvegarde.

A lancer par l exploitant lui-meme : gpg demande une phrase de passe, elle ne
doit passer ni par un journal ni par le contexte d un assistant.

Trois refus, eprouves en les faisant echouer : destination dans l infrastructure
(un coffre dont la cle est dedans), archive existante (elle est peut-etre la
seule), archive illisible (supprimee). Le premier essai du premier refus etait
faux — le shell developpait HOME avant que je le remplace, l instrument mesurait
ailleurs que la cible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-05 09:42:01 -04:00

208 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Sort du poste ce qui n'existe QUE sur le poste — chiffre, puis relu.
CE QUE CE SCRIPT PROTEGE, ET POURQUOI C'EST LE PLUS URGENT (mesure du 2026-09-05).
Le CODE de Set-OPS est replique trois fois : `eregion`, la forge du site, patient 0. Les
voutes chiffrees y sont aussi — le coffre est solide.
Les CLES qui ouvrent ce coffre, elles, vivent dans six fichiers de `~/.config`, 357
octets au total, sans aucune copie ailleurs. S'y ajoutent les cles SSH par lesquelles on
entre sur les hyperviseurs, la frontiere et chaque machine.
Ce qu'on perd avec le poste, dans l'ordre de gravite :
- poste seul : les mots de passe restic sont encore lisibles sur les machines vivantes
(`/etc/setops/restic.pass`), donc recuperable — mais douloureux, et on ne peut plus
rien deployer entre-temps ;
- poste + une machine : l'etat de cette machine devient illisible ;
- poste + site : terminal.
357 octets valent donc l'installation entiere. C'est le pire rapport valeur/fragilite du
systeme, et il se ferme en quelques minutes.
CE QU'IL N'AFFICHE JAMAIS : le contenu des cles. Ni a l'ecran, ni dans un journal. Il
n'en montre que le nom, la taille et l'empreinte — de quoi verifier sans divulguer.
ECRIRE PUIS RELIRE (D-68). Une sauvegarde de cles qu'on n'a pas rouverte n'est pas une
sauvegarde : c'est un fichier dont on espere quelque chose. Ce script REDECHIFFRE ce
qu'il vient d'ecrire et compare les empreintes, une a une.
"""
from __future__ import annotations
import argparse
import hashlib
import os
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
MAISON = Path.home()
# CE QUI N'EXISTE QUE SUR CE POSTE — declare, pas devine.
#
# Les motifs sont volontairement etroits : ce script sort des SECRETS du poste, il ne
# doit emporter que ce qu'on a decide d'emporter. Un `~/.ssh/*` large embarquerait les
# cles d'autres projets, qui n'ont rien a faire dans une sauvegarde Set-OPS.
A_SORTIR = [
(MAISON / ".config", "setops-vault-*",
"Cles des voutes — ce qui ouvre TOUS les secrets chiffres du depot."),
(MAISON / ".ssh", "id_ed25519_ansible_chezlepro",
"Entree sur les hyperviseurs, la frontiere et la flotte de Chezlepro."),
(MAISON / ".ssh", "id_ed25519_ansible_technolibre",
"Entree sur la flotte de Technolibre."),
(MAISON / ".ssh", "id_ed25519",
"Cle declaree au plan de certains ecosystemes."),
(MAISON / ".ssh", "id_git_ed25519",
"Acces aux depots distants du genome."),
]
def _empreinte(chemin: Path) -> str:
return hashlib.sha256(chemin.read_bytes()).hexdigest()
def _recenser() -> list[tuple[Path, str]]:
trouves: list[tuple[Path, str]] = []
for dossier, motif, raison in A_SORTIR:
for f in sorted(dossier.glob(motif)):
if f.is_file():
trouves.append((f, raison))
return trouves
def _refuser_destination_interne(dest: Path) -> None:
"""Une copie DANS l'infrastructure ne protege de rien — elle boucle.
Le depot de sauvegarde du site est chiffre par un mot de passe qui vit dans une
voute, ouverte par les cles qu'on est en train de sauvegarder. Y deposer ces cles
fabriquerait un coffre dont la cle est a l'interieur.
"""
interdits = [MAISON / "Espace Chezlepro", Path("/srv/restic")]
for i in interdits:
try:
dest.resolve().relative_to(i.resolve())
except (ValueError, OSError):
continue
raise SystemExit(
f"REFUS : {dest} est DANS l'infrastructure ({i}).\n"
" Ces cles ouvrent les sauvegardes ; les y ranger ferait un coffre dont la\n"
" cle est a l'interieur. Viser un support amovible ou une machine tierce.")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--vers",
help="Repertoire de destination (support amovible, machine tierce)")
# VOIR CE QUI SORT AVANT DE LE SORTIR. Ce script emporte des secrets : pouvoir en
# lire l'inventaire sans rien ecrire n'est pas un confort, c'est ce qui permet de
# verifier qu'on n'emporte ni trop ni trop peu.
ap.add_argument("--recenser", action="store_true",
help="Montrer ce qui sortirait, sans rien ecrire")
ap.add_argument("--nom", default="setops-cles",
help="Nom de base de l'archive (defaut: setops-cles)")
args = ap.parse_args()
if not args.recenser:
if not args.vers:
raise SystemExit("REFUS : --vers requis (ou --recenser pour voir sans ecrire).")
dest = Path(args.vers).expanduser()
_refuser_destination_interne(dest)
if not dest.is_dir():
raise SystemExit(f"REFUS : {dest} n'existe pas ou n'est pas un repertoire.")
fichiers = _recenser()
if not fichiers:
raise SystemExit("REFUS : aucun fichier a sortir — rien ne correspond aux motifs.")
print(" CE QUI VA SORTIR DU POSTE :\n")
attendu: dict[str, str] = {}
raison_vue: set[str] = set()
for f, raison in fichiers:
if raison not in raison_vue:
print(f" {raison}")
raison_vue.add(raison)
emp = _empreinte(f)
attendu[f.name] = emp
print(f" {f.name:<44} {f.stat().st_size:>5} o {emp[:16]}…")
print(f"\n {len(fichiers)} fichier(s), "
f"{sum(f.stat().st_size for f, _ in fichiers)} octets au total.\n")
if args.recenser:
print(" RECENSEMENT SEUL — rien n'a ete ecrit.")
return 0
archive = dest / f"{args.nom}-{os.uname().nodename}.tar.gpg"
if archive.exists():
raise SystemExit(
f"REFUS : {archive} existe deja.\n"
" On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n"
" La deplacer ou choisir un autre --nom.")
with tempfile.TemporaryDirectory() as tmp:
brut = Path(tmp) / "cles.tar"
with tarfile.open(brut, "w") as tar:
for f, _ in fichiers:
tar.add(f, arcname=f.name)
print(" Une phrase de passe va etre demandee (deux fois : ecriture, puis")
print(" verification). Elle n'est stockee NULLE PART — c'est a toi de la garder,")
print(" et de la garder AILLEURS que sur ce support.\n")
r = subprocess.run(
["gpg", "--symmetric", "--cipher-algo", "AES256", "--s2k-digest-algo",
"SHA512", "--output", str(archive), str(brut)])
if r.returncode != 0 or not archive.exists():
raise SystemExit("ECHEC du chiffrement — rien n'a ete ecrit.")
# ECRIRE PUIS RELIRE. On redechiffre CE QU'ON VIENT D'ECRIRE et on compare les
# empreintes. Sans ca, on rentrerait chez soi avec un fichier dont on ESPERE
# qu'il contient les cles.
print("\n Relecture de ce qui vient d'etre ecrit…\n")
relu = Path(tmp) / "relu.tar"
r = subprocess.run(["gpg", "--quiet", "--output", str(relu),
"--decrypt", str(archive)])
if r.returncode != 0:
archive.unlink(missing_ok=True)
raise SystemExit(
"ECHEC de la relecture — l'archive a ete SUPPRIMEE.\n"
" Une sauvegarde de cles qu'on ne sait pas rouvrir est pire que rien :\n"
" elle donne le sentiment d'etre protege.")
ecarts = []
with tarfile.open(relu) as tar:
noms = tar.getnames()
for nom, emp in attendu.items():
if nom not in noms:
ecarts.append(f"{nom} : ABSENT de l'archive")
continue
membre = tar.extractfile(nom)
obtenu = hashlib.sha256(membre.read()).hexdigest() if membre else ""
if obtenu != emp:
ecarts.append(f"{nom} : empreinte differente")
for nom in noms:
if nom not in attendu:
ecarts.append(f"{nom} : PRESENT en trop dans l'archive")
if ecarts:
archive.unlink(missing_ok=True)
print(" ECARTS — l'archive a ete SUPPRIMEE :", file=sys.stderr)
for e in ecarts:
print(f" ! {e}", file=sys.stderr)
return 1
archive.chmod(0o600)
print(f" VERIFIE : {len(attendu)} fichier(s) relus, empreintes identiques.")
print(f" Archive : {archive} ({archive.stat().st_size} octets)\n")
print(" IL RESTE DEUX GESTES, ET ILS NE SONT PAS FACULTATIFS :")
print(" 1. Ranger la phrase de passe AILLEURS que ce support. Separes, ils ne")
print(" valent rien l'un sans l'autre ; ensemble, ils valent l'installation.")
print(" 2. Faire une SECONDE copie, dans un autre lieu physique. Un support")
print(" unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,")
print(" deplace de quelques metres.")
return 0
if __name__ == "__main__":
raise SystemExit(main())