Aller au contenu

Alerter sur une divergence

Lorsque le ledger constate que l’histoire scellée ne correspond plus au WAL, il refuse de sceller et sort avec un code non nul. C’est délibéré : un journal réécrit est un incident, et un service qui réessaierait toutes les trente secondes le déguiserait en battement de cœur. Ces conditions ne se réparent jamais d’elles-mêmes :

ErreurSignification
DivergedLogl’enregistrement à la frontière scellée ne hache plus vers la tête scellée : le WAL a été réécrit après le scellement
TruncatedLogdes enregistrements scellés ont disparu du WAL
UnauthenticatedRecordun enregistrement dont aucune clé d’origine de confiance ne répond : quelqu’un d’autre que la passerelle a écrit dans le WAL
StoreBrokenle store de points de contrôle est lui-même corrompu
KeyConflictun identifiant de clé réutilisé avec un matériel de clé différent

Tout le reste (un hoquet d’E/S, un segment de WAL surpris en cours d’écriture) fait l’objet d’une nouvelle tentative dans la boucle du mode run et ne provoque jamais de sortie. Toute sortie du mode run appartient donc, par construction, à la classe de ce qui ne se répare jamais tout seul.

Sortir n’est que la moitié du travail ; qui est prévenu ? Pas par le ledger lui-même, qui ne fait jamais aucun appel réseau : un webhook qui ne peut pas quitter l’enclave est une alerte que personne ne reçoit. Prévenir quelqu’un est le travail du superviseur, et sur les déploiements visés le superviseur est systemd.

[Unit]
Description=Obsign ledger — seals the gateway audit log
OnFailure=obsign-alert@%n.service
[Service]
Type=exec
User=obsign-ledger
ExecStart=/usr/local/bin/obsign-ledger run \
--wal /var/lib/obsign/wal --chain-id prod \
--store /var/lib/obsign/ledger \
--hsm-module /usr/lib/pkcs11/vendor.so \
--hsm-key-label seal-prod \
--hsm-pin-file /etc/obsign/hsm-pin \
--key-id seal-prod
# No Restart=. Transient failures are retried inside the loop and never
# exit; whatever does exit never self-heals. Restart=on-failure would
# re-detect the same divergence at every start — the heartbeat the exit
# code exists to avoid.
Restart=no
[Install]
WantedBy=multi-user.target

L’unité d’alerte (obsign-alert@.service, un gabarit instancié avec le nom de l’unité en échec) exécute un script propre au site. Tout ce qu’il contient est local ou reste sur le réseau d’administration :

#!/bin/sh
unit="$1"
# 1. A critical journal entry — the SIEM collector that already reads
# this host's journal picks it up without any new plumbing.
echo "AUDIT INCIDENT: $unit failed — sealed history no longer matches \
the WAL. journalctl -u $unit for the refusal." | systemd-cat -t obsign -p crit
# 2. Everyone logged in on the host, immediately.
echo "AUDIT INCIDENT: $unit failed — see journalctl -u $unit" | wall
# 3. Local mail through the site's internal relay, evidence attached.
{ printf 'Subject: [obsign] AUDIT INCIDENT on %s: %s\n\n' "$(hostname)" "$unit"
journalctl -u "$unit" -n 50 --no-pager
} | sendmail ops@site.internal

Le message de refus du ledger (quel numéro de séquence a divergé, si un préfixe authentique a été scellé) se trouve sur stderr, donc dans le journal de l’unité en échec. L’alerte n’a qu’à le désigner.

Une alerte dans un outil de discussion vaut la peine, à condition que la même règle tienne : l’appel réseau vit dans le superviseur, pas dans le ledger. Le script livré obsign-slack-alert.sh publie vers un Incoming Webhook sous trois contraintes :

  • Au mieux, jamais porteur. Il s’exécute aux côtés des canaux locaux (journal, wall, courrier), qui délivrent toujours à l’intérieur de l’enclave. Si Slack est injoignable, le script consigne un avertissement et sort avec 0 : un canal d’alerte ne doit jamais devenir l’incident.
  • Borné. Délais de connexion et délais totaux ; l’URL du webhook est lue depuis un fichier appartenant à root, jamais figée dans une unité ou dans un argument.
  • Conscient de la sortie réseau. Il honore https_proxy ; un site sans sortie fait tourner à la place un relais de type store-and-forward sur le réseau d’administration. L’enclave elle-même n’ouvre jamais de connexion sortante.

Le seal en une passe est le mode adapté à cron et à l’air-gap, avec un ajout. Un timer continue de se déclencher après un échec, et chaque passe redétecterait et réalerterait sur la même divergence. Le verrou arrête le scellement après le premier incident jusqu’à ce qu’un opérateur le lève explicitement :

# obsign-seal.service
[Unit]
Description=Obsign sealing pass
OnFailure=obsign-alert@%n.service
# The latch: once the flag exists the timer stops producing alerts (and
# seals) until the operator removes the file. Clearing it is a deliberate
# act that concludes an investigation, not a retry.
ConditionPathExists=!/var/lib/obsign/ledger/INCIDENT
[Service]
Type=oneshot
User=obsign-ledger
ExecStart=/usr/local/bin/obsign-ledger seal ...
ExecStopPost=/bin/sh -c '[ "$$SERVICE_RESULT" = success ] || \
touch /var/lib/obsign/ledger/INCIDENT'

Une réserve honnête : contrairement au mode run, un seal en une passe ne peut pas réessayer en interne, si bien qu’une défaillance passagère (HSM brièvement injoignable) déclenche elle aussi l’alerte. La fin du journal dit dans quelle classe vous êtes. Si cette distinction compte à votre échelle, faites tourner le démon ; toutes ses sorties sont de classe incident.

L’image distroless du ledger n’embarque pas systemd : gardez le superviseur à l’extérieur. Lancez le conteneur depuis une unité systemd (ExecStart=docker run --rm ...) : le code de sortie du conteneur devient celui de l’unité, et OnFailure= s’applique sans changement. Une politique compose restart: always sur le ledger reconvertit l’incident en battement de cœur, sans que personne ne soit prévenu.

Traitez-la comme un incident. Le WAL et le store du ledger sont la preuve ; ne les « réparez » pas pour repasser le scellement au vert. obsign-ledger export fonctionne toujours et écrit le pack même lorsqu’il échoue à la vérification ; un pack en échec sur le disque est exactement ce que veut l’investigation. Le dernier point de contrôle scellé (et son ancre RFC 3161, s’il en a une) borne le moment où la réécriture s’est produite : tout jusqu’à to_seq reste démontrable, et cette frontière est le point de départ de la chronologie.