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é.
op://…/cleop://…/key
le dépôt ne contient que des référencesthe repo only holds references
op run -- python …op run -- python …
en clair, mais seulement en mémoireplaintext, but only in memory
tube, pas de fichierpipe, no file
rien dans ps, rien dans l’historiquenothing in ps, nothing in history
stdin → --format yamlstdin → --format yaml
chiffré pour la clé du contrôleurencrypted for the controller key
git commitgit commit
testé : chaque clé > 100 caractères de chiffrétested: every key > 100 chars of ciphertext
ps. Ce qui redescend du cluster est déjà chiffré.
The value only exists in plaintext in memory (steps ② and ③): never in a file,
never in an argument visible to ps. What comes back from the cluster is already encrypted.
Trois outils suffisent :
- 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. 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.- Un petit script qui lit cet environnement et écrit le
SecretKubernetes 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
- 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. - Toutes les clés, toujours. Vides plutôt qu’absentes, et entre guillemets pour que YAML ne les transforme pas en
null. - 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.
- 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. - 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.
- 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.