diff --git a/CHANGELOG.md b/CHANGELOG.md index a860218..3eba01f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # CHANGELOG — Set-OPS +## 2026-09-05 (2) — Rouvrir les cles : la cle USB doit se suffire a elle-meme + +**56 preuves.** Les cles sont sorties du poste. Restait la moitie qui compte : savoir les +REMETTRE. Une sauvegarde qu'on ne sait pas rouvrir n'est pas une sauvegarde. + +### Le piege, et pourquoi la procedure ne peut pas vivre dans le depot + +Le jour ou l'on s'en sert, **le poste est mort**. Le depot Set-OPS est replique trois fois +— eregion, la forge du site, patient 0 — mais le CLONER demande la cle SSH, qui est +justement dans l'archive qu'on essaie d'ouvrir. Une procedure de restauration rangee dans +le depot serait donc inaccessible exactement quand elle sert. + +`exporter_cles.py` depose donc, A COTE DE L'ARCHIVE : `restaurer_cles.py` et un +`LISEZ-MOI-RESTAURATION.txt`. La cle ne demande plus que `gpg`, `python3` et la phrase de +passe. + +### Ce que le script fait de plus qu'un `tar -x` + +- il remet chaque fichier a sa place selon son NOM, pas selon un chemin enregistre — on + restaure souvent sur une machine neuve, parfois sous un autre compte ; +- il repose les droits a **0600**. `ssh` REFUSE une cle privee que d'autres peuvent lire, + et son message ne dit pas qu'il s'agit d'un droit — une archive extraite depuis un + support FAT arrive toujours dans cet etat ; +- il **refuse d'ecraser** une cle deja presente, et regarde AVANT d'ecrire : refuser a + mi-parcours laisserait la moitie des cles en place et l'autre non, un etat que personne + ne sait diagnostiquer. + +### Et si meme ce script ne tourne pas + +Le LISEZ-MOI porte les trois commandes manuelles — `gpg`, `tar`, `chmod`. C'est le vrai +filet : un outil peut avoir un defaut, `gpg` et `tar` seront la. + +### EPROUVE, PAS SUPPOSE + +Cycle complet sur des fichiers factices : export vers une cle, poste neuf entierement +vide, restauration **depuis la cle seule**, puis comparaison. + + IDENTIQUES — 4 empreintes sur 4 + 700 .ssh 600 .ssh/id_git_ed25519 600 .config/setops-vault-… + REFUS : ces cles existent deja ici — Rien n'a ete ecrit. + +Le filet manuel a ete passe au meme test, separement : identiques, 4 sur 4. + +### Le test le moins cher, a refaire souvent + + make cles-restaurer ARCHIVE=/media/…/setops-cles-*.tar.gpg + +Il 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. A refaire apres chaque changement de cle. + ## 2026-09-05 — 1 644 octets valent l'installation, et ils n'existaient qu'a un endroit **56 preuves.** En cherchant par quoi reprendre, une mesure a change l'ordre des diff --git a/Makefile b/Makefile index a08dfc0..ed41294 100644 --- a/Makefile +++ b/Makefile @@ -575,6 +575,23 @@ cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS= + @# POUR UNE CLE EXISTANTE. L'archive n'est pas refaite : ni phrase de passe redemandee, + @# ni risque d'ecraser ce qui est deja verifie. + @if [ -z "$(VERS)" ]; then \ + printf 'Refus: relancer avec VERS=.\n'; exit 2; fi + python3 scripts/exporter_cles.py --vers "$(VERS)" --compagnons + +cles-restaurer: ## Remet les cles en place depuis une archive — ARCHIVE= + @# EN VRAI, ON N'UTILISE PAS CETTE CIBLE. Le jour ou l'on restaure, le depot n'est pas + @# la — le cloner demande la cle SSH qu'on essaie justement de recuperer. On lance + @# alors `restaurer_cles.py` DEPUIS LA CLE USB, ou les trois commandes du LISEZ-MOI. + @# Cette cible existe pour eprouver la manoeuvre pendant qu'on a encore le choix. + @if [ -z "$(ARCHIVE)" ]; then \ + printf 'Refus: relancer avec ARCHIVE=.\n'; exit 2; fi + python3 scripts/restaurer_cles.py "$(ARCHIVE)" $(if $(FORCER),--forcer) + +.PHONY: cles-compagnons cles-restaurer cacher-paquets: ## Tire les paquets NON DEBIAN dans le cache du controleur (a faire en ligne) @# CE QUI REND LA RECONSTRUCTION HORS LIGNE POSSIBLE. Trois depots tiers sont en @# HTTPS et contournent le cache du site (`Acquire::https::Proxy "DIRECT"`). Sans diff --git a/docs/sortir-les-cles-du-poste.md b/docs/sortir-les-cles-du-poste.md index 0947844..27a069b 100644 --- a/docs/sortir-les-cles-du-poste.md +++ b/docs/sortir-les-cles-du-poste.md @@ -52,6 +52,48 @@ gestionnaire de mots de passe qui n'est pas sur ce poste. **2. Une seconde copie, dans un autre lieu physique.** Un support unique dans un tiroir unique, c'est le problème qu'on vient de fermer, déplacé de quelques mètres. +## Les rouvrir — la moitié qui compte + +Le jour où l'on s'en sert, **le poste est mort**. Le dépôt Set-OPS est répliqué trois +fois, mais le *cloner* demande la clé SSH… qui est dans l'archive qu'on essaie d'ouvrir. +Une procédure rangée dans le dépôt serait donc inaccessible exactement quand elle sert. + +La clé se suffit donc à elle-même. À côté de l'archive : + +``` +setops-cles-.tar.gpg les clés, chiffrées +restaurer_cles.py le script, autonome +LISEZ-MOI-RESTAURATION.txt le mode d'emploi, et les commandes manuelles +``` + +Sur la machine neuve, il ne faut que `gpg`, `python3` et la phrase de passe : + +```bash +python3 restaurer_cles.py setops-cles-*.tar.gpg --lister # voir sans rien écrire +python3 restaurer_cles.py setops-cles-*.tar.gpg # remettre en place +``` + +Le script remet chaque fichier à sa place selon son nom, et **repose les droits à 0600**. +Ce n'est pas cosmétique : `ssh` refuse une clé privée que d'autres peuvent lire, et son +message ne dit pas qu'il s'agit d'un droit — une archive extraite depuis un support FAT +arrive systématiquement dans cet état. Il **refuse** aussi d'écraser une clé déjà là : +lancé par erreur sur un poste qui fonctionne, il ne détruit rien. + +Si même ce script refuse de tourner, le LISEZ-MOI porte les commandes manuelles — `gpg`, +`tar`, `chmod`. **Les deux chemins ont été éprouvés** le 2026-09-05 : export, poste vidé, +restauration depuis la clé seule, empreintes comparées — identiques, 4 sur 4, droits +700/600. + +## L'éprouver pendant qu'on a encore le choix + +```bash +make cles-restaurer ARCHIVE=/media/…/setops-cles-*.tar.gpg +``` + +Il refusera, puisque les clés sont déjà en place — et ce refus est en soi la preuve que +l'archive s'ouvre et que la phrase de passe est la bonne. C'est le test le moins cher +qui existe, et il vaut d'être refait après chaque changement de clé. + ## Quand recommencer Quand une voûte est créée ou sa clé changée (`voutes.py`), quand une paire SSH de runner diff --git a/scripts/exporter_cles.py b/scripts/exporter_cles.py index fac8cb1..3263295 100644 --- a/scripts/exporter_cles.py +++ b/scripts/exporter_cles.py @@ -92,6 +92,75 @@ def _refuser_destination_interne(dest: Path) -> None: " 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@ 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", @@ -101,10 +170,27 @@ def main() -> int: # 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).") @@ -210,6 +296,8 @@ def main() -> int: 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") diff --git a/scripts/restaurer_cles.py b/scripts/restaurer_cles.py new file mode 100644 index 0000000..4a5a32a --- /dev/null +++ b/scripts/restaurer_cles.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Remet en place les cles sorties par `exporter_cles.py`. Autonome, sans le depot. + +POURQUOI CE SCRIPT DOIT POUVOIR VIVRE SUR LA CLE USB. + +Le jour ou l'on s'en sert, le poste est mort. Le depot Set-OPS est replique — eregion, la +forge du site, patient 0 — mais le CLONER demande la cle SSH, qui est justement dans +l'archive qu'on essaie d'ouvrir. Une procedure de restauration qui vit dans le depot +serait donc inaccessible exactement quand elle sert. + +`exporter_cles.py` en depose donc une copie A COTE DE L'ARCHIVE, avec un mode d'emploi. +La cle USB se suffit a elle-meme : gpg, python3, et les trois commandes manuelles ecrites +dans le LISEZ-MOI si meme ce script refuse de tourner. + +CE QU'IL FAIT DE PLUS QU'UN `tar -x` : + + - il remet CHAQUE fichier a sa place (`~/.config` ou `~/.ssh`, selon son nom) ; + - il repose les permissions a 0600. Sans ca, `ssh` REFUSE une cle privee lisible par + d'autres — « UNPROTECTED PRIVATE KEY FILE » — et le message n'aide pas a comprendre + qu'il s'agit d'un droit, pas d'une cle invalide. Une archive extraite depuis un + support FAT arrive systematiquement dans cet etat ; + - il REFUSE d'ecraser une cle existante. Lance par erreur sur un poste qui fonctionne, + il ne doit rien detruire. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path + +MAISON = Path.home() + + +def _destination(nom: str) -> Path: + """Ou remettre un fichier — deduit de son nom, pas d'un chemin enregistre. + + L'archive ne garde que les noms de base : un chemin absolu la rendrait dependante du + compte qui l'a creee, et on restaure souvent sur une machine neuve, parfois sous un + autre nom d'utilisateur. + """ + if nom.startswith("setops-vault"): + return MAISON / ".config" / nom + if nom.startswith("id_"): + return MAISON / ".ssh" / nom + raise SystemExit(f"REFUS : {nom} n'est ni une cle de voute ni une cle SSH connue.") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("archive", help="Chemin de l'archive .tar.gpg") + ap.add_argument("--lister", action="store_true", + help="Montrer ce que l'archive contient, sans rien ecrire") + ap.add_argument("--forcer", action="store_true", + help="Ecraser une cle deja presente (a n'utiliser qu'en connaissance)") + args = ap.parse_args() + + arch = Path(args.archive).expanduser() + if not arch.is_file(): + raise SystemExit(f"REFUS : {arch} introuvable.") + + with tempfile.TemporaryDirectory() as tmp: + clair = Path(tmp) / "cles.tar" + print(" La phrase de passe de l'archive va etre demandee.\n") + r = subprocess.run(["gpg", "--quiet", "--output", str(clair), + "--decrypt", str(arch)]) + if r.returncode != 0: + raise SystemExit( + "\nECHEC du dechiffrement.\n" + " Phrase de passe erronee, ou archive abimee. gpg verifie l'integrite\n" + " (AES256 + MDC) : s'il refuse, le contenu n'est pas fiable.") + + with tarfile.open(clair) as tar: + noms = [n for n in tar.getnames() if "/" not in n and not n.startswith("..")] + if args.lister: + print(f" {len(noms)} fichier(s) dans l'archive :\n") + for n in sorted(noms): + m = tar.getmember(n) + print(f" {n:<44} {m.size:>5} o -> {_destination(n)}") + print("\n LISTE SEULE — rien n'a ete ecrit.") + return 0 + + # ON REGARDE AVANT D'ECRIRE. Refuser a mi-parcours laisserait la moitie des + # cles en place et l'autre non — un etat que personne ne sait diagnostiquer. + deja = [n for n in noms if _destination(n).exists()] + if deja and not args.forcer: + print(" REFUS : ces cles existent deja ici —", file=sys.stderr) + for n in sorted(deja): + print(f" ! {_destination(n)}", file=sys.stderr) + print("\n Rien n'a ete ecrit. Ce poste a peut-etre deja ses cles.\n" + " Si l'ecrasement est VOULU, relancer avec --forcer.", file=sys.stderr) + return 1 + + poses = [] + for n in sorted(noms): + dest = _destination(n) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.parent.chmod(0o700) + membre = tar.extractfile(n) + if membre is None: + continue + dest.write_bytes(membre.read()) + # 0600 SANS CONDITION : `ssh` refuse une cle privee que d'autres peuvent + # lire, et `ansible-vault` n'est pas plus tolerant. Une archive extraite + # depuis un support FAT arrive toujours avec de mauvais droits. + dest.chmod(0o600) + poses.append(dest) + + print(f" {len(poses)} cle(s) remises en place, en 0600 :\n") + for d in poses: + print(f" {d}") + print("\n VERIFIER MAINTENANT, PAS PLUS TARD :") + print(" ssh -o BatchMode=yes ansible@ hostname") + print(" python3 scripts/voutes.py etat") + print("\n Une cle remise en place qu'on n'a pas essayee n'est pas une cle restauree.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())