cles : la cle USB se suffit a elle-meme, et la restauration est prouvee
Some checks are pending
verifier / verifier (push) Waiting to run

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
This commit is contained in:
Daniel Allaire 2026-09-05 13:33:31 -04:00
parent 3eff217bd5
commit dcbb57db69
5 changed files with 319 additions and 0 deletions

View file

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

View file

@ -575,6 +575,23 @@ cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS=<repertoi
python3 scripts/exporter_cles.py --vers "$(VERS)"
.PHONY: cles-recenser cles-exporter
cles-compagnons: ## Depose le script de restauration sur une cle DEJA faite — VERS=<repertoire>
@# 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=<repertoire de la cle>.\n'; exit 2; fi
python3 scripts/exporter_cles.py --vers "$(VERS)" --compagnons
cles-restaurer: ## Remet les cles en place depuis une archive — ARCHIVE=<fichier>
@# 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=<chemin de l archive .tar.gpg>.\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

View file

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

View file

@ -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@<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",
@ -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")

122
scripts/restaurer_cles.py Normal file
View file

@ -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@<un hyperviseur> 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())