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
|
|
|
#!/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
|
|
|
|
|
|
2026-09-05 11:16:45 -04:00
|
|
|
# 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.")
|
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
|
|
|
print(f" VERIFIE : {len(attendu)} fichier(s) relus, empreintes identiques.")
|
2026-09-05 11:16:45 -04:00
|
|
|
print(f" Acces : {acces}")
|
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
|
|
|
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())
|