🔑 Les clés secrètes deviennent des données d'application
La plupart des applications Symfony que j’écris considèrent encore la clé du modèle comme faisant partie de l’infrastructure :
ANTHROPIC_API_KEY=...
Cette ligne est simple car la propriété est simple. L’identifiant appartient au déploiement. Les opérateurs l’enregistrent dans .env.local ou dans un coffre-fort de secrets. Le conteneur l’injecte une seule fois au démarrage. L’application n’a jamais à l’accepter, à le stocker ou à décider quand il est autorisé à exister en mémoire.
Le modèle « Bring-your-own-key » (apportez votre propre clé) change le propriétaire.
Que se passe-t-il lorsque la clé appartient à l’utilisateur à la place ?
Elle arrive via HTTP. Elle est associée à une entité de l’application. Elle doit perdurer d’une requête à l’autre. Elle ne doit pas être stockée en clair, ne doit pas être réinsérée dans le code HTML, et ne doit être déchiffrée que lorsqu’un élément a réellement besoin d’appeler un fournisseur. Ce fournisseur peut engager des dépenses.
Le fichier .env n’est plus la solution. L’identifiant est devenu une donnée d’application.
L’expérience de cette semaine, navi-key-management, est une petite application Symfony 8.2 qui implémente ce cycle de vie puis le mesure. Je rapporte ici ce qui a été exécuté le 19/09/2026 : PHP 8.5.4, Symfony 8.2.x-dev, SQLite, Sodium local. Symfony KeyManagement est expérimental. C’est ce qu’indiquent ses docblocks d’installation. Considérez chaque signature ici comme un instantané, et non comme une promesse concernant la version stable 8.2.
BYOK modifie les limites
Le chemin que j’ai codé est celui que je souhaitais pouvoir lire :
Utilisateur ↓ Formulaire Symfony ↓ Chiffrement par KeyManagement ↓ Texte chiffré SQLite ↓ Déchiffrement au moment de l’exécution ↓ Darkwood Navi ↓ Symfony AI ↓ Anthropic / fournisseur
Chaque flèche représente un type de traitement différent. Le formulaire ne sait pas comment une requête de chat est construite. Navi ne stocke pas les secrets. Symfony AI ne détient pas la ligne de la base de données. KeyManagement ne communique pas avec Claude.
Je n’ai pas ajouté de couche de référentiel ni de deuxième SDK d’IA. Les abstractions intéressantes existaient déjà dans la pile. L’une d’entre elles pouvait facilement être mal nommée.
darkwood/navi est le shell du workflow : Action, Context, WorkflowRunner, Event. Il ne comporte ni client Anthropic ni type pour une clé API. C’est Symfony AI qui communique avec le modèle. La chaîne d’exécution dans ce référentiel est la suivante :
AiConnection ↓ CredentialProtector::decrypt() ↓ WorkflowRunner ↓ TestAiConnectionAction ↓ Anthropic\Factory::createPlatform($apiKey) ↓ provider
Navi orchestre une étape. Il ne remplace pas Symfony AI.
Il y a eu un problème de compatibilité des paquets qui mérite d’être mentionné ici, car il est bien réel. Le paquet Packagist darkwood/navi ^1.0 nécessite toujours Symfony 8.0. Cette application a besoin de la version 8.2.x-dev ; Navi est donc un dépôt de chemin d’accès vers le monorepo local (8.1.x-dev). Expérimenter avant que les contraintes de la version finale ne soient fixées, c'est ça : un lien symbolique, pas un slogan.
Symfony se dote d'une primitive de gestion des clés
Une fois que la clé est considérée comme une donnée d'application, la première pièce manquante n'est pas un autre wrapper AI. C'est un moyen de chiffrer un petit secret sans avoir à inventer un service de cryptographie.
KeyManagement de Symfony 8.2 est cette fonctionnalité de base. J’ai installé symfony/key-management et symfony/aws-key-management sur la version 8.2.x-dev et j’ai utilisé les interfaces que le conteneur connecte automatiquement :
$ciphertext = $this->encrypter->encrypt($this->keyId, $apiKey); $connection->setCiphertextBlob(base64_encode($ciphertext->blob)); $connection->setKmsKeyId($ciphertext->keyId);
La méthode encrypt() renvoie un objet Ciphertext, et non une chaîne de caractères. La ligne regroupe le blob et l’identifiant de clé. L’application injecte les interfaces EncrypterInterface et DecrypterInterface. Le chiffrement par enveloppe est disponible dans le même bundle. Je ne l’ai pas utilisé. Une clé API fait plusieurs centaines d’octets, ce qui est bien en deçà de la limite de 4 Ko imposée par le chiffrement direct sur AWS KMS. Le chiffrement d’enveloppe est destiné aux fichiers et aux documents volumineux, ou à une politique stipulant que le KMS ne doit jamais avoir accès au texte en clair de l’application. Ce n’est pas obligatoire, car le composant le fournit.
La configuration que j’ai finalement retenue n’est pas le DSN d’une seule ligne que j’avais d’abord essayé.
key_management: clients: app: 'sodium://?keys[app-key]=%env(DEV_KMS_KEY)%' default_client: app when@prod: key_management: clients: app: 'aws-kms://default?region=%env(AWS_KMS_REGION)%'
Un simple '%env(KMS_DSN)%' ne se compile pas. L’arborescence de configuration expérimentale marque le DSN du client comme cannotBeEmpty, et une variable d’environnement entièrement substituée est traitée comme vide au moment de la compilation :
Le chemin « key_management.clients.app » ne peut pas contenir de variable d’environnement lorsque les valeurs vides ne sont pas autorisées par définition et font l’objet d’une validation.
Le modèle de fonctionnement conserve un schéma littéral — sodium:// dans dev/test, aws-kms:// dans prod — et ne remplace par la variable d’environnement que la clé ou la région.
dev / test ↓ Sodium ← testé dans ce dépôt prod ↓ AWS KMS ← configuré, non appelé
C’est grâce à Sodium que l’expérience est reproductible localement. Le processus dispose de DEV_KMS_KEY. Je l’ai générée avec sodium_crypto_aead_xchacha20poly1305_ietf_keygen() et Base64UrlSafe. Quiconque peut lire cette valeur d’environnement peut déchiffrer toutes les clés API stockées. C’est acceptable pour une démonstration sur un ordinateur portable. Ce n’est pas le cas en production.
AWS KMS est la frontière externe vers laquelle pointe le DSN de production. Le pont AWS chiffrer côté serveur ; la clé principale est censée rester dans KMS. Je n’ai pas envoyé de requête à AWS. Le bloc de production correspond à la configuration, et non à un résultat de test.
Le badge de l’interface utilisateur n’analyse pas le DSN. KMS_BACKEND est une étiquette (Sodium ou AWS KMS), de sorte que le matériel de chiffrement ne devient jamais une variable Twig.
La clé de développement enregistrée dans le fichier .env de l’expérience est une clé de développement uniquement. Générez une clé privée Sodium avant d’utiliser ce modèle pour une application réelle.
Une petite expérience
Le modèle est constitué d’une seule entité, AiConnection : libellé, fournisseur, modèle, point de terminaison facultatif, ciphertextBlob, kmsKeyId, horodatages. Il n’y a pas de colonne contenant la clé API en clair.
L’interface utilisateur se compose de quatre routes et d’un panneau sombre. Elle a pour but de rendre le cycle de vie visible : une liste avec des badges « chiffré » et « backend », un formulaire, une action de test, une page de résultats. Il ne s’agit pas d’un produit de type tableau de bord.
GET / GET|POST /connections/new GET|POST /connections/{id}/edit POST /connections/{id}/test
Cela constitue l'intégralité de l'interface de l'application.
Le formulaire est désormais une frontière de sécurité
La clé était auparavant introduite via dotenv. Elle est désormais saisie via un formulaire. Le formulaire fait donc désormais partie intégrante de la conception de la sécurité, et n’est plus simplement une « enveloppe » d’un DTO.
Symfony 8.2 peut déduire le type de formulaire à partir de la classe qui contient les données. Ce DTO comporte cinq champs. C’est à cette taille que les nouveaux attributs sont adaptés.
#[AsFormType] final class AiConnectionInput { #[FormField(ChoiceType::class, [ 'choices' => [ 'Anthropic' => 'anthropic', 'OpenAI' => 'openai', ], ])] public string $provider = 'anthropic'; #[FormField(PasswordType::class, [ 'always_empty' => true, 'attr' => ['autocomplete' => 'new-password'], ])] #[Assert\NotBlank(groups: ['create'])] public ?string $apiKey = null; }
createForm(AiConnectionInput::class, $input) suffit. Il n'y a pas d'AbstractType. Le nom compilé est ai_connection_input.
Le comportement de sécurité que je souhaitais est assuré à la fois par les attributs et par le contrôleur. Le champ « key » correspond à la saisie d’un mot de passe. always_empty empêche le navigateur d’afficher une valeur. Lors de la modification, le contrôleur copie le libellé, le fournisseur, le modèle et le point de terminaison dans le DTO et laisse apiKey nul. Un envoi sans valeur n’appelle pas encrypt() ; le texte chiffré existant est conservé. NotBlank n’apparaît que dans le groupe create.
Les formulaires basés sur des attributs m’ont évité d’écrire une classe que j’aurais autrement dû créer. Ils n’ont pas supprimé les groupes de validation ni la protection CSRF. La recette Flex « stateless-CSRF » a laissé value="csrf-token" dans le code HTML et prévoyait un contrôleur Stimulus que cette application ne fournit pas. La protection CSRF par session a permis de faire fonctionner un envoi normal via le navigateur. C’est une petite leçon de la version 8.2 qui vient compléter la grande : les nouveaux attributs de formulaire s’intègrent bien dans un DTO de configuration, et ils ne suppriment pas le reste de la pile de formulaires.
Texte chiffré dans SQLite
Après l’envoi par le navigateur d’une fausse clé, SQLite a conservé des métadonnées ainsi qu’un blob :
$ sqlite3 var/data.db "SELECT kms_key_id, length(ciphertext_blob), instr(ciphertext_blob, 'sk-ant') FROM ai_connection;" app-key|96|0
instr(ciphertext_blob, 'sk-ant') = 0. Une comparaison LIKE avec la valeur soumise a également donné 0. Le texte stocké est un Base64 opaque de Ciphertext::$blob.
Il s’agit de la preuve de persistance la plus solide de la semaine, mais elle présente une limite claire. Elle prouve que le texte en clair identifiable est absent de cette colonne. Il ne s’agit pas d’un audit cryptographique. Elle ne prouve pas que l’algorithme de chiffrement soit Sodium XChaCha, ne prouve pas la gestion des nonces, et ne prouve pas qu’un attaquant disposant de la clé principale de développement soit bloqué. PHPUnit effectue le même contrôle d’absence sur la réponse HTTP : la clé que j’ai publiée n’apparaît pas dans le code HTML.
La page de liste affiche chiffré et Sodium. Elle n’affiche pas la clé.
Déchiffrer au dernier moment responsable
La consultation d’une connexion ne déchiffre rien. Sa modification ne déchiffre rien. La clé n’est reconstruite que lorsqu’une action d’IA en a besoin.
$apiKey = $this->credentials->decrypt($connection); try { $result = $this->workflows->run( Context::fromArray([ 'connection_id' => $connection->getId(), 'provider' => $connection->getProvider(), 'model' => $connection->getModel(), ]), [new TestAiConnectionAction($apiKey, /* fournisseur, modèle, point de terminaison */)], ); } finally { $apiKey = str_repeat("\0", \strlen($apiKey)); unset($apiKey); }
texte chiffré stocké ↓ Test de connexion / sondage ↓ déchiffrement ↓ constructeur de TestAiConnectionAction ↓ Factory::createPlatform($apiKey) ↓ une invite
L'action marque l'argument du constructeur #[\SensitiveParameter]. Le Context transporte des identifiants. Il ne transporte pas le secret. C’est délibéré : le Context de Navi dispose de la méthode toArray(). Y placer la clé transformerait l’état de l’orchestration en une fuite d’informations.
app:probe-connection a signalé context_has_secret: no après une exécution en production. Navi peut orchestrer l’appel sans intégrer les identifiants dans le flux de travail.
Au sein de l’action, Symfony AI est instancié au moment de l’exécution. Une variable ANTHROPIC_API_KEY au niveau du conteneur dans ai.yaml aurait contrecarré le principe BYOK.
AnthropicFactory::createPlatform( $this->apiKey, $this->httpClient, baseUrl: $this->endpoint ?: 'https://api.anthropic.com', );
La prompt ne comporte qu’une seule ligne : Réponds par le mot pong. Si le client HTTP renvoie un message contenant la clé, SecretRedactor remplace cette sous-chaîne avant que l’erreur n’atteigne Twig.
Une requête ayant échoué peut tout de même nous apporter des informations
J’ai utilisé un identifiant Anthropic disponible localement via app:probe-connection. Je ne vais pas l’afficher ici.
plaintext_in_ciphertext: no decrypt_matches: yes context_has_secret: no test_ok: false test_error: Votre solde de crédit est trop faible pour accéder à l’API Anthropic. ...
Le chiffrement et le déchiffrement ont concordé. La clé était absente de Navi Context. La requête a quitté le processus et a atteint Anthropic. Anthropic a répondu par une erreur de facturation.
Une erreur de facturation reste une preuve de la distance parcourue par la requête. Une réponse indiquant une clé invalide aurait signifié que nous ne nous étions jamais authentifiés. Nous sommes allés plus loin que cela.
Il ne s’agit pas d’une opération réussie. Il ne s’agit pas d’une utilisation de jeton. Il ne s’agit pas d’un reçu. Le parcours de l’interface utilisateur avec une fausse clé affichait « La clé API est invalide. » et aucune information confidentielle. Les deux parcours d’erreur étaient utiles. Aucun des deux ne correspond à une conversation aboutie.
Les clés ont un rayon d’impact économique
L’article « Cost of a diff » de Guillaume Moigneu est à l’origine d’une idée, et non de ce code : une action d’IA a un coût économique observable. Des jetons d’entrée, des jetons de sortie, des jetons mis en cache, un modèle, un tarif.
Un secret qui déverrouille un fournisseur d’IA ne sert donc pas uniquement au contrôle d’accès. Il protège quelque chose qui peut dépenser de l’argent.
L’application dispose du chemin comptable. TestAiConnectionAction lit l’interface TokenUsageInterface de Symfony AI lorsque le résultat de l’appel contient token_usage :
jetons de prompt → entrée jetons de complétion → sortie création de cache → cache_write lecture de cache → cache_read jetons mis en cache → mis en cache
Les métriques manquantes restent null et s’affichent comme « non rapportées ». AiCostTable applique ensuite une liste explicite par million de dollars US datée du 19/09/2026 :
usd = entrée × taux_d’entrée / 1_000_000 + sortie × taux_de_sortie / 1_000_000 + cache_write × cache_write_rate / 1_000_000 (si la table en comporte un) + cache_read × cache_read_rate / 1_000_000 (si la table en comporte un)
Claude Sonnet 4.5 : dans cette table, les valeurs sont respectivement de 3 $ / 15 $ / 3,75 $ / 0,30 $. Une simulation locale avec des chiffres fictifs (42 entrées, 8 sorties) donne 0,000246 $. Ce chiffre est issu d’une vérification sur tableur. Il ne s’agit pas d’un reçu du fournisseur.
Aucune transaction payante n’a abouti cette semaine. Le chemin de facturation existe. Il n’a pas été validé par rapport aux métadonnées d’utilisation fournies par Anthropic. Je ne vais pas inventer de jetons pour donner l’impression que l’interface utilisateur est terminée.
Le fournisseur n’est pas un point de terminaison
Une discussion sur Symfony AI cette semaine a indiqué que Claude, via un abonnement Azure AI, fonctionnait en redirigeant le pont Anthropic existant vers une URL de messages compatible avec Anthropic, plutôt qu’en ajoutant une nouvelle abstraction Azure-Claude.
La structure rapportée était la suivante :
<resource>.services.ai.azure.com/anthropic/v1/messages
Le test rapporté comprenait un échange de messages complet et la mise en cache des invites. Je ne répéterai pas les identités privées mentionnées dans ce fil de discussion.
La méthode Factory::createPlatform() d’Anthropic déjà installée dispose déjà de $baseUrl. ModelClient envoie des requêtes vers {baseUrl}/v1/messages. Le point de terminaison facultatif du formulaire correspond donc à la base Azure sans ce suffixe :
https://<resource>.services.ai.azure.com/anthropic
baseUrl est configurée. L’appel à Azure Anthropic n’a pas été effectué.
Bedrock ne fonctionne pas de la même manière. Il s’authentifie à l’aide d’identifiants AWS, et non d’une clé Anthropic associée à un hôte. La compatibilité était un problème de point de terminaison pour la passerelle Azure. Il s’agit d’un problème de modèle d’authentification pour Bedrock. Je n’ai pas ajouté de couche Bedrock.
L’identité du fournisseur, le point de terminaison de transport et le stockage des identifiants sont des préoccupations distinctes.
Parfois, la compatibilité est une question de configuration, et non une autre abstraction. C’est la même habitude que de garder Navi hors de la persistance et Symfony AI hors du formulaire.
Sodium en local, KMS en externe
Je ne vais pas transformer cet article en tutoriel de cryptographie. La différence opérationnelle suffit.
Avec Sodium, l’application détient la clé de chiffrement et peut déchiffrer chaque ligne qu’elle a écrite. L’expérience est exécutable sans compte AWS. La compromission de DEV_KMS_KEY équivaut à la compromission des clés API stockées.
Avec AWS KMS, l’application interroge un service externe. La clé principale est censée rester en dehors du stockage de l’application. La compromission du fichier .env ne suffit pas ; un attaquant a toujours besoin des identifiants AWS et d’une politique de clé autorisant le déchiffrement.
Il s’agit d’une amélioration en matière de gestion des clés. Ce n’est pas une protection miracle contre un environnement d’exécution compromis. Si l’application en cours d’exécution peut appeler decrypt(), un attaquant suffisamment présent peut également l’appeler. L’avantage réside dans le fait que la clé principale n’est pas une chaîne d’octets placée à côté du texte chiffré, et que le chiffrement et le déchiffrement peuvent être audités en dehors de l’application. J’ai vérifié le premier cas de figure. J’ai configuré le second.
L’IA modifie les deux facettes de la sécurité
Fabien Potencier a écrit ce mois-ci que Symfony recevait auparavant environ un rapport de sécurité par trimestre et qu’il en reçoit désormais au moins un par jour. Les agents analysent une nouvelle version dès sa sortie. Un rapport arrivant dans la boîte de réception doit être considéré comme déjà public. Les embargos partaient du principe que la découverte d’une faille coûtait cher.
Il ne s’agit pas d’un CVE dans cette application. Je n’associe pas cette expérience à un avis numéroté.
Le lien pertinent est d’ordre économique. Les agents réduisent le coût de la recherche, dans une base de code, de l’endroit où un secret fuit. Parallèlement, un identifiant généré par l’IA a une valeur financière directe : il peut être dépensé. Ces deux faits combinés expliquent pourquoi la gestion BYOK (Bring Your Own Key) relève de l’architecture de l’application plutôt que de la gestion du déploiement.
Les choix concrets dans le dépôt sont volontairement simples : pas de colonne en texte clair, pas de saisie de mot de passe, pas de réaffichage, décryptage uniquement pour l’exécution, clé absente de Navi Context, faux fichier .env.example, badge backend sans DSN. KeyManagement se charge du chiffrement. Le reste relève des bonnes pratiques de sécurité, car l’objet que nous protégeons est désormais une ligne de base de données.
Ce qui a réellement fonctionné
Ces vérifications ont été effectuées.
Container, Twig et YAML ont passé les vérifications de syntaxe sans erreur.
PHPUnit : 2 tests, 15 assertions, OK. Un test effectue un chiffrement et un déchiffrement via Sodium et vérifie que le texte en clair est absent du blob de l’entité. L’autre envoie le formulaire basé sur les attributs et vérifie que la clé est absente de SQLite et du code HTML.
Dans le navigateur, j’ai ajouté une connexion, j’ai vu les badges « crypté » et « Sodium », j’ai ouvert l’édition avec un champ de clé API vide, puis j’ai testé un faux identifiant. Le fournisseur a répondu : « Clé API non valide. »
SQLite après cette soumission :
instr(ciphertext_blob, 'sk-ant') = 0
La sonde Anthropic en direct :
chiffrement/déchiffrement : correspondance identifiants dans le contexte Navi : non fournisseur atteint : oui résultat : erreur de facturation — solde créditeur trop faible
Telles sont les conclusions que je suis prêt à défendre.
Ce que je n’ai pas vérifié
AWS KMS a été configuré mais n’a pas été appelé.
La baseUrl d’Anthropic sur Azure a été configurée mais n’a pas été appelée.
Aucune opération payante sur Anthropic n’a abouti. Par conséquent, aucun jeton n’a été réellement reçu, et la comptabilisation des coûts n’a pas été validée par rapport à une réponse positive du fournisseur.
KeyManagement reste expérimental.
« Implémenté », « configuré », « testé » et « vérifié » sont quatre termes distincts. Cette semaine, j’ai utilisé les quatre. Je ne les confonds pas.
Conclusion
Le fichier .env est un bon modèle lorsque le secret appartient au déploiement.
Le BYOK change de propriétaire.
Dès lors qu’un identifiant appartient à un utilisateur de l’application, il nécessite un cycle de vie propre à l’application : saisie, chiffrement, persistance, exécution, observabilité et, à terme, rotation. La gestion des clés de Symfony 8.2 est une primitive utile pour l’étape de chiffrement. Il est expérimental, et son arbre de configuration est pointilleux sur les variables d’environnement, mais les interfaces que j’ai testées — chiffrer un petit secret, stocker un Ciphertext, déchiffrer au dernier moment — sont celles dont BYOK a réellement besoin.
Navi n’a pas besoin de devenir un gestionnaire de secrets. Symfony AI n’a pas besoin de gérer la persistance. Le formulaire n’a pas besoin de savoir comment la requête IA est construite.
Chaque couche peut rester légère. La clé n’est plus une ligne dans .env. C’est une ligne de table, un texte chiffré, un argument de constructeur et un élément de ligne. C’est là tout le changement.
💫 Auteur sur Reddit - r/opensource : Un projet CAPTCHA open source serait-il une bonne idée ?
🤖 Veille Darkwood - 2026-09-20
🤖 Veille Darkwood - 2026-09-19
💫 Bluesky Creator - @martinfowler.com : Réflexions sur le piratage de RubyGems
💫 Le créateur de Hacker News - Bend 2 et le piège du « Vibe-Coding »
💫 GitHub Creator - twigphp: twigphp/twig: v3.29.0
🤖 Veille Darkwood - 2026-09-18
💫 Bluesky Creator - @martinfowler.com : NOUVEL ARTICLE
💫 Créateur de Hacker News - Tableau de bord en direct de Xiaomi Mimo 2.6 après la formation
💫 Créateur Reddit - r/opensource : un réseau d'échange ou de prêt ?
🤖 Veille Darkwood - 2026-09-17
💫 Créateur Reddit - r/opensource : Archinstoo - Programme d'installation d'Arch Linux
💫 GitHub Creator - cilium: cilium/cilium: 1.18.14
🤖 Veille Darkwood - 2026-09-16
💫 Auteur sur arXiv - cs.LG : Optimisation des politiques de Bellman
🤖 Veille Darkwood - 2026-09-15
💫 Créateur de Hacker News - Les dessins en 3D d'Apple
💫 GitHub Creator - argoproj: argoproj/argo-cd v3.5.3
🤖 Veille Darkwood - 2026-09-14
💫 Fondateur de Hacker News - Nous devons repousser les limites