Set-OPS-Public/scripts/restaurer_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

122 lines
5.3 KiB
Python

#!/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())