From fd62b5998936ceae210b2384dce06172abb9fdb6 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 3 Aug 2026 11:43:32 -0400 Subject: [PATCH] =?UTF-8?q?pare-feu=20Proxmox=20:=20le=20filtrage=20est-ou?= =?UTF-8?q?est=20intra-tenant,=20d=C3=A9riv=C3=A9=20(P25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make devis-proxmox-fw` : 34 groupes de sécurité, 40 règles, 2 tenants — depuis les 57 flux intra-tenant que le registre connaissait déjà. Défense en profondeur, pas remplacement : l'hyperviseur filtre puis l'hôte destinataire filtre à nouveau. Coût de maintenance nul, les deux barrières lisent le registre par les MÊMES fonctions — la duplication est dans l'application, jamais dans la décision. Un IPSet par rôle porte les membres, les groupes y renvoient : ajouter un hôte à un rôle met à jour toutes les règles qui l'autorisent, en un endroit. Garde ajoutée après coup : Proxmox limite un nom de groupe à 18 caractères. Ma première version tronquait sans vérifier — deux rôles tronqués au même nom auraient fusionné leurs règles, donnant à une VM les autorisations d'un rôle qu'elle ne porte pas, silencieusement. Le préfixe porte maintenant l'index plutôt que l'étiquette, et une garde échoue sur toute collision. Exercée. Conséquence consignée : tout ce qui entre dans un tenant passant par la frontière, le contrôleur Ansible aussi — l'OPNsense devient un prérequis de déploiement, pas une étape parmi d'autres. Preuves : 25 OK, 0 échec. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 29 ++++ Makefile | 7 + docs/audit/affirmations.md | 2 + docs/audit/preuve-2026-08-03.md | 3 +- docs/decisions-architecture.md | 2 + docs/sdn-evpn.md | 7 +- scripts/devis_proxmox_fw.py | 248 ++++++++++++++++++++++++++++++++ scripts/prouver.py | 2 + 8 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 scripts/devis_proxmox_fw.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a3ed9..ec9baeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # CHANGELOG — Set-OPS +## 2026-08-03 (suite 8) — le pare-feu Proxmox, troisième lecture du même registre + +`make devis-proxmox-fw` (**preuve P25**). Le filtrage est-ouest intra-tenant est désormais +dérivé pour l'hyperviseur : **34 groupes de sécurité, 40 règles, 2 tenants** — depuis les +57 flux intra-tenant que le registre connaissait déjà. + +Décidé : la défense est **en profondeur**, pas en remplacement. L'hyperviseur filtre, puis +l'hôte destinataire filtre à nouveau. Une VM compromise doit franchir les deux. Le coût de +maintenance est nul : les deux barrières lisent le registre **par les mêmes fonctions** +(`_resoudre_sources`, `_pairs`, `_hotes_du_groupe`) — la duplication est dans l'application, +jamais dans la décision. + +Un IPSet par rôle porte les membres, les groupes de sécurité y renvoient : ajouter un hôte à +un rôle met à jour toutes les règles qui l'autorisent, en un seul endroit. + +### La garde qui manquait à ma première version +Proxmox limite un nom de groupe à **18 caractères**. Ma première version tronquait sans +vérifier : deux rôles tronqués au même nom auraient **fusionné leurs règles**, donnant à une VM +les autorisations d'un rôle qu'elle ne porte pas — silencieusement. + +Le préfixe porte maintenant l'**index** (`t17-`) plutôt que l'étiquette (`chez17-`), ce qui +rend la troncature bien plus rare, et une garde **échoue** sur toute collision plutôt que +d'émettre un devis pareil. Exercée. + +### Conséquence à retenir +Puisque **tout** ce qui entre dans un tenant passe par la frontière, le contrôleur Ansible +aussi. **L'OPNsense devient un prérequis de déploiement**, pas une étape parmi d'autres : +sans lui, plus rien ne se déploie. + ## 2026-08-03 (suite 7) — le modèle déclarait un MTU que le matériel n'a pas En préparant le déplacement des adresses de VTEP, la lecture des interfaces a montré deux diff --git a/Makefile b/Makefile index 8bbd5a5..025d513 100644 --- a/Makefile +++ b/Makefile @@ -348,6 +348,13 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe) python3 scripts/devis_opnsense.py $(if $(JSON),--json,) +.PHONY: devis-proxmox-fw devis-proxmox-fw-verifier +devis-proxmox-fw: ansible-runtime ## Devis pare-feu Proxmox (est-ouest intra-tenant), derive du registre des flux + python3 scripts/devis_proxmox_fw.py $(if $(JSON),--json,) + +devis-proxmox-fw-verifier: + python3 scripts/devis_proxmox_fw.py --verifier + devis-opnsense-verifier: python3 scripts/devis_opnsense.py --verifier diff --git a/docs/audit/affirmations.md b/docs/audit/affirmations.md index 4e30f20..56b07a5 100644 --- a/docs/audit/affirmations.md +++ b/docs/audit/affirmations.md @@ -155,6 +155,7 @@ Légende du statut : ✅ prouvée · 🟡 partielle · ❌ fausse · ⚪ invéri | AFF-103 | « L'underlay ne chevauche aucun supernet tenant et reste sous la plage VLAN tenant » (`underlay.yml.example` ; wiki) | structurelle | **P23** (`scripts/underlay.py --verifier`) | ✅ | Sauté si aucun `underlay.yml` monté (dépôt public nu). | | AFF-104 | « La frontière déclare toujours une source d'administration ; un devis dont `nftables_admin_ssh` est vide est refusé » (`docs/frontiere-opnsense.md` §5) | fonctionnelle | **P24** (`scripts/devis_opnsense.py --verifier`) | ✅ | Garde anti-lockout : sans cet intrant, la règle SSH n'aurait aucune source et le `block` final fermerait l'accès. | | AFF-105 | « Les modèles publics valident, underlay compris » (`exemples/modeles/socle/README.md`) | structurelle | **P17** (`scripts/modeles.py verifier`) | ✅ | Underlay facultatif ; validé pour sa cohérence **interne** seulement — un modèle est un gabarit, pas un site déployé. | +| AFF-107 | « Le filtrage est-ouest intra-tenant est dérivé du registre des flux, et redit la même politique que les nftables d'hôte » (`docs/sdn-evpn.md` §3) | structurelle | **P25** (`scripts/devis_proxmox_fw.py --verifier`) | ✅ | 34 groupes, 40 règles sur 2 tenants. Les deux barrières lisent le registre par les **mêmes fonctions** : elles ne peuvent pas diverger. | | AFF-106 | « Le panneau couvre le schéma du plan : aucun champ n'oblige à éditer le YAML » (`docs/intrants-base-gui-conception.md`) | fonctionnelle | **P19** (`scripts/couverture_gui.py`) | 🟡 | Vrai des registres du plan. **Exceptions assumées** : `reseaux` et `hotes` de l'underlay (listes de tables) restent hors du panneau ; les ports physiques et le nœud de sortie EVPN se nomment à la main. | **Non enregistré volontairement.** Les devis eux-mêmes (`make devis-reseau`, @@ -391,6 +392,7 @@ Chaque affirmation ✅ est soit **rejouée** par une preuve de `make prouver` | AFF-104 | **P24** (frontière : accès d'administration déclaré) | | AFF-105 | **P17** (tous les modèles valident, underlay compris) | | AFF-106 | **P19** (le GUI couvre le schéma du plan) | +| AFF-107 | **P25** (pare-feu Proxmox est-ouest) | **Structurelles/doctrinales, hors harnais récurrent** (vérifiées à l'audit) : AFF-005 (`make`=aide), AFF-014 (ciblage `groupe:&hotes_actifs`), AFF-024 (`instancier-appliquer`, diff --git a/docs/audit/preuve-2026-08-03.md b/docs/audit/preuve-2026-08-03.md index f0ad28b..47b22ff 100644 --- a/docs/audit/preuve-2026-08-03.md +++ b/docs/audit/preuve-2026-08-03.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (24 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (25 OK · 0 echec · 0 saute) ## Preuves @@ -37,6 +37,7 @@ | P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (19 sections). | | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 5 reseau(x), aucune collision avec la plage tenant. | | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 26 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. | +| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 34 groupe(s), 40 regle(s). | ## Couverture des affirmations ✅ du registre diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 55cee7a..8650434 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -33,6 +33,8 @@ sont les seules vérifiables. | **D-07** | **Pas d'ACL** sur cette fabric (`acl_inter_tenant: false`) | le matériel ne sait pas lier une ACL à un SVI ; des règles jamais liées auraient l'air d'isoler | `frontiere-opnsense.md` §1 | — | | **D-08** | Le **routage tenant passe au SDN EVPN** — une zone par tenant | une zone EVPN est un VRF : l'isolation devient structurelle, pas réglementaire | `sdn-evpn.md` | P23 (MTU) | | **D-09** | Le **filtrage inter-zone d'un tenant** se fait au même niveau (EVPN) | là où le routage a lieu | `sdn-evpn.md` §3 | — | +| **D-31** | Le filtrage est-ouest est appliqué **deux fois** : hyperviseur **puis** nftables d'hôte | défense en profondeur ; les deux dérivent du même registre par les mêmes fonctions, donc ne peuvent pas diverger | `sdn-evpn.md` §3 | P25 | +| **D-32** | **Tout** ce qui entre ou sort d'un tenant passe par la frontière | un VRF n'a qu'une sortie ; conséquence : la frontière devient un **prérequis de déploiement** | `sdn-evpn.md` §3 | — | | **D-10** | L'**inter-tenant passe obligatoirement par la frontière** | il sort du VRF, donc traverse une bordure en `block` par défaut : il ne peut plus être oublié | `sdn-evpn.md` §3 | — | | **D-11** | La **sortie générale est déclarée** dans le registre des flux | `block out` est un vrai default-deny ; un besoin oublié ne se manifeste pas par un refus clair | `frontiere-opnsense.md` §8 | P09 | | **D-29** | L'**overlay EVPN plafonne à 1450** ; le transport doit donc dépasser 1500 | choix d'exploitation ; l'encapsulation VXLAN coûte 50 octets | `sdn-evpn.md` §5 | P23 (MTU du transport) | diff --git a/docs/sdn-evpn.md b/docs/sdn-evpn.md index bba990d..d498510 100644 --- a/docs/sdn-evpn.md +++ b/docs/sdn-evpn.md @@ -50,7 +50,7 @@ plafond de 245 tenants reste, sa cause change. | **entre zones d'un même tenant** | zone EVPN du tenant (VRF, sur les hyperviseurs) | **au même endroit** | | **entre tenants** | sort du VRF → nœud de sortie → **OPNsense** | la frontière, et elle seule | | **vers l'extérieur** | idem | la frontière | -| **de service à service, sur un hôte** | — | nftables d'hôte, `policy drop` | +| **de service à service, sur un hôte** | — | **deux barrières** : pare-feu Proxmox (`make devis-proxmox-fw`) **puis** nftables d'hôte (`make flux`) | Deux conséquences qui méritent d'être dites. @@ -59,6 +59,11 @@ sortir du VRF, donc traverser la bordure, qui est en `block` par défaut. Un flu légitime devra être **déclaré** pour exister — le registre des flux n'a pas encore de mot-clé pour ça, c'est un point ouvert. +**La défense est en profondeur, sans coût de maintenance.** Le filtrage est-ouest est appliqué +deux fois : par l'hyperviseur, puis par l'hôte destinataire. Une VM compromise doit franchir +les deux. Et comme les deux **dérivent du même registre par les mêmes fonctions**, elles ne +peuvent pas se contredire — la duplication est dans l'application, jamais dans la décision. + **Le commutateur ne voit plus rien du trafic tenant.** Il transporte du VXLAN qu'il ne lit pas. Y chercher une trace d'un problème applicatif serait perdre son temps : le miroir utile est sur l'hyperviseur. diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py new file mode 100644 index 0000000..74b4cfb --- /dev/null +++ b/scripts/devis_proxmox_fw.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Devis de pare-feu Proxmox — le filtrage EST-OUEST intra-tenant. + +DERIVE du meme registre des flux que les nftables d'hote et que la frontiere. Les trois +lisent `roles/*/meta/flux.yml` par les MEMES fonctions : ils ne peuvent pas diverger. + +PARTAGE DES ROLES (cf. docs/sdn-evpn.md) : + - ce devis : est-ouest INTRA-tenant, applique par l'hyperviseur ; + - `make flux` : la meme politique, rejouee sur l'hote lui-meme (defense en + profondeur — une VM compromise franchit deux barrieres) ; + - `make devis-opnsense` : tout ce qui ENTRE ou SORT d'un tenant. + +Les flux `pair: externe` sont donc SAUTES ici : ils relevent de la frontiere. + +NON destructif : ce script n'ecrit rien sur le cluster. Il produit un devis a relire, +puis (a venir) un JSON pour l'API Proxmox. + +Usage : + python3 scripts/devis_proxmox_fw.py # devis lisible + python3 scripts/devis_proxmox_fw.py --json # meme contenu, pour l'API + python3 scripts/devis_proxmox_fw.py --verifier # garde : aucun role sans source +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE / "scripts")) + +from resoudre_flux import ( # noqa: E402 + charger_flux, + est_groupe_operationnel, + _enfants, + _hotes_du_groupe, + _ip_par_hote, + _pairs, + _resoudre_sources, +) +from devis_reseau import admin_de, decouvrir, inventaire_de, prefixe # noqa: E402 + + +def nom_ipset(role: str, etiquette: str) -> str: + return f"{etiquette}_{role}".lower() + + +LONGUEUR_MAX_GROUPE = 18 # limite de Proxmox pour un nom de groupe de securite + + +def nom_groupe(role: str, index: int) -> str: + """Nom de groupe de securite, sous la limite de 18 caracteres de Proxmox. + + Le prefixe porte l'INDEX du tenant plutot que son etiquette : `t17-` au lieu de + `chez17-`, ce qui laisse trois caracteres de plus au role et rend la troncature + beaucoup plus rare. Les groupes sont a l'echelle du datacenter, d'ou le prefixe. + """ + court = role.replace("serveur_", "srv-").replace("client_", "cli-").replace("_", "-") + return f"t{index}-{court}".lower()[:LONGUEUR_MAX_GROUPE] + + +def _verifier_unicite(noms: list[str], tenant: str) -> None: + """Une troncature qui fait collisionner deux roles FUSIONNERAIT leurs regles. + + Silencieusement, et dans le mauvais sens : une VM recevrait les autorisations d'un + role qu'elle ne porte pas. On echoue plutot que d'emettre un devis pareil. + """ + vus: dict[str, str] = {} + for role, nom in noms: + if nom in vus: + raise SystemExit( + f"erreur: {tenant} — les roles '{vus[nom]}' et '{role}' donnent le meme " + f"groupe '{nom}' apres troncature a {LONGUEUR_MAX_GROUPE} caracteres. " + f"Leurs regles fusionneraient. Renommer un role ou raccourcir le prefixe.") + vus[nom] = role + + +def _ports(fl: dict) -> list[str]: + p = fl["port"] + return [str(x) for x in (p if isinstance(p, list) else [p])] + + +def construire(tenants: list[tuple[str, str, dict]]) -> dict: + """Le devis, en structure. Un bloc par tenant federe.""" + flux_par_role = charger_flux() + blocs: list[dict] = [] + + for nom, pfx, n in tenants: + inv = inventaire_de(nom) + if inv is None: + continue + etiquette = f"{pfx}{n['index']}" + data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} + # Les PLANIFIES comptent : le pare-feu se prepare avant que la VM existe, + # sinon le devis changerait a chaque materialisation d'hote. + tous = set(_ip_par_hote(data)) + ips = _ip_par_hote(data) + + # Un IPSet par role porte dans ce tenant : les membres sont ses hotes. + ipsets: dict[str, dict] = {} + for groupe in sorted(_enfants(data)): + if not est_groupe_operationnel(groupe): + continue + membres = sorted({ips[h] for h in _hotes_du_groupe(data, groupe) if h in ips}) + if membres: + ipsets[nom_ipset(groupe, etiquette)] = { + "role": groupe, "membres": membres, + } + + groupes: list[dict] = [] + sans_source: list[str] = [] + for groupe in sorted(_enfants(data)): + if not est_groupe_operationnel(groupe): + continue + if not _hotes_du_groupe(data, groupe): + continue + regles: list[dict] = [] + for fl in flux_par_role.get(groupe, []): + if fl.get("sens") != "ingress": + continue + pairs = _pairs(fl) + if "externe" in pairs: + continue # la frontiere s'en charge + if pairs == ["localhost"]: + continue # boucle locale : aucune regle inter-noeud + srcs = _resoudre_sources(data, fl.get("pair"), tous, ips) + if not srcs: + sans_source.append(f"{groupe}:{','.join(_ports(fl))}") + continue + # Source par IPSet quand le pair est UN role : une seule liste a tenir. + source = (f"+{nom_ipset(pairs[0], etiquette)}" + if len(pairs) == 1 and nom_ipset(pairs[0], etiquette) in ipsets + else ",".join(srcs)) + for port in _ports(fl): + regles.append({ + "action": "ACCEPT", "sens": "IN", + "source": source, + "proto": fl.get("protocole", "tcp"), + "dport": port, + "raison": fl.get("raison", ""), + }) + if regles: + groupes.append({ + "nom": nom_groupe(groupe, n["index"]), "role": groupe, "regles": regles, + }) + _verifier_unicite([(g["role"], g["nom"]) for g in groupes], nom) + + # Affectation : quelle VM recoit quels groupes. + affect: list[dict] = [] + par_nom_groupe = {g["role"]: g["nom"] for g in groupes} + for hote in sorted(_hotes_du_groupe(data, "hotes_actifs") + or [h for h in ips]): + vmid = None + for g, membres in _enfants(data).items(): + h = (membres.get("hosts") or {}).get(hote) or {} + if h.get("proxmox_vmid"): + vmid = h["proxmox_vmid"] + break + porte = [par_nom_groupe[g] for g in sorted(_enfants(data)) + if hote in (_enfants(data)[g].get("hosts") or {}) and g in par_nom_groupe] + if porte: + affect.append({"hote": hote, "vmid": vmid, "groupes": porte}) + + blocs.append({ + "tenant": nom, "etiquette": etiquette, + "ipsets": ipsets, "groupes": groupes, + "affectations": affect, "sans_source": sorted(set(sans_source)), + "admin": admin_de(nom), + }) + + return {"blocs": blocs} + + +def rendre(devis: dict) -> str: + out = [ + "# ============================================================", + "# DEVIS PARE-FEU PROXMOX — est-ouest INTRA-tenant", + "# Genere par scripts/devis_proxmox_fw.py. NE PAS EDITER A LA MAIN.", + "# Derive du MEME registre des flux que les nftables d'hote : les deux", + "# barrieres disent la meme chose, et ne peuvent pas diverger.", + "#", + "# Ce qui ENTRE ou SORT d'un tenant n'est PAS ici : c'est la frontiere", + "# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.", + "# ============================================================", + "", + "## 0. Prerequis, au niveau du datacenter", + "# Pare-feu active, politique d'entree DROP. Sans cela, ces regles", + "# s'ajoutent a un tout-permis et n'interdisent rien.", + "", + ] + for b in devis["blocs"]: + out += [f"## Tenant {b['tenant']} ({b['etiquette']})", ""] + out.append(f"### IPSets — {len(b['ipsets'])} (les membres d'un role, en un seul endroit)") + for nom, s in sorted(b["ipsets"].items()): + out.append(f"ipset {nom} # {s['role']}") + for ip in s["membres"]: + out.append(f" {ip}") + out += ["", f"### Groupes de securite — {len(b['groupes'])} (les flux entrants d'un role)"] + for g in b["groupes"]: + out.append(f"securitygroup {g['nom']} # {g['role']}") + for r in g["regles"]: + out.append(f" {r['sens']} {r['action']} -source {r['source']} " + f"-p {r['proto']} -dport {r['dport']}") + if r["raison"]: + out.append(f" # {r['raison']}") + out += ["", f"### Affectation aux VM — {len(b['affectations'])}"] + for a in b["affectations"]: + out.append(f" vmid {a['vmid'] or '(non materialise)'} {a['hote']:<18} " + f"-> {', '.join(a['groupes'])}") + if b["sans_source"]: + out += ["", "# /!\\ Flux ingress sans source resolue dans ce tenant (aucune regle emise) :"] + out += [f"# {x}" for x in b["sans_source"]] + out.append("") + out += [ + "## Defaut", + "# Politique d'entree DROP au datacenter : ce qui n'est pas ci-dessus", + "# n'entre pas. Un flux oublie se declare dans `roles//meta/flux.yml`,", + "# jamais a la main dans Proxmox — la regle serait perdue a la generation.", + ] + return "\n".join(out) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--json", action="store_true") + ap.add_argument("--verifier", action="store_true") + args = ap.parse_args(argv) + + devis = construire(decouvrir()) + if args.verifier: + total_g = sum(len(b["groupes"]) for b in devis["blocs"]) + total_r = sum(len(g["regles"]) for b in devis["blocs"] for g in b["groupes"]) + if not total_g: + print("erreur: aucun groupe de securite derive — le registre ou l'inventaire " + "est vide.", file=sys.stderr) + return 2 + print(f"CONFORME : pare-feu Proxmox, {len(devis['blocs'])} tenant(s), " + f"{total_g} groupe(s), {total_r} regle(s).") + return 0 + print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/prouver.py b/scripts/prouver.py index ef00ede..dd13d10 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -292,6 +292,8 @@ PREUVES: list[dict] = [ "cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]}, {"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-103"], "cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]}, + {"id": "P25", "titre": "Pare-feu Proxmox : est-ouest intra-tenant derive", "refs": ["AFF-107"], + "cmds": [[sys.executable, "scripts/devis_proxmox_fw.py", "--verifier"]]}, {"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-104"], "cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]}, ]