Set-OPS-Public/scripts/dnssec.py

321 lines
14 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Les cles DNSSEC d'un locataire : les creer dans SA voute, en calculer les DS, les verifier.
POURQUOI LA CLE VIT DANS LA VOUTE, ET PAS SUR LA MACHINE (2026-09-16). PowerDNS range ses cles
dans une base SQLite sur le primaire. Or ce depot reconstruit ses machines depuis zero, et
c'est prouve. Une cle nee sur la machine mourrait avec elle : la nouvelle zone serait signee
par une AUTRE cle, le DS publie chez le registraire ne correspondrait plus, et tout resolveur
validant — la majorite de l'Internet — declarerait le domaine BOGUS. Le site web ne
repondrait plus, le courriel ne partirait plus, et rien sur nos machines ne serait en panne.
La cle est donc tiree ici, une fois, rangee dans la voute DU LOCATAIRE (c'est lui qui signe ;
l'hebergeur ne la detient pas), et le role `serveur_powerdns` l'importe a chaque passage.
LE DS SE CALCULE SANS LA MACHINE. Il se deduit de la cle publique ; ce script le rend depuis
la voute seule. Le locataire peut donc le remettre a son registraire avant meme que la zone
soit deployee — et le rapprocher de ce que le registre publie (`verifier`).
UNE CLE, CSK, ECDSA P-256 (algorithme 13). Un seul type de cle : pas de roulement ZSK a
orchestrer. P-256 : ce que tous les resolveurs valident, et des reponses courtes.
Usage :
python3 scripts/dnssec.py ds # DS de chaque zone `dnssec: true`
python3 scripts/dnssec.py verifier # voute, plan et registre concordent-ils ?
python3 scripts/dnssec.py generer chezlepro.ca # cree la cle si elle manque
CONFIRMER=true python3 scripts/dnssec.py generer chezlepro.ca --remplacer
Remplacer une cle dont le DS est publie rend le domaine BOGUS : `--remplacer` le refuse tant
que le registre publie un DS pour la zone.
"""
from __future__ import annotations
import argparse
import base64
import datetime as dt
import hashlib
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from inventory_rules import instance_courante # noqa: E402
from voutes import cle_de # noqa: E402
ALGORITHME = 13 # ECDSAP256SHA256
DRAPEAUX_CSK = 257 # SEP : la cle que le parent designe
RESOLVEUR = "9.9.9.9"
def nom_variable(zone: str) -> str:
"""`chezlepro.ca` -> `vault_dnssec_chezlepro_ca`. Le role derive le meme nom."""
return "vault_dnssec_" + re.sub(r"[^a-z0-9]", "_", zone.lower().rstrip("."))
# --- LA CLE ET SES DERIVES -------------------------------------------------------------
def _privee(scalaire_b64: str):
from cryptography.hazmat.primitives.asymmetric import ec
d = int.from_bytes(base64.b64decode(scalaire_b64), "big")
return ec.derive_private_key(d, ec.SECP256R1())
def nouvelle_cle() -> str:
from cryptography.hazmat.primitives.asymmetric import ec
k = ec.generate_private_key(ec.SECP256R1())
return base64.b64encode(k.private_numbers().private_value.to_bytes(32, "big")).decode()
def dnskey_rdata(scalaire_b64: str) -> bytes:
pub = _privee(scalaire_b64).public_key().public_numbers()
cle = pub.x.to_bytes(32, "big") + pub.y.to_bytes(32, "big")
return DRAPEAUX_CSK.to_bytes(2, "big") + bytes([3, ALGORITHME]) + cle
def etiquette(rdata: bytes) -> int:
"""Key tag, RFC 4034 annexe B."""
acc = 0
for i, octet in enumerate(rdata):
acc += octet << 8 if i % 2 == 0 else octet
acc = (acc & 0xFFFF) + (acc >> 16)
return acc & 0xFFFF
def _nom_filaire(zone: str) -> bytes:
return b"".join(bytes([len(p)]) + p.encode() for p in zone.lower().rstrip(".").split(".")) + b"\0"
def ds(zone: str, scalaire_b64: str) -> str:
"""Le DS (SHA-256, type 2) a remettre au registraire."""
rdata = dnskey_rdata(scalaire_b64)
empreinte = hashlib.sha256(_nom_filaire(zone) + rdata).hexdigest()
return f"{etiquette(rdata)} {ALGORITHME} 2 {empreinte}"
def fichier_prive(scalaire_b64: str) -> str:
"""Le format que `pdnsutil import-zone-key` lit (celui de BIND, v1.2)."""
return ("Private-key-format: v1.2\nAlgorithm: 13 (ECDSAP256SHA256)\n"
f"PrivateKey: {scalaire_b64}\n")
# --- LA VOUTE DU LOCATAIRE --------------------------------------------------------------
ENV = dict(os.environ, PAGER="cat", ANSIBLE_PAGER="cat")
for _k in ("ANSIBLE_VAULT_IDENTITY_LIST", "ANSIBLE_VAULT_PASSWORD_FILE"):
ENV.pop(_k, None) # une voute, UNE cle : on ne laisse rien d'autre essayer
def _instance() -> Path:
base = instance_courante()
if not base.exists():
raise SystemExit("REFUS : aucune instance montee (symlink `instance/` ou SETOPS_INSTANCE). "
"Une cle DNSSEC appartient a UN locataire.")
return base.resolve()
def _voute(base: Path) -> tuple[Path, Path]:
f = base / "inventories" / "principal" / "group_vars" / "all" / "vault.yml"
cle = cle_de(base.name)
if not f.is_file() or not cle.is_file():
raise SystemExit(f"REFUS : voute ({f}) ou cle ({cle}) absente.")
return f, cle
def _dechiffrer(fichier: Path, cle: Path) -> str:
# PAR UN TUBE : ansible-vault ecrit sur une sortie non bloquante et echoue en silence
# sans lui (docs : « ansible-vault exige un tube »).
r = subprocess.run(["ansible-vault", "view", "--vault-password-file", str(cle), str(fichier)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=ENV)
if r.returncode != 0:
raise SystemExit(f"ECHEC du dechiffrement de {fichier} : {r.stderr.strip()[-160:]}")
return r.stdout
def lire_voute(base: Path) -> dict:
return yaml.safe_load(_dechiffrer(*_voute(base))) or {}
def _empreinte(d: dict) -> str:
return hashlib.sha256(yaml.safe_dump(d, sort_keys=True, allow_unicode=True).encode()).hexdigest()
def ecrire_variable(base: Path, nom: str, valeur: str, commentaire: str) -> Path:
"""Pose ou remplace UNE variable, voute relue avant et apres. Rend la copie de surete."""
f, cle = _voute(base)
clair = _dechiffrer(f, cle)
data = yaml.safe_load(clair) or {}
motif = re.compile(rf"^{re.escape(nom)}:.*$", re.MULTILINE)
if nom in data:
if len(motif.findall(clair)) != 1:
raise SystemExit(f"REFUS : `{nom}` n'est pas sur une seule ligne dans la voute ; "
"a remplacer a la main.")
nouveau = motif.sub(f'{nom}: "{valeur}"', clair)
else:
nouveau = clair.rstrip("\n") + f"\n\n# --- {commentaire} ---\n{nom}: \"{valeur}\"\n"
attendu = {**data, nom: valeur}
if yaml.safe_load(nouveau) != attendu:
raise SystemExit("REFUS : le YAML modifie ne reproduit pas l'attendu ; rien n'est ecrit.")
sauve = Path.home() / ".config" / f"setops-voutes-avant-dnssec-{dt.date.today().isoformat()}"
sauve.mkdir(mode=0o700, exist_ok=True)
copie = sauve / f"{base.name}--{dt.datetime.now():%H%M%S}--{f.name}"
shutil.copy2(f, copie)
atelier = Path(tempfile.mkdtemp(dir=sauve))
atelier.chmod(0o700)
f_clair, f_chiffre = atelier / "clair.yml", atelier / "chiffre.yml"
try:
f_clair.write_text(nouveau, encoding="utf-8")
f_clair.chmod(0o600)
r = subprocess.run(["ansible-vault", "encrypt", "--vault-password-file", str(cle),
"--output", str(f_chiffre), str(f_clair)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=ENV)
if r.returncode != 0:
raise SystemExit(f"ECHEC du chiffrement : {r.stderr.strip()[-160:]}")
if not f_chiffre.read_text(encoding="utf-8").startswith("$ANSIBLE_VAULT;1.1;AES256"):
raise SystemExit("ECHEC : en-tete de voute inattendu ; rien n'est remplace.")
if _empreinte(yaml.safe_load(_dechiffrer(f_chiffre, cle)) or {}) != _empreinte(attendu):
raise SystemExit("ECHEC : la relecture differe de l'attendu ; rien n'est remplace.")
mode = f.stat().st_mode & 0o777
os.replace(f_chiffre, f)
f.chmod(mode)
finally:
if f_clair.exists():
subprocess.run(["shred", "-u", "-z", str(f_clair)], check=False)
shutil.rmtree(atelier, ignore_errors=True)
if _empreinte(yaml.safe_load(_dechiffrer(f, cle)) or {}) != _empreinte(attendu):
raise SystemExit(f"ECHEC : la voute en place differe apres remplacement. Copie : {copie}")
return copie
# --- LE PLAN ET LE REGISTRE -------------------------------------------------------------
def zones_signees(base: Path) -> list[str]:
reg = yaml.safe_load((base / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {}
return sorted(z for z, c in (reg.get("domaines_publics") or {}).items()
if c.get("autorite") == "primaire-cache" and c.get("dnssec") is True)
def ds_publies(zone: str) -> set[str] | None:
"""Les DS que le parent publie, ou None si la mesure echoue (jamais « aucun »)."""
r = subprocess.run(["dig", "+noall", "+answer", "+comments", "+time=4", "+tries=2",
f"@{RESOLVEUR}", zone, "DS"], capture_output=True, text=True, timeout=30)
statut = next((ligne.split("status:")[1].split(",")[0].strip()
for ligne in r.stdout.splitlines() if "status:" in ligne), None)
if r.returncode != 0 or statut != "NOERROR":
return None
vus = set()
for ligne in r.stdout.splitlines():
champs = ligne.split()
if len(champs) >= 8 and not ligne.startswith(";") and champs[3] == "DS":
vus.add(f"{champs[4]} {champs[5]} {champs[6]} {''.join(champs[7:]).lower()}")
return vus
# --- COMMANDES ------------------------------------------------------------------------
def cmd_ds(args) -> int:
base = _instance()
voute = lire_voute(base)
zones = [args.zone] if args.zone else zones_signees(base)
if not zones:
print(f"Aucune zone `dnssec: true` dans {base.name}.")
return 0
manque = 0
for z in zones:
s = voute.get(nom_variable(z))
if not s:
print(f"{z} : AUCUNE CLE dans la voute ({nom_variable(z)}) — `dnssec.py generer {z}`.")
manque += 1
continue
print(f"{z}. IN DS {ds(z, s)}")
print(f" DNSKEY 257 3 13 {base64.b64encode(dnskey_rdata(s)[4:]).decode()}")
print("\nA remettre au registraire (type de condensat 2, SHA-256), APRES que les serveurs de "
"noms servent la zone signee — jamais avant.")
return 1 if manque else 0
def cmd_verifier(args) -> int:
base = _instance()
voute = lire_voute(base)
code = 0
zones = zones_signees(base)
orphelines = sorted(k for k in voute if k.startswith("vault_dnssec_")
and k not in {nom_variable(z) for z in zones})
for z in zones:
s = voute.get(nom_variable(z))
if not s:
print(f"{z} : ECHEC — `dnssec: true` au plan, aucune cle dans la voute.")
code = 1
continue
attendu = ds(z, s).lower()
publies = ds_publies(z)
if publies is None:
print(f"{z} : MESURE IMPOSSIBLE — le DS publie n'a pu etre lu via {RESOLVEUR}.")
code = max(code, 2)
elif not publies:
print(f"{z} : cle presente ; le registre ne publie aucun DS (zone signee, pas encore ancree).")
elif attendu in publies:
print(f"{z} : CONFORME — le registre publie le DS de la cle de la voute.")
else:
print(f"{z} : ECHEC — le registre publie {sorted(publies)}, la voute donne {attendu}. "
"Tout resolveur validant tient ce domaine pour BOGUS.")
code = 1
for k in orphelines:
print(f"note : {k} est dans la voute, mais aucune zone `dnssec: true` ne la lit.")
return code
def cmd_generer(args) -> int:
base = _instance()
z = args.zone.lower().rstrip(".")
reg = yaml.safe_load((base / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {}
conf = (reg.get("domaines_publics") or {}).get(z)
if not conf or conf.get("autorite") != "primaire-cache":
raise SystemExit(f"REFUS : {z} n'est pas une zone `primaire-cache` de {base.name}.")
voute = lire_voute(base)
var = nom_variable(z)
if voute.get(var):
if not args.remplacer:
print(f"{z} : une cle existe deja ({var}). Rien n'est fait. DS : {ds(z, voute[var])}")
return 0
if os.environ.get("CONFIRMER") != "true":
raise SystemExit("REFUS : remplacer une cle DNSSEC exige CONFIRMER=true.")
publies = ds_publies(z)
if publies is None or publies:
raise SystemExit(
f"REFUS : le registre publie un DS pour {z} (ou la mesure a echoue). Remplacer la "
"cle maintenant rendrait le domaine BOGUS. Un roulement se fait en publiant "
"d'abord le nouveau DS a cote de l'ancien — hors de portee de cette commande.")
s = nouvelle_cle()
copie = ecrire_variable(base, var, s, f"DNSSEC : cle CSK ECDSA P-256 de {z} ({dt.date.today()})")
print(f"{z} : cle posee dans la voute de {base.name} ({var}), relue en place.")
print(f" DS : {ds(z, s)}")
print(f" copie de surete chiffree de l'ancienne voute : {copie}")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sous = ap.add_subparsers(dest="commande", required=True)
p = sous.add_parser("ds", help="DS a remettre au registraire, depuis la voute")
p.add_argument("zone", nargs="?")
p.set_defaults(fn=cmd_ds)
p = sous.add_parser("verifier", help="voute, plan et registre concordent-ils ? (aucune ecriture)")
p.set_defaults(fn=cmd_verifier)
p = sous.add_parser("generer", help="cree la cle d'une zone dans la voute du locataire")
p.add_argument("zone")
p.add_argument("--remplacer", action="store_true")
p.set_defaults(fn=cmd_generer)
args = ap.parse_args()
return args.fn(args)
if __name__ == "__main__":
sys.exit(main())