cles : sortir du poste ce qui n existe qu au poste
Some checks are pending
verifier / verifier (push) Waiting to run
Some checks are pending
verifier / verifier (push) Waiting to run
Le code est replique trois fois (eregion, forge du site, patient 0) et les voutes chiffrees y sont aussi — le coffre est solide. Les CLES qui l ouvrent vivaient dans neuf fichiers, 1644 octets, sans copie ailleurs. Poste seul : les mots de passe restic restent lisibles sur les machines vivantes, donc recuperable mais douloureux. Poste + une machine : l etat de cette machine devient illisible. Poste + site : terminal. make cles-recenser montre ce qui sortirait sans rien ecrire — nom, taille, empreinte, JAMAIS le contenu. make cles-exporter chiffre en AES256 puis REDECHIFFRE ce qu il vient d ecrire et compare les empreintes une a une : une sauvegarde de cles qu on n a pas rouverte n est pas une sauvegarde. A lancer par l exploitant lui-meme : gpg demande une phrase de passe, elle ne doit passer ni par un journal ni par le contexte d un assistant. Trois refus, eprouves en les faisant echouer : destination dans l infrastructure (un coffre dont la cle est dedans), archive existante (elle est peut-etre la seule), archive illisible (supprimee). Le premier essai du premier refus etait faux — le shell developpait HOME avant que je le remplace, l instrument mesurait ailleurs que la cible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
parent
42becd0c02
commit
bebdb84212
6 changed files with 435 additions and 2 deletions
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -1,5 +1,60 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 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
|
||||
priorites : le CODE de Set-OPS est replique trois fois — `eregion`, la forge du site,
|
||||
patient 0 — et les voutes chiffrees y sont aussi. Le coffre est solide.
|
||||
|
||||
Les CLES qui l'ouvrent vivaient dans neuf fichiers de `~/.config` et `~/.ssh`, **1 644
|
||||
octets au total, sans aucune copie ailleurs**. C'est le pire rapport valeur/fragilite de
|
||||
l'installation : six cles de voute qui ouvrent tous les secrets — jetons Proxmox et
|
||||
OPNsense, mots de passe de step-ca, des bases, et les `vault_restic_password` — plus les
|
||||
cles SSH par lesquelles on entre partout.
|
||||
|
||||
Ce qu'on perdait avec le poste, sans exagerer la gravite :
|
||||
|
||||
- **le poste seul** : les mots de passe restic restent lisibles sur les machines vivantes
|
||||
(`/etc/setops/restic.pass`) — recuperable, mais douloureux, et plus aucun deploiement
|
||||
possible entre-temps ;
|
||||
- **le poste et une machine** : l'etat de cette machine devient illisible ;
|
||||
- **le poste et le site** : terminal.
|
||||
|
||||
### `make cles-recenser` et `make cles-exporter`
|
||||
|
||||
Le recensement montre ce qui sortirait sans rien ecrire — nom, taille, empreinte, **jamais
|
||||
le contenu**. L'export met le tout dans une archive chiffree (AES256, phrase de passe
|
||||
symetrique) sur un support choisi.
|
||||
|
||||
**A LANCER SOI-MEME.** `gpg` demande une phrase de passe : elle ne doit passer ni par un
|
||||
journal, ni par le contexte d'un assistant. La cible le dit dans son propre commentaire.
|
||||
|
||||
ECRIRE PUIS RELIRE (D-68), applique a ce qui compte le plus : l'outil REDECHIFFRE ce
|
||||
qu'il vient d'ecrire et compare les empreintes une a une. Une sauvegarde de cles qu'on
|
||||
n'a pas rouverte n'est pas une sauvegarde, c'est un fichier dont on espere quelque chose.
|
||||
|
||||
### Trois refus, eprouves en les faisant echouer
|
||||
|
||||
- **destination dans l'infrastructure** — ces cles ouvrent les sauvegardes ; les y ranger
|
||||
ferait un coffre dont la cle est a l'interieur. `~/Espace Chezlepro` et `/srv/restic`
|
||||
sont refuses nommement ;
|
||||
- **archive existante** — on n'ecrase pas une sauvegarde de cles : elle est peut-etre la
|
||||
seule ;
|
||||
- **archive illisible** — l'archive est SUPPRIMEE. Elle donnerait le sentiment d'etre
|
||||
protege sans l'etre.
|
||||
|
||||
Les trois ont ete essayes sur des fichiers factices avant d'approcher les vraies cles —
|
||||
et le premier essai du premier refus etait FAUX : le shell developpait `$HOME` avant que
|
||||
je le remplace, l'instrument mesurait donc ailleurs que la cible. Reteste correctement,
|
||||
le refus tire. Encore une fois : verifier d'ou l'instrument mesure.
|
||||
|
||||
### Ce que l'outil ne couvre pas, et qui reste a l'humain
|
||||
|
||||
La phrase de passe (perdue, l'archive est du bruit) et la seconde copie dans un autre
|
||||
lieu. Un support unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,
|
||||
deplace de quelques metres. Ces deux gestes sont ecrits dans la sortie du script et dans
|
||||
`docs/sortir-les-cles-du-poste.md` — pas en note de bas de page.
|
||||
|
||||
## 2026-09-03 — Les paquets non Debian passent par le cache : le dernier obstacle au hors-ligne
|
||||
|
||||
**56 preuves.** Trois depots tiers etaient configures sur la flotte — Smallstep, Icinga,
|
||||
|
|
|
|||
13
Makefile
13
Makefile
|
|
@ -562,6 +562,19 @@ site-verifier: ## Verifie que playbooks/site.yml correspond aux couches declaree
|
|||
python3 scripts/orchestrer.py verifier
|
||||
|
||||
.PHONY: flux flux-verifier
|
||||
cles-recenser: ## Montre ce qui n'existe QUE sur ce poste (sans rien ecrire)
|
||||
python3 scripts/exporter_cles.py --recenser
|
||||
|
||||
cles-exporter: ## Sort les cles du poste, chiffrees et relues — VERS=<repertoire>
|
||||
@# A LANCER SOI-MEME, PAS PAR UN AGENT. `gpg` demande une phrase de passe : elle ne
|
||||
@# doit passer ni par un journal, ni par le contexte d'un assistant. Taper la commande
|
||||
@# dans son propre terminal est la seule facon de s'en assurer.
|
||||
@if [ -z "$(VERS)" ]; then \
|
||||
printf 'Refus: relancer avec VERS=<repertoire de destination>.\n'; \
|
||||
printf 'Ex. make cles-exporter VERS=/media/danallaire/CLE\n'; exit 2; fi
|
||||
python3 scripts/exporter_cles.py --vers "$(VERS)"
|
||||
|
||||
.PHONY: cles-recenser cles-exporter
|
||||
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
|
||||
|
|
|
|||
93
docs/audit/preuve-2026-09-05.md
Normal file
93
docs/audit/preuve-2026-09-05.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Preuve de conformite — Set-OPS — 2026-09-05
|
||||
|
||||
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
|
||||
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
|
||||
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
|
||||
> [`docs/audit/README.md`](README.md), et le registre trace :
|
||||
> [`docs/audit/affirmations.md`](affirmations.md).
|
||||
|
||||
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
|
||||
- **Verdict** : ❌ NON CONFORME (55 OK · 1 echec · 0 saute)
|
||||
|
||||
## Preuves
|
||||
|
||||
| # | Preuve | Affirmations | Statut | Detail |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
|
||||
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
|
||||
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
|
||||
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
|
||||
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
|
||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
|
||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 99 flux, schéma + matrice OK. |
|
||||
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
|
||||
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
|
||||
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
|
||||
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 35 groupes (inventaire dechiffre et parse). |
|
||||
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
|
||||
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
|
||||
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
|
||||
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
|
||||
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
|
||||
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
|
||||
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 reseau(x), aucune collision avec la plage tenant. |
|
||||
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
|
||||
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 80 regle(s). |
|
||||
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
|
||||
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
|
||||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
|
||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 56 scripts expliques et atteignables, 111 cibles make documentees, 65 roles avec README. |
|
||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
|
||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (32 genere(s) exempte(s)). |
|
||||
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
|
||||
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
|
||||
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
|
||||
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
|
||||
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
|
||||
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 52 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. |
|
||||
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
|
||||
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ❌ ECHEC | La carte d'orientation ne dit plus vrai :
|
||||
- « pieces d'audit » : la carte annonce 34, le depot en compte 35 |
|
||||
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). |
|
||||
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. |
|
||||
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
|
||||
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
|
||||
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
|
||||
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
|
||||
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
|
||||
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
|
||||
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
|
||||
elles restent hors du harnais recurrent (rien d'executable a rejouer).
|
||||
|
||||
## Declarations d'intention (⚪ invérifiables localement — assumees)
|
||||
|
||||
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
|
||||
comme declarations d'intention, non comme preuves :
|
||||
|
||||
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
|
||||
contre une flotte vivante.
|
||||
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
|
||||
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
|
||||
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
|
||||
|
||||
_Rapport genere le 2026-09-05._
|
||||
|
|
@ -25,8 +25,8 @@ README de rôles). Cette page comble ces deux trous.
|
|||
|---|---|---|
|
||||
| rôles | 65 | `roles/*/` |
|
||||
| README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
|
||||
| documents | 38 | `docs/*.md` |
|
||||
| pièces d'audit | 34 | `docs/audit/*` |
|
||||
| documents | 39 | `docs/*.md` |
|
||||
| pièces d'audit | 35 | `docs/audit/*` |
|
||||
| unités de wiki | 27 | `wiki/*.md` |
|
||||
| décisions en vigueur | 79 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||
|
|
|
|||
64
docs/sortir-les-cles-du-poste.md
Normal file
64
docs/sortir-les-cles-du-poste.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
> **Pour qui :** l'exploitant, le jour où il réalise que 1 644 octets valent toute
|
||||
> l'installation. À faire une fois, puis à refaire quand une clé change.
|
||||
|
||||
# Sortir les clés du poste
|
||||
|
||||
## Ce qui est en jeu
|
||||
|
||||
Le **code** de Set-OPS est répliqué trois fois : `eregion`, la forge du site, patient 0.
|
||||
Les **voûtes chiffrées** y sont aussi. Le coffre est solide.
|
||||
|
||||
Les **clés** qui l'ouvrent vivent dans neuf fichiers de ce poste — 1 644 octets — sans
|
||||
aucune copie ailleurs. S'y ajoutent les clés SSH par lesquelles on entre sur les
|
||||
hyperviseurs, la frontière et chaque machine.
|
||||
|
||||
Ce qu'on perd avec le poste, par ordre de gravité :
|
||||
|
||||
| Ce qui disparaît | Conséquence |
|
||||
|---|---|
|
||||
| Le poste seul | Les mots de passe restic restent lisibles sur les machines vivantes (`/etc/setops/restic.pass`) — récupérable, mais douloureux, et plus aucun déploiement possible entre-temps |
|
||||
| Le poste **et** une machine | L'état de cette machine devient illisible |
|
||||
| Le poste **et** le site | Terminal |
|
||||
|
||||
## La manœuvre
|
||||
|
||||
```bash
|
||||
make cles-recenser # voir ce qui sortirait, sans rien écrire
|
||||
make cles-exporter VERS=/media/…/CLE # sortir, chiffrer, RELIRE
|
||||
```
|
||||
|
||||
**Lance-la toi-même**, dans ton terminal. `gpg` demande une phrase de passe : elle ne doit
|
||||
passer ni par un journal, ni par le contexte d'un assistant. C'est la seule façon de
|
||||
s'en assurer.
|
||||
|
||||
L'outil refuse trois choses, et chacune a été éprouvée en la faisant échouer :
|
||||
|
||||
- une destination **dans** l'infrastructure — ces clés ouvrent les sauvegardes ; les y
|
||||
ranger ferait un coffre dont la clé est à l'intérieur ;
|
||||
- **écraser** une archive existante — elle est peut-être la seule ;
|
||||
- une archive qu'il **n'arrive pas à rouvrir** — elle est alors supprimée. Une sauvegarde
|
||||
de clés qu'on ne sait pas ouvrir est pire que rien : elle donne le sentiment d'être
|
||||
protégé.
|
||||
|
||||
Il ne montre jamais le contenu des clés — seulement leur nom, leur taille et leur
|
||||
empreinte. De quoi vérifier sans divulguer.
|
||||
|
||||
## Les deux gestes qui restent, et qui ne sont pas facultatifs
|
||||
|
||||
**1. La phrase de passe va ailleurs que le support.** Séparés, ils ne valent rien l'un
|
||||
sans l'autre ; ensemble, ils valent l'installation. Un papier dans un autre lieu, ou un
|
||||
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.
|
||||
|
||||
## Quand recommencer
|
||||
|
||||
Quand une voûte est créée ou sa clé changée (`voutes.py`), quand une paire SSH de runner
|
||||
est refaite, et à l'arrivée d'un écosystème. `make cles-recenser` dit en une seconde si
|
||||
l'archive rangée est encore complète : compare le nombre de fichiers et les empreintes.
|
||||
|
||||
## Ce que ça ne couvre pas
|
||||
|
||||
La **phrase de passe** elle-même : si elle est perdue, l'archive est du bruit. C'est le
|
||||
prix du chiffrement, et c'est pour ça que le geste 1 n'est pas décoratif.
|
||||
208
scripts/exporter_cles.py
Normal file
208
scripts/exporter_cles.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Sort du poste ce qui n'existe QUE sur le poste — chiffre, puis relu.
|
||||
|
||||
CE QUE CE SCRIPT PROTEGE, ET POURQUOI C'EST LE PLUS URGENT (mesure du 2026-09-05).
|
||||
|
||||
Le CODE de Set-OPS est replique trois fois : `eregion`, la forge du site, patient 0. Les
|
||||
voutes chiffrees y sont aussi — le coffre est solide.
|
||||
|
||||
Les CLES qui ouvrent ce coffre, elles, vivent dans six fichiers de `~/.config`, 357
|
||||
octets au total, sans aucune copie ailleurs. S'y ajoutent les cles SSH par lesquelles on
|
||||
entre sur les hyperviseurs, la frontiere et chaque machine.
|
||||
|
||||
Ce qu'on perd avec le poste, dans l'ordre de gravite :
|
||||
|
||||
- poste seul : les mots de passe restic sont encore lisibles sur les machines vivantes
|
||||
(`/etc/setops/restic.pass`), donc recuperable — mais douloureux, et on ne peut plus
|
||||
rien deployer entre-temps ;
|
||||
- poste + une machine : l'etat de cette machine devient illisible ;
|
||||
- poste + site : terminal.
|
||||
|
||||
357 octets valent donc l'installation entiere. C'est le pire rapport valeur/fragilite du
|
||||
systeme, et il se ferme en quelques minutes.
|
||||
|
||||
CE QU'IL N'AFFICHE JAMAIS : le contenu des cles. Ni a l'ecran, ni dans un journal. Il
|
||||
n'en montre que le nom, la taille et l'empreinte — de quoi verifier sans divulguer.
|
||||
|
||||
ECRIRE PUIS RELIRE (D-68). Une sauvegarde de cles qu'on n'a pas rouverte n'est pas une
|
||||
sauvegarde : c'est un fichier dont on espere quelque chose. Ce script REDECHIFFRE ce
|
||||
qu'il vient d'ecrire et compare les empreintes, une a une.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MAISON = Path.home()
|
||||
|
||||
# CE QUI N'EXISTE QUE SUR CE POSTE — declare, pas devine.
|
||||
#
|
||||
# Les motifs sont volontairement etroits : ce script sort des SECRETS du poste, il ne
|
||||
# doit emporter que ce qu'on a decide d'emporter. Un `~/.ssh/*` large embarquerait les
|
||||
# cles d'autres projets, qui n'ont rien a faire dans une sauvegarde Set-OPS.
|
||||
A_SORTIR = [
|
||||
(MAISON / ".config", "setops-vault-*",
|
||||
"Cles des voutes — ce qui ouvre TOUS les secrets chiffres du depot."),
|
||||
(MAISON / ".ssh", "id_ed25519_ansible_chezlepro",
|
||||
"Entree sur les hyperviseurs, la frontiere et la flotte de Chezlepro."),
|
||||
(MAISON / ".ssh", "id_ed25519_ansible_technolibre",
|
||||
"Entree sur la flotte de Technolibre."),
|
||||
(MAISON / ".ssh", "id_ed25519",
|
||||
"Cle declaree au plan de certains ecosystemes."),
|
||||
(MAISON / ".ssh", "id_git_ed25519",
|
||||
"Acces aux depots distants du genome."),
|
||||
]
|
||||
|
||||
|
||||
def _empreinte(chemin: Path) -> str:
|
||||
return hashlib.sha256(chemin.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _recenser() -> list[tuple[Path, str]]:
|
||||
trouves: list[tuple[Path, str]] = []
|
||||
for dossier, motif, raison in A_SORTIR:
|
||||
for f in sorted(dossier.glob(motif)):
|
||||
if f.is_file():
|
||||
trouves.append((f, raison))
|
||||
return trouves
|
||||
|
||||
|
||||
def _refuser_destination_interne(dest: Path) -> None:
|
||||
"""Une copie DANS l'infrastructure ne protege de rien — elle boucle.
|
||||
|
||||
Le depot de sauvegarde du site est chiffre par un mot de passe qui vit dans une
|
||||
voute, ouverte par les cles qu'on est en train de sauvegarder. Y deposer ces cles
|
||||
fabriquerait un coffre dont la cle est a l'interieur.
|
||||
"""
|
||||
interdits = [MAISON / "Espace Chezlepro", Path("/srv/restic")]
|
||||
for i in interdits:
|
||||
try:
|
||||
dest.resolve().relative_to(i.resolve())
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
raise SystemExit(
|
||||
f"REFUS : {dest} est DANS l'infrastructure ({i}).\n"
|
||||
" Ces cles ouvrent les sauvegardes ; les y ranger ferait un coffre dont la\n"
|
||||
" cle est a l'interieur. Viser un support amovible ou une machine tierce.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--vers",
|
||||
help="Repertoire de destination (support amovible, machine tierce)")
|
||||
# VOIR CE QUI SORT AVANT DE LE SORTIR. Ce script emporte des secrets : pouvoir en
|
||||
# lire l'inventaire sans rien ecrire n'est pas un confort, c'est ce qui permet de
|
||||
# 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")
|
||||
ap.add_argument("--nom", default="setops-cles",
|
||||
help="Nom de base de l'archive (defaut: setops-cles)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.recenser:
|
||||
if not args.vers:
|
||||
raise SystemExit("REFUS : --vers requis (ou --recenser pour voir sans ecrire).")
|
||||
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.")
|
||||
|
||||
fichiers = _recenser()
|
||||
if not fichiers:
|
||||
raise SystemExit("REFUS : aucun fichier a sortir — rien ne correspond aux motifs.")
|
||||
|
||||
print(" CE QUI VA SORTIR DU POSTE :\n")
|
||||
attendu: dict[str, str] = {}
|
||||
raison_vue: set[str] = set()
|
||||
for f, raison in fichiers:
|
||||
if raison not in raison_vue:
|
||||
print(f" {raison}")
|
||||
raison_vue.add(raison)
|
||||
emp = _empreinte(f)
|
||||
attendu[f.name] = emp
|
||||
print(f" {f.name:<44} {f.stat().st_size:>5} o {emp[:16]}…")
|
||||
print(f"\n {len(fichiers)} fichier(s), "
|
||||
f"{sum(f.stat().st_size for f, _ in fichiers)} octets au total.\n")
|
||||
|
||||
if args.recenser:
|
||||
print(" RECENSEMENT SEUL — rien n'a ete ecrit.")
|
||||
return 0
|
||||
|
||||
archive = dest / f"{args.nom}-{os.uname().nodename}.tar.gpg"
|
||||
if archive.exists():
|
||||
raise SystemExit(
|
||||
f"REFUS : {archive} existe deja.\n"
|
||||
" On n'ecrase pas une sauvegarde de cles : elle est peut-etre la seule.\n"
|
||||
" La deplacer ou choisir un autre --nom.")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
brut = Path(tmp) / "cles.tar"
|
||||
with tarfile.open(brut, "w") as tar:
|
||||
for f, _ in fichiers:
|
||||
tar.add(f, arcname=f.name)
|
||||
|
||||
print(" Une phrase de passe va etre demandee (deux fois : ecriture, puis")
|
||||
print(" verification). Elle n'est stockee NULLE PART — c'est a toi de la garder,")
|
||||
print(" et de la garder AILLEURS que sur 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. On redechiffre CE QU'ON VIENT D'ECRIRE et on compare les
|
||||
# empreintes. Sans ca, on rentrerait chez soi avec un fichier dont on ESPERE
|
||||
# qu'il contient les cles.
|
||||
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 — l'archive a ete SUPPRIMEE.\n"
|
||||
" Une sauvegarde de cles qu'on ne sait pas rouvrir est pire que rien :\n"
|
||||
" elle donne le sentiment d'etre protege.")
|
||||
|
||||
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 de l'archive")
|
||||
continue
|
||||
membre = tar.extractfile(nom)
|
||||
obtenu = hashlib.sha256(membre.read()).hexdigest() if membre else ""
|
||||
if obtenu != emp:
|
||||
ecarts.append(f"{nom} : empreinte differente")
|
||||
for nom in noms:
|
||||
if nom not in attendu:
|
||||
ecarts.append(f"{nom} : PRESENT en trop dans l'archive")
|
||||
|
||||
if ecarts:
|
||||
archive.unlink(missing_ok=True)
|
||||
print(" ECARTS — l'archive a ete SUPPRIMEE :", file=sys.stderr)
|
||||
for e in ecarts:
|
||||
print(f" ! {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
archive.chmod(0o600)
|
||||
print(f" VERIFIE : {len(attendu)} fichier(s) relus, empreintes identiques.")
|
||||
print(f" Archive : {archive} ({archive.stat().st_size} octets)\n")
|
||||
print(" IL RESTE DEUX GESTES, ET ILS NE SONT PAS FACULTATIFS :")
|
||||
print(" 1. Ranger la phrase de passe AILLEURS que ce support. Separes, ils ne")
|
||||
print(" valent rien l'un sans l'autre ; ensemble, ils valent l'installation.")
|
||||
print(" 2. Faire une SECONDE copie, dans un autre lieu physique. Un support")
|
||||
print(" unique dans un tiroir unique, c'est le probleme qu'on vient de fermer,")
|
||||
print(" deplace de quelques metres.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue