❯ infra · Running

Sceller des secrets sans laisser de trace Sealing secrets without leaving a trace

Alimenter kubeseal depuis un coffre de mots de passe par la mémoire du processus et un tube SSH, sans qu'aucune valeur ne touche le disque ni une ligne de commande — et les cinq façons dont un secret s'est quand même corrompu ou échappé en chemin. Feeding kubeseal from a password vault through process memory and an SSH pipe, so no value ever lands on disk or in a command line — and the five ways a secret still got corrupted or leaked along the way.

· 9 min de lecture 9 min read ·
k3s-clustersecuritysecretsgitopsk8s
🇬🇧 Read this article in English

Le point de départ : une commande qu’on tape cent fois

Le 11 septembre 2026, une application doit partir en production pour la première fois. Son Deployment référence treize clés par secretKeyRef : une clé d’API LLM, les identifiants SMTP, un jeton de publication, les secrets d’un prestataire de paiement, un sel de hachage. Le dépôt est propre : un audit d’exposition passé la veille a déjà sorti un jeton d’une URL de git clone et durci les messages d’erreur. Reste à sceller.

La méthode documentée partout, y compris dans mon propre article sur Sealed Secrets, tient en deux lignes :

kubectl create secret generic app-secrets \
  --from-literal=API_KEY='sk-…' --dry-run=client -o yaml > secret.yaml
kubeseal --format yaml < secret.yaml > sealedsecret.yaml && rm secret.yaml

Elle marche. Elle laisse aussi trois traces. La valeur passe en argument : elle est visible dans ps le temps de l’exécution, et elle reste dans l’historique du shell. Le fichier secret.yaml existe en clair sur le disque, et un rm n’efface rien sur un SSD. Avec treize clés, c’est treize occasions de se tromper — sans compter le jour où on oublie le rm.

Le principe : la valeur ne vit qu’en mémoire

L’objectif est simple à énoncer : entre le coffre de mots de passe et le dépôt Git, la valeur ne doit jamais exister ailleurs que dans la mémoire d’un processus ou dans un canal chiffré.

Trois outils suffisent :

  1. Le coffre (ici 1Password et sa CLI op). Le dépôt ne contient qu’un fichier de références, API_KEY=op://Coffre/App/api-key. Ce fichier peut être commité : il ne dit rien.
  2. op run, qui résout les références et lance un processus avec les valeurs dans son environnement. L’environnement d’un processus n’est lisible que par son propriétaire et root, et il disparaît avec lui.
  3. Un petit script qui lit cet environnement et écrit le Secret Kubernetes sur sa sortie standard. Pas dans un fichier.

La sortie part dans un tube SSH vers un nœud control-plane, où kubeseal lit son entrée standard :

op run --env-file=refs.env -- python secret_pour_kubeseal.py app \
  | ssh <admin>@<control-plane> \
    "sudo -E KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubeseal \
       --controller-namespace kube-system --format yaml > /tmp/sealedsecret-app.yaml"

Ce qui atterrit sur le disque du nœud est déjà chiffré pour la clé publique du contrôleur. Ce qu’on rapatrie et qu’on commite aussi. La forme en clair du Secret n’a existé que dans un tampon de tube.

Ici, kubeseal tourne sur le nœud, là où se trouve déjà le kubeconfig. Le scellement hors ligne avec --cert sur le poste fonctionne aussi ; le principe reste identique tant que la valeur entre par stdin.

Le script : quarante lignes et deux pièges

Le cœur du script est banal : un dictionnaire de clés attendues, un encodage base64, un YAML écrit à la main.

CLES_APP = ("API_KEY", "SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_PASSWORD", ...)

def secret_app(env: dict[str, str]) -> str:
    valeurs = {c: env.get(c, "") for c in CLES_APP}
    if not valeurs["CONTACT_HASH_SECRET"]:
        valeurs["CONTACT_HASH_SECRET"] = secrets.token_hex(32)
    # Guillemets obligatoires : le base64 d'une chaîne vide est une chaîne vide,
    # que YAML lirait comme null.
    lignes = [f'  {cle}: "{_b64(val)}"' for cle, val in valeurs.items()]
    return "apiVersion: v1\nkind: Secret\n...data:\n" + "\n".join(lignes) + "\n"

Les deux pièges sont dans les commentaires.

Piège n° 1 : une clé vide n’est pas une clé nulle

Certaines clés sont légitimement vides au premier déploiement : le prestataire de paiement n’est pas encore configuré, la fonction qui l’utilise reste désactivée. Le base64 de "" est "". Écrit sans guillemets, PAYMENT_SECRET: est lu par YAML comme null, et kubeseal scelle une valeur nulle au lieu d’une chaîne vide. Avec les guillemets, PAYMENT_SECRET: "" reste une chaîne. La différence ne se voit qu’au démarrage du pod.

Piège n° 2 : une clé absente bloque le pod

La tentation est d’omettre les clés vides. Mauvaise idée : le Deployment les référence toutes, et une secretKeyRef vers une clé qui n’existe pas donne un CreateContainerConfigError. Le pod ne démarre pas, pour une fonction qu’on n’utilise même pas encore. Le script écrit donc toujours les treize clés, vides au besoin. Une clé vide est un réglage désactivé, parfaitement valide ; une clé absente est une panne. (L’alternative propre, optional: true sur la secretKeyRef, n’a de sens que si l’application sait vraiment vivre sans.)

Un troisième point, plus sournois, concerne le sel CONTACT_HASH_SECRET. Le script le tire au sort s’il manque, ce qui est confortable. Mais des liens envoyés par e-mail en sont dérivés par HMAC : sceller à nouveau avec un sel différent invalide tous les liens déjà envoyés. Après le premier scellement, la valeur tirée au sort doit donc retourner dans le coffre. Le confort d’une valeur générée se paie par une valeur qu’il faut conserver.

Les tests : celui qui bloquait le seul état correct

Le dépôt avait des tests sur les manifests. Deux d’entre eux vérifiaient que les SealedSecrets commités ne contenaient que le repère à sceller : une garantie raisonnable tant que les fichiers n’étaient que des gabarits, pour s’assurer qu’aucune vraie valeur n’y traînait.

Le jour du scellement, ces deux tests sont passés au rouge. Ils bloquaient exactement le seul état correct en production. La garantie utile est l’inverse : toutes les clés présentes, et toutes chiffrées.

def _chiffre(valeur) -> bool:
    """Un secret scellé est un chiffré RSA+AES encodé en base64 : plusieurs centaines
    de caractères. Tout ce qui est court, vide ou porte un repère de gabarit est soit
    un gabarit oublié, soit une valeur en clair."""
    return isinstance(valeur, str) and len(valeur) > 100 and "sceller" not in valeur

def test_sealedsecret_app_porte_les_treize_cles_toutes_chiffrees():
    chiffrees = sealedsecret["spec"]["encryptedData"]
    assert set(chiffrees) == CLES_SENSIBLES
    assert not [c for c, v in chiffrees.items() if not _chiffre(v)]

Le seuil de cent caractères n’a rien de cryptographique. Il attrape les deux erreurs réelles : un gabarit oublié, et une valeur en clair collée par mégarde dans encryptedData (une clé d’API fait rarement plus de cent caractères, un chiffré scellé jamais moins). Le secret d’accès au registre d’images a son propre test : type kubernetes.io/dockerconfigjson, sinon le kubelet l’ignore et le pod reste en ErrImagePull.

Le retour de bâton : quand le tube lui-même abîme le secret

Le 9 octobre 2026, une autre application du cluster (un agent de tri d’e-mails) doit recevoir quatre SealedSecrets, dont une clé d’API LLM. Même principe, un autre poste : cette fois, le scellement est lancé depuis PowerShell. Quatre incidents en une demi-heure.

Les retours à la ligne disparus. Le SealedSecret est écrit par Out-File -NoNewline. Le YAML perd ses sauts de ligne : le fichier n’est plus un document valide. Corrigé en restaurant les retours à la ligne, sans rescellement.

Le BOM et le CRLF. Le tube PowerShell a glissé un BOM UTF-8 devant la valeur et un \r\n derrière. Le Secret déchiffré contient donc trois octets invisibles en tête et un retour chariot en queue. L’application place la clé dans un en-tête HTTP : en-tête invalide, chaque appel échoue.

La fuite par le journal. Pire : l’exception levée (UnicodeEncodeError) recopie la chaîne fautive dans son message, et le journal du pod affichait l’exception complète. La clé s’est donc retrouvée en clair dans le journal du pod. Ce n’est plus une panne, c’est une fuite. Il n’y a qu’une réponse : la clé est à révoquer, une nouvelle est émise.

La troncature. Rescellée par le même tube, la nouvelle clé arrive amputée de six caractères. Rescellée une troisième fois depuis Git Bash, en comparant cette fois une empreinte de la valeur avant le tube et après déchiffrement dans le pod.

Les correctifs côté application sont modestes et définitifs : toute valeur lue depuis un Secret est nettoyée (BOM, espaces et fins de ligne retirés) avant usage, et les erreurs ne sont plus journalisées que par leur type et leurs 120 premiers caractères. Un secret ne doit jamais pouvoir remonter dans un message d’erreur, même si on l’a mal transporté.

Ce que je garde

  1. Aucune valeur en argument, aucun fichier en clair. Coffre → environnement → stdout → stdin de kubeseal. Si une étape demande un fichier, c’est l’étape à remplacer.
  2. Toutes les clés, toujours. Vides plutôt qu’absentes, et entre guillemets pour que YAML ne les transforme pas en null.
  3. Tester le chiffré, pas le gabarit. Un test qui exige un repère de gabarit bloque la production le jour où on scelle. Exiger des valeurs longues et opaques couvre à la fois le gabarit oublié et la valeur en clair.
  4. Vérifier par empreinte. Comparer un hash de la valeur avant le tube et après déchiffrement dans le pod (kubectl exec … | sha256sum), plutôt que « ça a l’air de marcher ». Un shell qui ajoute un BOM ne prévient pas.
  5. Nettoyer en entrée, censurer en sortie. L’application nettoie ce qu’elle lit d’un Secret et ne journalise jamais une exception brute qui pourrait contenir une valeur.
  6. Une valeur générée est une valeur à conserver. Un sel tiré au sort dont dépendent des liens ou des jetons doit retourner au coffre immédiatement.

Le premier scellement s’est fait avec un script et des tests écrits pour l’occasion ; il n’a cassé qu’un test. Le second, avec un shell moins coopératif, a coûté une clé révoquée. La mécanique de Sealed Secrets n’était en cause dans aucun des deux cas : ce qui casse, c’est le transport de la valeur jusqu’à kubeseal.

k3s-clustersecuritysecretsgitopsk8s