cles : la cle USB se suffit a elle-meme, et la restauration est prouvee
Some checks are pending
verifier / verifier (push) Waiting to run
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:
parent
3eff217bd5
commit
dcbb57db69
5 changed files with 319 additions and 0 deletions
50
CHANGELOG.md
50
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
|
||||
|
|
|
|||
17
Makefile
17
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
122
scripts/restaurer_cles.py
Normal 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())
|
||||
Loading…
Reference in a new issue