Set-OPS-Public/scripts/exporter_cles.py
Daniel Allaire dcbb57db69
Some checks are pending
verifier / verifier (push) Waiting to run
cles : la cle USB se suffit a elle-meme, et la restauration est prouvee
Le jour ou l on s en sert, le poste est mort — et cloner le depot demande la cle
SSH qui est dans l archive qu on essaie d ouvrir. Une procedure rangee dans le
depot serait inaccessible exactement quand elle sert.

L export depose donc restaurer_cles.py et un LISEZ-MOI a cote de l archive. La
cle ne demande plus que gpg, python3 et la phrase de passe.

Le script remet chaque fichier a sa place selon son NOM (machine neuve, parfois
autre compte), repose les droits a 0600 — ssh refuse une cle privee lisible par
d autres, et son message ne dit pas qu il s agit d un droit — et refuse d
ecraser une cle presente, en regardant AVANT d ecrire.

Le LISEZ-MOI porte les trois commandes manuelles. Un outil peut avoir un defaut ;
gpg et tar seront la.

EPROUVE : export vers une cle, poste neuf vide, restauration depuis la cle SEULE,
empreintes comparees — identiques 4 sur 4, droits 700/600, et le refus d ecraser
tire. Le filet manuel passe au meme test separement, identiques 4 sur 4.

make cles-restaurer refusera puisque les cles sont en place — et ce refus est la
preuve que l archive s ouvre et que la phrase de passe est la bonne.

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

314 lines
14 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.")
LISEZ_MOI = """SI TU LIS CECI, LE POSTE EST PROBABLEMENT MORT.
==================================================
Cette cle contient les SECRETS d'une installation Set-OPS : ce qui ouvre les voutes
chiffrees, et les cles SSH qui entrent sur les machines. Le code, lui, est ailleurs —
replique sur plusieurs forges. C'est ici qu'est la partie qu'on ne peut pas refabriquer.
CE QU'IL FAUT, ET RIEN D'AUTRE
------------------------------
- la phrase de passe (elle n'est PAS sur cette cle, c'est voulu) ;
- `gpg` et `python3`, presents sur tout Debian ou Ubuntu.
LA MANOEUVRE
------------
python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien ecrire
python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place
Le script remet chaque fichier a sa place (~/.config ou ~/.ssh) et repose les droits a
0600. Ce dernier point n'est pas cosmetique : `ssh` REFUSE une cle privee que d'autres
peuvent lire, et son message ne dit pas qu'il s'agit d'un droit. Il refuse aussi
d'ecraser une cle deja presente — lance par erreur sur un poste qui marche, il ne
detruit rien.
SI MEME CE SCRIPT NE TOURNE PAS
-------------------------------
Trois commandes suffisent. C'est le vrai filet :
gpg --output cles.tar --decrypt setops-cles-*.tar.gpg
mkdir -p ~/.config ~/.ssh && tar -xf cles.tar -C /tmp
cp /tmp/setops-vault-* ~/.config/ ; cp /tmp/id_* ~/.ssh/
chmod 700 ~/.ssh ~/.config ; chmod 600 ~/.ssh/id_* ~/.config/setops-vault-*
ENSUITE, ESSAYER — PAS SUPPOSER
-------------------------------
ssh -o BatchMode=yes ansible@<un hyperviseur> hostname
python3 scripts/voutes.py etat (depuis le depot, une fois clone)
Une cle remise en place qu'on n'a pas essayee n'est pas une cle restauree.
OU EST LE RESTE
---------------
Le depot Set-OPS vit sur plusieurs forges ; les adresses sont dans `.git/config` de
n'importe quelle copie. Les sauvegardes des machines vivent sur le depot du site, et
leur mot de passe restic est dans les voutes que cette cle ouvre.
"""
def _deposer_compagnons(dest: Path) -> list[str]:
"""Le script de restauration et son mode d'emploi, A COTE de l'archive.
POURQUOI CE N'EST PAS FACULTATIF (2026-09-05). Le jour ou l'on ouvre cette cle, le
poste est mort. Le depot Set-OPS est replique trois fois — mais le CLONER demande la
cle SSH, qui est dans l'archive qu'on essaie justement d'ouvrir. Une procedure de
restauration rangee dans le depot serait donc inaccessible exactement quand elle sert.
La cle doit se suffire a elle-meme. On y depose le script, et un LISEZ-MOI qui porte
en plus les commandes manuelles — pour le cas ou meme le script refuserait de tourner.
"""
poses = []
src = Path(__file__).parent / "restaurer_cles.py"
if src.is_file():
(dest / "restaurer_cles.py").write_bytes(src.read_bytes())
(dest / "restaurer_cles.py").chmod(0o755)
poses.append("restaurer_cles.py")
(dest / "LISEZ-MOI-RESTAURATION.txt").write_text(LISEZ_MOI, encoding="utf-8")
poses.append("LISEZ-MOI-RESTAURATION.txt")
return poses
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")
# POUR UNE CLE DEJA FAITE. L'archive n'est pas refaite — on ne redemande pas une
# phrase de passe et on ne risque pas d'ecraser ce qui est deja verifie.
ap.add_argument("--compagnons", action="store_true",
help="Deposer seulement le script de restauration et son mode d'emploi")
ap.add_argument("--nom", default="setops-cles",
help="Nom de base de l'archive (defaut: setops-cles)")
args = ap.parse_args()
if args.compagnons:
if not args.vers:
raise SystemExit("REFUS : --vers requis.")
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.")
for nom in _deposer_compagnons(dest):
print(f" + {dest / nom}")
print("\n La cle se suffit desormais a elle-meme : script, mode d'emploi, et")
print(" les commandes manuelles si meme le script refuse de tourner.")
return 0
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
# UN `chmod` QUI ECHOUE NE DOIT PAS ANNULER UNE VERIFICATION REUSSIE.
#
# Une cle USB est le plus souvent en FAT32 ou exFAT — des systemes qui n'ont pas de
# permissions Unix. `chmod` y echoue ou n'a aucun effet, selon les options de montage.
# Sans cette garde, le script mourrait sur sa DERNIERE ligne, apres avoir ecrit et
# verifie l'archive : l'exploitant verrait une trace d'erreur et douterait d'un
# travail pourtant termine. Le pire moment pour semer un doute.
#
# On le DIT plutot que de le taire : sur un support sans permissions, l'archive est
# lisible par quiconque branche la cle. C'est precisement pourquoi elle est chiffree —
# mais il faut le savoir pour choisir ou ranger la cle.
try:
archive.chmod(0o600)
acces = "0600 (lisible par toi seul)"
except OSError:
acces = ("permissions non applicables — support sans droits Unix (FAT/exFAT). "
"L'archive est lisible par qui branche la cle : c'est le chiffrement "
"qui protege, pas le support.")
for nom in _deposer_compagnons(dest):
print(f" + {nom}")
print(f" VERIFIE : {len(attendu)} fichier(s) relus, empreintes identiques.")
print(f" Acces : {acces}")
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())