Livrer se terminait par une phrase — tes cles te seront remises separement — et rien n ecrivait la suite. Temps 1 l identite (sa cle de voute, sa voute, sa racine d AC), temps 2 la machine a echeance (sa cle entre, la notre sort, voute re-cletee, secrets tournes). scripts/remise.py refuse une destination interne, un paquet sans racine d AC, et tout ce qui n est pas l ecosysteme monte. Le registre remise.yml declare enfin le responsable designe (D-18). P80 refuse un registre incomplet, un second temps echu, un second temps declare fait sans revocation au plan, et un secret dans un fichier versionne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
570 lines
25 KiB
Python
570 lines
25 KiB
Python
#!/usr/bin/env python3
|
|
"""Remet un ecosysteme a son proprietaire — le paquet, le registre, et la garde.
|
|
|
|
POURQUOI CE SCRIPT EXISTE (2026-09-16). Livrer un ecosysteme se terminait par une phrase :
|
|
« tes cles te seront remises separement ». Ce qui se passait ensuite n'etait ecrit nulle
|
|
part — ni ce qu'on remet, ni dans quel ordre, ni ce qu'on garde. Un geste qui donne le
|
|
controle d'une organisation ne peut pas etre le seul geste du depot sans procedure, sans
|
|
outil et sans preuve.
|
|
|
|
LA REMISE SE FAIT EN DEUX TEMPS, et ce decoupage est tout le sujet.
|
|
|
|
TEMPS 1 — l'IDENTITE. Le client recoit de quoi gouverner ses gens tout de suite : la
|
|
cle de SA voute, sa voute chiffree, la racine de SON autorite. Des la premiere
|
|
connexion il cree, retire et habilite sans nous. L'hebergeur garde l'acces MACHINE,
|
|
parce qu'il exploite encore.
|
|
|
|
TEMPS 2 — la MACHINE. A une date convenue : la cle SSH du client entre au plan, celle
|
|
de l'hebergeur en sort, la voute change de mot de passe, les secrets applicatifs
|
|
tournent. C'est l'etape 8 de `docs/migration-tenant.md` — « revoquer, pas
|
|
transmettre » — appliquee a une livraison plutot qu'a un depart.
|
|
|
|
CE QUE CE SCRIPT NE FAIT JAMAIS :
|
|
|
|
- il n'affiche AUCUNE valeur de secret. Ni a l'ecran, ni dans le registre, ni dans un
|
|
journal : seulement des noms, des tailles et des empreintes SHA256 ;
|
|
- il n'emporte QUE l'ecosysteme monte. La cle du SITE et celles des autres locataires
|
|
vivent dans le meme dossier `~/.config` ; un motif large les embarquerait toutes, et
|
|
remettre a un client les secrets de ses voisins serait la faute que cette procedure
|
|
existe pour rendre impossible ;
|
|
- il ne fait pas tourner les secrets a la place d'un humain. Une valeur se saisit par
|
|
`voute.py saisir --remplacer`, sans echo. Un script qui engendre et ecrit tout seul
|
|
connait ce qu'il ecrit ;
|
|
- il ne DECLARE pas le temps 2 fait parce qu'on le lui dit. Il le MESURE dans le plan :
|
|
la cle du client presente, celle de l'hebergeur absente, la cle de voute changee.
|
|
|
|
Usage :
|
|
python3 scripts/remise.py recenser # ce qui partirait, sans rien ecrire
|
|
python3 scripts/remise.py paquet --vers <repertoire> # temps 1 : fabrique et relit
|
|
python3 scripts/remise.py inscrire --recu-par "…" --courriel "…" [--dans N_JOURS]
|
|
python3 scripts/remise.py verifier # etat de la remise (code de sortie)
|
|
python3 scripts/remise.py recleer --confirmer # temps 2 : mesure, puis estampille
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import hashlib
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
from inventory_rules import ecriture_atomique, instance_courante # noqa: E402
|
|
from voutes import voutes # noqa: E402
|
|
|
|
MAISON = Path.home()
|
|
FICHIER_REMISE = "remise.yml"
|
|
|
|
# Le delai par defaut du second temps. Ni une regle de droit ni un caprice : un mois laisse
|
|
# au client le temps d'ouvrir son compte et de vivre avec son ecosysteme, et reste assez
|
|
# court pour qu'une revocation oubliee se voie encore comme un oubli plutot que comme un
|
|
# etat de fait.
|
|
DELAI_TEMPS2_JOURS = 30
|
|
|
|
|
|
# --- Ce qui appartient a CET ecosysteme, et rien d'autre -----------------------------
|
|
|
|
def _instance() -> Path:
|
|
base = instance_courante()
|
|
if not base.exists():
|
|
raise SystemExit("REFUS : aucune instance montee (symlink `instance/` ou "
|
|
"SETOPS_INSTANCE). Une remise vise UN ecosysteme, jamais la flotte.")
|
|
return base.resolve()
|
|
|
|
|
|
def _cle_de_voute() -> Path:
|
|
"""La cle de la voute de l'instance MONTEE — jamais celle du site ni d'un voisin.
|
|
|
|
`voutes()` nomme les trois roles (instance, hebergeur, voisin) ; on ne retient que le
|
|
premier. C'est la seule ligne qui empeche une remise d'emporter la flotte.
|
|
"""
|
|
for role, _nom, chemin in voutes():
|
|
if role == "instance":
|
|
if not chemin.is_file():
|
|
raise SystemExit(
|
|
f"REFUS : la cle de voute de cet ecosysteme est absente ({chemin}).\n"
|
|
" Sans elle, le client recevrait une voute qu'il ne peut pas ouvrir.")
|
|
return chemin
|
|
raise SystemExit("REFUS : aucune voute d'instance nommee (`voutes.py etat`).")
|
|
|
|
|
|
def _voute_chiffree(base: Path) -> Path | None:
|
|
for nom in ("principal", "production", "lab"):
|
|
p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml"
|
|
if p.is_file():
|
|
return p
|
|
return None
|
|
|
|
|
|
def _racine_ac() -> Path | None:
|
|
"""La racine de l'AC, si elle a ete recuperee. PUBLIQUE — elle voyage en clair.
|
|
|
|
On ne la va PAS la chercher soi-meme : `make ca-racine` interroge une machine vivante
|
|
et affiche l'empreinte a comparer. Un script de remise qui l'irait chercher en silence
|
|
priverait l'operateur du seul geste qui distingue sa racine d'une racine interceptee.
|
|
"""
|
|
for p in (Path.cwd() / "root_ca.crt", RACINE / "root_ca.crt"):
|
|
if p.is_file():
|
|
return p
|
|
return None
|
|
|
|
|
|
def _empreinte(f: Path) -> str:
|
|
return hashlib.sha256(f.read_bytes()).hexdigest()
|
|
|
|
|
|
def _recenser(base: Path) -> list[tuple[Path, str, str]]:
|
|
"""(fichier, nature, raison). `nature` vaut `secret` ou `public`."""
|
|
pieces: list[tuple[Path, str, str]] = [
|
|
(_cle_de_voute(), "secret",
|
|
"La cle de SA voute — ce qui ouvre les secrets de CET ecosysteme, et d'aucun autre."),
|
|
]
|
|
v = _voute_chiffree(base)
|
|
if v:
|
|
pieces.append((v, "chiffre",
|
|
"Sa voute, telle qu'elle est versionnee : chiffree, inutile sans la cle."))
|
|
ca = _racine_ac()
|
|
if ca:
|
|
pieces.append((ca, "public",
|
|
"La racine de SON autorite de certification — publique, a installer."))
|
|
return pieces
|
|
|
|
|
|
# --- Le registre : ce qui a ete remis, a qui, et ce qui reste du ----------------------
|
|
|
|
def _chemin_registre(base: Path) -> Path:
|
|
return base / FICHIER_REMISE
|
|
|
|
|
|
def _lire_registre(base: Path) -> dict:
|
|
p = _chemin_registre(base)
|
|
if not p.is_file():
|
|
return {}
|
|
return (yaml.safe_load(p.read_text(encoding="utf-8")) or {}).get("remise") or {}
|
|
|
|
|
|
def _ecrire_registre(base: Path, remise: dict) -> Path:
|
|
entete = (
|
|
"# REMISE de cet ecosysteme — a qui il a ete remis, et ce qui reste du.\n"
|
|
"#\n"
|
|
"# GENERE par `make remise-inscrire` / `make remise-recleer`. Ne pas editer a la\n"
|
|
"# main : reinscrire.\n"
|
|
"#\n"
|
|
"# IL NE PORTE AUCUN SECRET, par construction : des noms, des tailles et des\n"
|
|
"# empreintes SHA256. Une empreinte prouve qu'on a remis CE fichier-la sans rien\n"
|
|
"# reveler de son contenu.\n"
|
|
"#\n"
|
|
"# Le second temps — `recleer` — n'est pas une formalite : tant qu'il n'a pas eu\n"
|
|
"# lieu, l'hebergeur garde l'acces machine d'un client qui, lui, se croit chez lui.\n"
|
|
"---\n")
|
|
cible = _chemin_registre(base)
|
|
with ecriture_atomique(cible) as f:
|
|
f.write(entete)
|
|
yaml.safe_dump({"remise": remise}, f, default_flow_style=False,
|
|
sort_keys=False, allow_unicode=True)
|
|
return cible
|
|
|
|
|
|
def _organisation(base: Path) -> str:
|
|
for nom in ("principal", "production", "lab"):
|
|
f = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml"
|
|
if f.is_file():
|
|
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
if d.get("organisation"):
|
|
return str(d["organisation"])
|
|
return base.name
|
|
|
|
|
|
# --- Le plan : ce qu'il dit des cles d'administration --------------------------------
|
|
|
|
def _cles_admin_du_plan(base: Path) -> list[dict]:
|
|
for nom in ("principal", "production", "lab"):
|
|
f = base / "inventories" / nom / "group_vars" / "all" / "10-intrants.yml"
|
|
if f.is_file():
|
|
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
if "ssh_baseline_cles_admin" in d:
|
|
return list(d.get("ssh_baseline_cles_admin") or [])
|
|
return []
|
|
|
|
|
|
def _etat_des_cles(base: Path) -> tuple[list[str], list[str]]:
|
|
"""(presentes, absentes) — les raisons declarees, pas les cles elles-memes."""
|
|
presentes, absentes = [], []
|
|
for e in _cles_admin_du_plan(base):
|
|
if not isinstance(e, dict):
|
|
continue
|
|
etiquette = str(e.get("pourquoi") or str(e.get("cle", ""))[:40])
|
|
(absentes if str(e.get("etat", "present")) == "absent" else presentes).append(etiquette)
|
|
return presentes, absentes
|
|
|
|
|
|
# --- Les commandes --------------------------------------------------------------------
|
|
|
|
def cmd_recenser(_args) -> int:
|
|
base = _instance()
|
|
pieces = _recenser(base)
|
|
print(f" REMISE DE : {_organisation(base)} ({base.name})\n")
|
|
for f, nature, raison in pieces:
|
|
print(f" [{nature}] {raison}")
|
|
print(f" {f.name:<44} {f.stat().st_size:>6} o {_empreinte(f)[:16]}…")
|
|
if not _racine_ac():
|
|
print("\n MANQUE la racine de l'AC (`root_ca.crt`) — elle est PUBLIQUE et le")
|
|
print(" client en a besoin pour que son navigateur cesse de crier :")
|
|
print(" make ca-racine puis make ca-empreinte (a comparer)")
|
|
print("\n CE QUI NE PART PAS, ET C'EST VOULU : la cle du SITE, celles des autres")
|
|
print(" locataires, et les cles SSH de l'hebergeur. Elles vivent dans le meme")
|
|
print(" dossier ; seul l'ecosysteme monte est emporte.")
|
|
print("\n RECENSEMENT SEUL — rien n'a ete ecrit.")
|
|
return 0
|
|
|
|
|
|
def _refuser_destination_interne(dest: Path) -> None:
|
|
"""Une remise DANS l'infrastructure ne remet rien : elle boucle.
|
|
|
|
Meme refus que `exporter_cles.py`, et pour la meme raison : le depot de sauvegarde est
|
|
chiffre par un mot de passe qui vit dans la voute que ce paquet ouvre.
|
|
"""
|
|
for i in (MAISON / "Espace Chezlepro" / "DépôtsSurForge", Path("/srv/restic")):
|
|
try:
|
|
dest.resolve().relative_to(i.resolve())
|
|
except (ValueError, OSError):
|
|
continue
|
|
raise SystemExit(
|
|
f"REFUS : {dest} est DANS l'infrastructure ({i}).\n"
|
|
" Un paquet de remise se depose sur un support remis en main propre,\n"
|
|
" pas dans les depots que ce meme paquet permet d'ouvrir.")
|
|
|
|
|
|
LISEZ_MOI = """PAQUET DE REMISE — VOTRE ECOSYSTEME
|
|
===================================
|
|
|
|
Ce support porte ce qui vous rend maitre de votre ecosysteme. Il ne contient AUCUN
|
|
mot de passe en clair : la phrase de passe de l'archive vous est transmise par un
|
|
autre canal, et sans elle ce support est du bruit.
|
|
|
|
OUVRIR L'ARCHIVE
|
|
|
|
gpg --decrypt setops-remise-*.tar.gpg > remise.tar
|
|
tar -xvf remise.tar
|
|
|
|
CE QUE VOUS Y TROUVEZ
|
|
|
|
setops-vault-<votre-ecosysteme> la CLE de votre voute. A ranger dans
|
|
~/.config/ , droits 0600, et NULLE PART ailleurs.
|
|
vault.yml votre voute, chiffree. Inutile sans la cle.
|
|
root_ca.crt la racine de votre autorite de certification.
|
|
PUBLIQUE : elle s'installe, elle ne se cache pas.
|
|
|
|
REMETTRE LA CLE EN PLACE
|
|
|
|
mkdir -p ~/.config && umask 077
|
|
cp setops-vault-* ~/.config/
|
|
chmod 600 ~/.config/setops-vault-*
|
|
|
|
VERIFIER LA RACINE AVANT DE L'INSTALLER
|
|
|
|
openssl x509 -in root_ca.crt -noout -subject -fingerprint -sha256
|
|
|
|
Comparez cette empreinte avec celle qui vous a ete transmise separement. Installer une
|
|
autorite, c'est lui donner le droit de signer n'importe quel nom pour votre navigateur :
|
|
la comparaison est ce qui distingue votre racine d'une racine interceptee.
|
|
|
|
CE QUI RESTE A FAIRE, ET QUI N'EST PAS FACULTATIF
|
|
|
|
1. Ouvrir votre compte et changer le mot de passe a usage unique (votre document de
|
|
livraison, section 3).
|
|
2. Ranger la phrase de passe de cette archive AILLEURS que sur ce support. Separes,
|
|
ils ne valent rien l'un sans l'autre ; ensemble, ils valent votre ecosysteme.
|
|
3. Faire une seconde copie de ce support, dans un autre lieu physique.
|
|
|
|
CE QUE VOTRE HEBERGEUR DETIENT ENCORE
|
|
|
|
Tant que le SECOND TEMPS de la remise n'a pas eu lieu, votre hebergeur garde l'acces
|
|
machine a votre ecosysteme — c'est ce qui lui permet de l'exploiter pour vous. Ce second
|
|
temps fait entrer VOTRE cle, sortir la sienne, et change le mot de passe de votre voute.
|
|
Sa date est inscrite dans le registre de remise de votre depot (remise.yml).
|
|
"""
|
|
|
|
|
|
def cmd_paquet(args) -> int:
|
|
base = _instance()
|
|
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.")
|
|
|
|
pieces = _recenser(base)
|
|
if not _racine_ac():
|
|
raise SystemExit(
|
|
"REFUS : la racine de l'AC (`root_ca.crt`) n'est pas la.\n"
|
|
" Sans elle, le client installe un ecosysteme dont son navigateur refuse\n"
|
|
" tous les certificats — et il apprendra a cliquer sur « continuer quand\n"
|
|
" meme », ce qui vaut pire que pas de TLS du tout.\n"
|
|
" La recuperer : make ca-racine puis make ca-empreinte")
|
|
|
|
org = _organisation(base)
|
|
attendu = {f.name: _empreinte(f) for f, _, _ in pieces}
|
|
print(f" REMISE DE : {org} ({base.name})\n")
|
|
for f, nature, _ in pieces:
|
|
print(f" [{nature:<7}] {f.name:<40} {_empreinte(f)[:16]}…")
|
|
|
|
suffixe = "tar" if args.support_chiffre else "tar.gpg"
|
|
archive = dest / f"setops-remise-{base.name.lower()}.{suffixe}"
|
|
if archive.exists():
|
|
raise SystemExit(
|
|
f"REFUS : {archive} existe deja.\n"
|
|
" On n'ecrase pas un paquet de remise : il est peut-etre celui qu'on vient\n"
|
|
" de verifier. Le deplacer, ou choisir une autre destination.")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
brut = Path(tmp) / "remise.tar"
|
|
with tarfile.open(brut, "w") as tar:
|
|
for f, _, _ in pieces:
|
|
tar.add(f, arcname=f.name)
|
|
|
|
if args.support_chiffre:
|
|
# LE SUPPORT EST DEJA CHIFFRE AU REPOS (LUKS). Meme raisonnement que
|
|
# `exporter_cles.py` : la seconde couche ne protege que du vol du support, que
|
|
# LUKS couvre deja, au prix d'une phrase de passe stockee nulle part. Le choix
|
|
# reste EXPLICITE : le silence ne doit jamais pencher du cote de la divulgation.
|
|
brut.replace(archive)
|
|
relu_noms = set(tarfile.open(archive).getnames())
|
|
manquants = [n for n in attendu if n not in relu_noms]
|
|
if manquants:
|
|
archive.unlink(missing_ok=True)
|
|
raise SystemExit(f"ECHEC de la relecture — paquet SUPPRIME. "
|
|
f"Manquent : {', '.join(manquants)}")
|
|
print(f"\n Ecrit EN CLAIR : {archive}")
|
|
print(" LA PROTECTION EST CELLE DU SUPPORT — demonte-le quand il ne sert pas.")
|
|
else:
|
|
print("\n Une phrase de passe va etre demandee (deux fois : ecriture, puis")
|
|
print(" verification). Elle n'est stockee NULLE PART — elle se transmet au")
|
|
print(" client par un AUTRE canal que 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 (D-68). Un paquet qu'on n'a pas rouvert n'est pas un
|
|
# paquet : c'est un fichier dont on espere quelque chose. Et celui-la, on ne
|
|
# le rouvrira qu'une fois chez le client, quand il sera trop tard pour refaire.
|
|
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 — le paquet a ete SUPPRIME.\n"
|
|
" Un paquet de remise qu'on ne sait pas rouvrir est pire que rien :\n"
|
|
" il donne le sentiment d'avoir remis.")
|
|
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 du paquet")
|
|
continue
|
|
membre = tar.extractfile(nom)
|
|
if (hashlib.sha256(membre.read()).hexdigest() if membre else "") != emp:
|
|
ecarts.append(f"{nom} : empreinte differente")
|
|
for nom in noms:
|
|
if nom not in attendu:
|
|
ecarts.append(f"{nom} : PRESENT en trop dans le paquet")
|
|
if ecarts:
|
|
archive.unlink(missing_ok=True)
|
|
print(" ECARTS — le paquet a ete SUPPRIME :", file=sys.stderr)
|
|
for e in ecarts:
|
|
print(f" ! {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
(dest / "LISEZ-MOI-REMISE.txt").write_text(LISEZ_MOI, encoding="utf-8")
|
|
try:
|
|
archive.chmod(0o600)
|
|
acces = "0600"
|
|
except OSError:
|
|
acces = "support sans droits Unix (FAT/exFAT) — c'est le chiffrement qui protege"
|
|
|
|
print(f" VERIFIE : {len(attendu)} piece(s) relues, empreintes identiques.")
|
|
print(f" Paquet : {archive} ({acces})")
|
|
print(f" + {dest / 'LISEZ-MOI-REMISE.txt'}")
|
|
print("\n IL RESTE DEUX GESTES, et le second n'a pas d'outil :")
|
|
print(" 1. inscrire la remise : make remise-inscrire RECU_PAR=\"…\" COURRIEL=\"…\"")
|
|
print(" 2. transmettre la phrase de passe par un AUTRE canal que ce support.")
|
|
return 0
|
|
|
|
|
|
def cmd_inscrire(args) -> int:
|
|
base = _instance()
|
|
pieces = _recenser(base)
|
|
ancien = _lire_registre(base)
|
|
aujourd_hui = dt.date.today()
|
|
echeance = aujourd_hui + dt.timedelta(days=int(args.dans))
|
|
|
|
remise = {
|
|
"organisation": _organisation(base),
|
|
"ecosysteme": base.name,
|
|
"temps_1": {
|
|
"remis_le": aujourd_hui.isoformat(),
|
|
"remis_par": args.remis_par or os.environ.get("USER", "inconnu"),
|
|
"recu_par": {"nom": args.recu_par, "courriel": args.courriel},
|
|
"pieces": [{"fichier": f.name, "nature": nature,
|
|
"empreinte_sha256": _empreinte(f)} for f, nature, _ in pieces],
|
|
},
|
|
# LE RESPONSABLE DESIGNE (D-18) EST DECLARE ICI, ET NULLE PART AILLEURS.
|
|
# `docs/migration-tenant.md` le decide depuis longtemps et laisse ouverte la
|
|
# question « ou est-il declare ». La remise est le moment ou la reponse existe :
|
|
# c'est la personne qui RECOIT, et la nommer ailleurs qu'au moment de la remise
|
|
# reviendrait a la deviner.
|
|
"responsable_designe": {"nom": args.recu_par, "courriel": args.courriel},
|
|
"temps_2": {
|
|
"du_le": echeance.isoformat(),
|
|
"fait_le": (ancien.get("temps_2") or {}).get("fait_le"),
|
|
"gestes": [
|
|
"la cle SSH du client entre au plan (`etat: present`)",
|
|
"celle de l'hebergeur en sort (`etat: absent`), et le deploiement l'applique",
|
|
"la voute change de mot de passe (`ansible-vault rekey`), cle chez le client",
|
|
"les secrets applicatifs tournent (`voute.py saisir --remplacer`), puis deploiement",
|
|
],
|
|
},
|
|
}
|
|
cible = _ecrire_registre(base, remise)
|
|
print(f"{cible} inscrit — {len(pieces)} piece(s) remises a {args.recu_par}.")
|
|
print(f" Second temps du le {echeance.isoformat()} "
|
|
f"({args.dans} jours). `make remise-verifier` le rappellera.")
|
|
return 0
|
|
|
|
|
|
def _verdict(base: Path) -> tuple[int, list[str]]:
|
|
r = _lire_registre(base)
|
|
if not r:
|
|
return 0, [f"{base.name} : aucune remise inscrite — cet ecosysteme n'a pas ete remis."]
|
|
lignes, code = [], 0
|
|
t1 = r.get("temps_1") or {}
|
|
t2 = r.get("temps_2") or {}
|
|
lignes.append(f"{r.get('organisation', base.name)} — temps 1 remis le "
|
|
f"{t1.get('remis_le', '?')} a {(t1.get('recu_par') or {}).get('nom', '?')}")
|
|
|
|
presentes, absentes = _etat_des_cles(base)
|
|
if t2.get("fait_le"):
|
|
lignes.append(f" temps 2 : fait le {t2['fait_le']}")
|
|
# LA CONTRADICTION QU'ON CHERCHE : un registre qui dit « revoque » pendant que le
|
|
# plan garde la cle de l'hebergeur. C'est le seul mensonge que ce fichier puisse
|
|
# porter sans qu'on s'en apercoive, parce qu'il flatte tout le monde.
|
|
if not absentes:
|
|
code = 1
|
|
lignes.append(" CONTRADICTION : le registre dit le temps 2 fait, et le plan "
|
|
"ne revoque AUCUNE cle d'administration.")
|
|
else:
|
|
du = t2.get("du_le")
|
|
if du and dt.date.fromisoformat(str(du)) < dt.date.today():
|
|
code = 1
|
|
retard = (dt.date.today() - dt.date.fromisoformat(str(du))).days
|
|
lignes.append(f" temps 2 : DU DEPUIS {retard} jour(s) ({du}) — l'hebergeur garde "
|
|
f"l'acces machine d'un client qui se croit chez lui.")
|
|
else:
|
|
lignes.append(f" temps 2 : du le {du or '?'} — pas encore fait.")
|
|
lignes.append(f" cles d'administration au plan : {len(presentes)} presente(s), "
|
|
f"{len(absentes)} revoquee(s)")
|
|
return code, lignes
|
|
|
|
|
|
def cmd_verifier(_args) -> int:
|
|
base = _instance()
|
|
code, lignes = _verdict(base)
|
|
for l in lignes:
|
|
print(l)
|
|
return code
|
|
|
|
|
|
def cmd_recleer(args) -> int:
|
|
"""Le second temps : on MESURE le plan, puis on estampille. Jamais l'inverse."""
|
|
base = _instance()
|
|
r = _lire_registre(base)
|
|
if not r:
|
|
raise SystemExit("REFUS : aucune remise inscrite — le second temps n'a rien a suivre.\n"
|
|
" Commencer par `make remise-inscrire`.")
|
|
presentes, absentes = _etat_des_cles(base)
|
|
cle_voute = _cle_de_voute()
|
|
empreinte_inscrite = next(
|
|
(p.get("empreinte_sha256") for p in ((r.get("temps_1") or {}).get("pieces") or [])
|
|
if p.get("fichier") == cle_voute.name), None)
|
|
|
|
manques = []
|
|
if not absentes:
|
|
manques.append("aucune cle d'administration n'est declaree `etat: absent` — celle "
|
|
"de l'hebergeur doit sortir du plan, puis le deploiement l'applique")
|
|
if not presentes:
|
|
manques.append("aucune cle d'administration `present` — le client doit avoir la "
|
|
"sienne AVANT qu'on retire la notre, sinon plus personne n'entre")
|
|
if empreinte_inscrite and _empreinte(cle_voute) == empreinte_inscrite:
|
|
manques.append("la cle de voute est IDENTIQUE a celle remise au temps 1 — la voute "
|
|
"n'a pas ete re-cletee (`ansible-vault rekey`)")
|
|
|
|
if manques:
|
|
print(" LE SECOND TEMPS N'EST PAS FAIT — rien n'a ete estampille :", file=sys.stderr)
|
|
for m in manques:
|
|
print(f" ! {m}", file=sys.stderr)
|
|
print("\n L'ordre, et il ne se permute pas : la cle du client entre, puis la "
|
|
"notre sort,\n puis la voute est re-cletee, puis les secrets tournent, puis "
|
|
"on deploie.\n Voir docs/remise-au-client.md §4.", file=sys.stderr)
|
|
return 1
|
|
|
|
if not args.confirmer:
|
|
print(" Tout est mesure comme fait. Estampiller le registre :")
|
|
print(" make remise-recleer CONFIRMER=true")
|
|
return 0
|
|
|
|
r.setdefault("temps_2", {})["fait_le"] = dt.date.today().isoformat()
|
|
r["temps_2"]["constate"] = {
|
|
"cles_presentes": presentes,
|
|
"cles_revoquees": absentes,
|
|
"cle_de_voute_changee": True,
|
|
}
|
|
cible = _ecrire_registre(base, r)
|
|
print(f"{cible} — second temps estampille le {r['temps_2']['fait_le']}.")
|
|
print(" L'hebergeur n'a plus d'acces machine declare a cet ecosysteme.")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = ap.add_subparsers(dest="commande", required=True)
|
|
sub.add_parser("recenser", help="Ce qui partirait, sans rien ecrire.")
|
|
|
|
pp = sub.add_parser("paquet", help="Temps 1 : fabrique le paquet, et le relit.")
|
|
pp.add_argument("--vers", required=True, help="Repertoire de destination (support remis).")
|
|
pp.add_argument("--support-chiffre", action="store_true",
|
|
help="Le support est DEJA chiffre au repos (LUKS) : tar clair.")
|
|
|
|
pi = sub.add_parser("inscrire", help="Inscrit la remise dans le depot du locataire.")
|
|
pi.add_argument("--recu-par", required=True, help="Qui recoit — le responsable designe.")
|
|
pi.add_argument("--courriel", required=True, help="Son courriel.")
|
|
pi.add_argument("--remis-par", default=None, help="Qui remet (defaut : $USER).")
|
|
pi.add_argument("--dans", default=str(DELAI_TEMPS2_JOURS),
|
|
help=f"Jours avant le second temps (defaut : {DELAI_TEMPS2_JOURS}).")
|
|
|
|
sub.add_parser("verifier", help="Etat de la remise (code de sortie).")
|
|
pr = sub.add_parser("recleer", help="Temps 2 : mesure le plan, puis estampille.")
|
|
pr.add_argument("--confirmer", action="store_true", help="Estampiller le registre.")
|
|
|
|
args = ap.parse_args(argv)
|
|
return {
|
|
"recenser": cmd_recenser, "paquet": cmd_paquet, "inscrire": cmd_inscrire,
|
|
"verifier": cmd_verifier, "recleer": cmd_recleer,
|
|
}[args.commande](args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|