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