Aller au contenu principal

Secrets ASP.NET Core sous systemd : EnvironmentFile, credentials et AddKeyPerFile

Modèle de menace, limites de /proc/[pid]/environ, directives LoadCredential/SetCredential (et variantes chiffrées), puis intégration ASP.NET Core via AddKeyPerFile pour gérer les secrets sous systemd.

Une application ASP.NET Core sous systemd manipule deux familles de configuration : valeurs non sensibles (niveau de log, flags, URLs) et secrets (chaînes de connexion, clés, tokens).

appsettings.json reste le bon point de départ pour le non-sensible. Pour les secrets, l'enjeu est d'éviter leur présence dans le dépôt, les artefacts et /proc/<pid>/environ. La grille suivante sert de cadre de décision. Elle est basée sur des classifications classiques, sans prétention normative.

Niveau Exemples Peut figurer dans le dépôt Impact d'une exposition
Public Niveau de log, feature flags, URLs publiques Aucun
Interne Configuration spécifique au déploiement, timeouts Selon politique du projet Aucun impact direct ; facilite la reconnaissance d'un attaquant
Confidentiel Chaîne de connexion base de données, clé JWT, token, clé API tierce Accès non autorisé aux données ou aux systèmes
Critique Clé de chiffrement de données au repos, Secret d'accès à un gestionnaire de secrets Compromission de l'ensemble du périmètre protégé

En pratique, seul le niveau Public peut figurer sans précaution dans le dépôt source. Le niveau Interne relève de la politique de l'équipe : certains projets versionnent les fichiers de configuration d'environnement, d'autres non. À partir de Confidentiel, la valeur ne doit jamais apparaître dans un dépôt ni un artefact de build non chiffré.

Cet article couvre le modèle de menace, les directives systemd (EnvironmentFile, Load/SetCredential et variantes chiffrées) et l'intégration ASP.NET Core via AddKeyPerFile().

Le modèle de menace

Le modèle de systemd part d'une hypothèse réaliste : une application peut être compromise. Le sandboxing vise donc la limitation d'impact, pas la prévention absolue de compromission. La question utile est : si le processus est compromis, quel périmètre reste accessible ?

Pour les secrets, deux surfaces sont à considérer :

  • Surface latérale : accès aux secrets des autres services du même hôte. Avec un namespace de montage actif, chaque service ne voit que son propre répertoire de credentials ; sans namespace, un service compromis exécuté sous le même UID peut lire /run/credentials/ sur l’hôte.
  • Surface locale : compromission du service lui-même. Un attaquant qui prend le contrôle du processus peut lire ses propres secrets, puisque leur déchiffrement est déjà effectué au démarrage. Aucun mécanisme d’injection ne protège ce scénario.

Les directives restent néanmoins très utiles contre les fuites accidentelles : logs debug, pages d'erreur ou agents APM qui inspectent l'environnement ne voient que $CREDENTIALS_DIRECTORY, pas les valeurs.

Les propriétés d'isolation latérale décrites dans cet article supposent qu'un namespace de montage est actif pour le service. Sans namespace, la protection latérale n'est plus assurée par systemd mais par le noyau et les permissions Unix : à UID égal, la lecture des secrets devient possible, et l'accès à l'environnement /proc/<pid>/environ comme à la mémoire /proc/<pid>/mem dépend ensuite des mécanismes de durcissement actifs. La directive minimale est PrivateMounts=yes, implicitement activée par ProtectSystem=, PrivateTmp= et d'autres directives de durcissement couvertes dans l'article précédent.

Les directives disponibles

Systemd expose cinq directives pour injecter des secrets dans un service. Les variantes chiffrées (LoadCredentialEncrypted=, SetCredentialEncrypted=) et systemd-creds nécessitent systemd >= 250. La référence complète est dans systemd.exec(5) et sur systemd.io/CREDENTIALS/. Ci-dessous, uniquement les implications utiles en déploiement ASP.NET Core.

EnvironmentFile=

Lit un fichier de paires clé=valeur en clair sur l'hôte et les expose comme variables d'environnement au démarrage du service.

[Service]
EnvironmentFile=/etc/myapp/secrets/app.env

Le fichier source devrait être en root:root avec permissions 600. Sinon, le secret reste lisible depuis le système de fichiers hôte.

Cette directive est très répandue en production, mais elle présente des risques de fuite : les valeurs atterrissent dans /proc/<pid>/environ, lisible par root et par tout processus tournant sous le même UID. Les logs debug, pages d'erreur ou agents APM qui inspectent l'environnement exposent directement les secrets. C'est précisément ce vecteur que les directives credentials adressent.

LoadCredential=

Lit un fichier source en clair sur l'hôte et le copie dans le répertoire de credentials du service au démarrage.

[Service]
LoadCredential=ConnectionStrings__DefaultConnection:/etc/myapp/secrets/ConnectionStrings.txt

Le double underscore est ici une convention ASP.NET Core (pas systemd) attendue par AddKeyPerFile() pour reconstruire la hiérarchie. ConnectionStrings__DefaultConnection devient ConnectionStrings:DefaultConnection. Le séparateur __ correspond à la valeur par défaut de SectionDelimiter et peut être adapté.

Le fichier source devrait être en root:root avec permissions 600. Sinon, le secret reste lisible depuis le système de fichiers hôte.

LoadCredentialEncrypted=

Même syntaxe, mais source chiffrée. Par défaut, le chiffrement combine une clé dérivée du TPM2 et une clé hôte stockée dans /var/lib/systemd/credential.secret : les deux sont nécessaires au déchiffrement. En l'absence de TPM2, seule la clé hôte est utilisée. Le déchiffrement se fait au démarrage et le clair est exposé via /run/credentials/myapp.service/ dans un FS mémoire (ramfs pour les services système, tmpfs pour les services utilisateur), sans écriture en clair sur disque persistant.

[Service]
LoadCredentialEncrypted=Jwt__Secret:/etc/myapp/secrets/Jwt__Secret.cred

Le fichier .cred est produit par systemd-creds encrypt, décrit dans la section suivante. Le paramètre --name embarque l'identifiant du credential dans le chiffré : un fichier produit pour Jwt__Secret ne peut pas être réutilisé sous un autre nom. La liaison à la machine hôte, elle, est assurée par le choix de clé (--with-key=auto par défaut).

SetCredential=

Écrit la valeur en clair dans le unit. Comme le unit peut être exposé (disque et D-Bus), cette directive n'est pas adaptée aux secrets. Usage défendable : valeur non sensible générée dynamiquement dans un drop-in. Pour du non sensible simple, Environment= est plus lisible ; pour du sensible, utiliser SetCredentialEncrypted=.

SetCredentialEncrypted=

Même chiffrement que LoadCredentialEncrypted=, mais valeur chiffrée inline dans le unit. Avantage : un seul artefact. Limite : la rotation des secrets devient couplée au cycle de déploiement du unit.

La valeur chiffrée est produite par systemd-creds encrypt avec l'option --pretty, décrite dans la section suivante :

[Service]
SetCredentialEncrypted=ExternalApi__Key: \
    k6iUCUh0RJCQyvL8k8q1UyAAAAABAAAADAAAABAAAAC3...

ImportCredential=

Cette directive est apparue en systemd version 254. Elle permet d'importer des credentials fournis au boot depuis des sources externes (TPM, EFI, boot loader, SMBIOS, qemu_fw_cfg). C'est surtout utile pour le premier secret d'une image cloud/VM ; sur bare-metal sans intégration firmware, l'usage est plus limité. Référence: systemd.io/CREDENTIALS/.

Chiffrement avec systemd-creds

systemd-creds encrypt génère les .cred (LoadCredentialEncrypted) et les valeurs inline (SetCredentialEncrypted). Pour ne pas laisser le secret dans l'historique shell, préférer systemd-ask-password -n. Le -n évite le 0x0a final qui peut casser silencieusement le parsing runtime. Détail documenté dans systemd-ask-password(1).

Clés de chiffrement

Par défaut (--with-key=auto), systemd combine deux clés : une dérivée du TPM2 et une clé hôte stockée dans /var/lib/systemd/credential.secret. Les deux sont nécessaires au déchiffrement. En l'absence de TPM2 (par exemple, sur un VPS) seule la clé hôte est utilisée. systemd le signale explicitement :

Credential secret file '/var/lib/systemd/credential.secret' is not located on encrypted media, using anyway.

Cette alerte indique que la clé de déchiffrement réside sur le même disque non chiffré que le fichier .cred. Un attaquant disposant d'un accès root ou d'un accès complet au système de fichiers peut déchiffrer les credentials.

Sur un serveur sans TPM2 et sans chiffrement de volume, LoadCredential= avec permissions 600 et LoadCredentialEncrypted= offrent donc des garanties pratiquement équivalentes pour la majorité des cas. Le choix entre les deux dépend du modèle de menace retenu, pas d'une supériorité absolue.

# Chiffrer un secret pour LoadCredentialEncrypted=
systemd-ask-password -n "Jwt__Secret :" | \
    systemd-creds encrypt \
        --name=Jwt__Secret \
        - /etc/myapp/secrets/Jwt__Secret.cred

# Pour SetCredentialEncrypted=, ajouter --pretty et coller la sortie
# dans le unit.

Vérifier avant déploiement avec systemd-creds decrypt. Une erreur de --name échoue immédiatement ; une valeur incorrecte échouera plus tard à l'usage applicatif.

systemd-creds decrypt \
    --name=Jwt__Secret \
    /etc/myapp/secrets/Jwt__Secret.cred -

Rotation d'un secret

Les secrets sont chargés au démarrage du service et figés pour toute la durée de vie de l'instance. Contrairement à appsettings.json qui supporte reloadOnChange, AddKeyPerFile() avec reloadOnChange: true n'a aucun effet ici : les fichiers dans le répertoire credentials sont immuables après le démarrage. Toute rotation nécessite de rechiffrer le secret (voir Chiffrement avec systemd-creds), puis un systemctl restart.

Si la disponibilité continue est une contrainte, elle se gère au niveau supérieur : load balancer avec instances multiples et redémarrage progressif.

Choisir son approche

Maintenant que les mécanismes sont posés, voici une synthèse de ce que chacun couvre. Environment= et EnvironmentFile= sont les approches les plus répandues en production : Environment= inscrit les valeurs directement dans le fichier unit, EnvironmentFile= les charge depuis un fichier hors-artefact. Dans les deux cas, les valeurs atterrissent dans /proc/<pid>/environ. Les credentials systemd adressent précisément ce vecteur.

Hors artefact de déploiement Hors /proc/environ Isolation inter-services Disque chiffré au repos Rotation sans restart Multi-nœuds
appsettings.json △ (selon consommation .NET)
Environment=
EnvironmentFile=
SetCredential= ✓ (avec namespace)
SetCredentialEncrypted= △ (chiffré, machine-lié) ✓ (avec namespace) △ (machine-lié si TPM2 disponible) △ (provisioning par hôte)
LoadCredential= ✓ (avec namespace)
LoadCredentialEncrypted= △ (chiffré, machine-lié) ✓ (avec namespace) △ (machine-lié si TPM2 disponible) △ (provisioning par hôte)
Gestionnaire de secrets (cloud) △ (selon fournisseur + code)

Pour les approches sans gestion centralisée (appsettings.json, variables d'environnement, credentials systemd), ✓ en colonne Multi-nœuds signifie que le mécanisme est techniquement réplicable sur plusieurs hôtes, pas qu'il offre rotation ou révocation centralisées. En colonne Rotation sans restart, △ signifie que le rechargement dépend de l'implémentation applicative (par exemple IOptionsSnapshot/IOptionsMonitor, cache, polling) et du fournisseur utilisé.

Les directives Set* et Load* partagent les propriétés d'isolation des credentials systemd. La différence est dans l'artefact : SetCredential= inscrit la valeur en clair dans le fichier unit ; SetCredentialEncrypted= y inscrit un chiffré lié à la machine cible, donc inexploitable sur un autre hôte par défaut. Les directives Load* conservent le secret hors du fichier unit, dans un fichier provisionné séparément sur le serveur.

En pratique : LoadCredential= est adapté aux actifs de niveau Confidentiel ; LoadCredentialEncrypted= apporte une protection au repos supplémentaire, pleinement efficace lorsqu'un TPM2 est disponible. Au-delà, ou dès que la rotation automatique et l'audit centralisé sont des exigences, un gestionnaire de secrets s'impose.

Les gestionnaires de secrets (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) s'intègrent nativement dans ASP.NET Core via AddAzureKeyVault() ou un fournisseur de configuration personnalisé. Sur une infrastructure cloud (Azure VM avec Managed Identity, EC2 avec rôle IAM), l'authentification passe par l'identité de la machine : aucun secret statique à stocker. Hors cloud, l'authentification requiert un certificat ou une clé d'accès. Ce secret lui-même doit être stocké de façon sécurisée sur l'hôte, et LoadCredentialEncrypted= est une réponse concrète à ce cas précis.

Intégration dans ASP.NET Core

Le fournisseur de configuration AddKeyPerFile() lit un répertoire et expose chaque fichier comme une clé de configuration : nom du fichier comme clé, contenu comme valeur. C'est exactement le format produit par $CREDENTIALS_DIRECTORY.

L'utilisation d'un fournisseur de configuration standard permet de garder le code métier inchangé, avec accès transparent aux credentials systemd via les mécanismes habituels de configuration .NET, par exemple Configuration["ConnectionStrings:DefaultConnection"].

Position dans la chaîne de configuration

La règle est simple : le dernier fournisseur enregistré l'emporte sur les clés en conflit. La position de AddKeyPerFile() dans la chaîne est un choix, pas une convention. Plus de détails sur la hiérarchie de configuration dans la documentation officielle.

Dans cet exemple précis, AddKeyPerFile() est ajouté après les fournisseurs par défaut de CreateBuilder() ; il prend donc priorité sur eux. Si un fournisseur est ajouté ensuite, il reprend la priorité.

var builder = WebApplication.CreateBuilder(args);

var credentialsDir =
    Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
// $CREDENTIALS_DIRECTORY est défini automatiquement par systemd pour chaque service.
// Hors de systemd, il est nul et le fournisseur n'est pas ajouté, 
// ce qui permet une exécution locale sans configuration spécifique.
if (!string.IsNullOrEmpty(credentialsDir))
{
    builder.Configuration.AddKeyPerFile(
        directoryPath: credentialsDir,
        // si $CREDENTIALS_DIRECTORY est défini mais invalide, on veut une erreur explicite
        optional: false,
        // les fichiers de credentials sont immuables après le démarrage
        reloadOnChange: false);
}

// Cet appel ajoute un second fournisseur de configuration en fin de chaîne 
// pour permettre une surcharge opérationnelle. Par exemple pour surcharger 
// une valeur de configuration en cas d'urgence, sans toucher au mécanisme de
// credential systemd.
// builder.Configuration.AddEnvironmentVariables();

Le comportement en cas de conflit entre sources est silencieux : aucune erreur, aucun avertissement. Par exemple, si une même clé existe à la fois dans un fichier de credential et dans les variables d'environnement, la valeur exposée à l'application dépend de l'ordre d'enregistrement des fournisseurs de configuration. Il convient de documenter clairement la hiérarchie des sources dans le projet et de surveiller les changements qui pourraient introduire des conflits silencieux.

Exemple

Exemple minimal : Environment= pour le non sensible, LoadCredentialEncrypted= pour les secrets.

Le chiffrement étant machine-lié, exécuter systemd-creds encrypt sur le serveur cible.

Préparation des fichiers chiffrés :

systemd-ask-password -n "ConnectionStrings__DefaultConnection :" | \
    systemd-creds encrypt \
        --name=ConnectionStrings__DefaultConnection \
        - /etc/myapp/secrets/ConnectionStrings__DefaultConnection.cred

systemd-ask-password -n "Jwt__Secret :" | \
    systemd-creds encrypt \
        --name=Jwt__Secret \
        - /etc/myapp/secrets/Jwt__Secret.cred

# Vérification avant déploiement (même pattern pour chaque secret)
systemd-creds decrypt \
    --name=ConnectionStrings__DefaultConnection \
    /etc/myapp/secrets/ConnectionStrings__DefaultConnection.cred -

systemd-creds decrypt \
    --name=Jwt__Secret \
    /etc/myapp/secrets/Jwt__Secret.cred -

chown -R root:root /etc/myapp/secrets/
chmod 700 /etc/myapp/secrets/
chmod 600 /etc/myapp/secrets/*.cred

Extraits du fichier unit /etc/systemd/system/myapp.service :

[Service]
User=myapp-user

# Valeurs non-sensibles
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=Logging__LogLevel__Default=Warning

# Secrets chiffrés (déchiffrés en mémoire au démarrage)
LoadCredentialEncrypted=ConnectionStrings__DefaultConnection:\
    /etc/myapp/secrets/ConnectionStrings__DefaultConnection.cred
LoadCredentialEncrypted=Jwt__Secret:\
    /etc/myapp/secrets/Jwt__Secret.cred

ExecStart=/opt/myapp/myapp

Côté application, la lecture est transparente et le code métier reste inchangé :

// Ici, seul l'usage métier est illustré.
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(
        builder.Configuration.GetConnectionString(
            "DefaultConnection")));

Le bootstrap de configuration est identique à la section "Position dans la chaîne de configuration" ; seul l'usage concret de GetConnectionString() est montré ici.

Ce que cette approche ne protège pas

  • Compromission active du processus. Comme rappelé dans le modèle de menace, un attaquant qui contrôle le processus peut accéder directement aux secrets.
  • Fuites mémoire. Une fois chargées, les valeurs sont gérées par le runtime .NET et peuvent être extraites via un dump mémoire ou un débogueur si l'accès au processus est possible. Cette limite s'applique à tous les mécanismes d'injection, pas seulement aux secrets systemd.
  • Rotation sans interruption. Les secrets sont figés au démarrage. Toute rotation nécessite un systemctl restart, contrairement à appsettings.json qui supporte reloadOnChange.
  • Portabilité machine. Le chiffrement de LoadCredentialEncrypted= est lié à la machine hôte (voir Clés de chiffrement). Dans la configuration standard, un même fichier .cred ne peut pas être déchiffré sur un autre hôte. La gestion sur un parc multi-nœuds reste techniquement possible mais nécessite une étape de provisioning locale sur chaque hôte cible. Dans ce cas, un gestionnaire de secrets avec authentification par identité machine est plus adapté.
  • Pas de révocation centralisée. Invalider un secret compromis nécessite une intervention directe sur chaque hôte, sans mécanisme d'audit ni de traçabilité centralisés.

Ressources

Vous souhaitez évaluer la sécurité de votre infrastructure applicative ?

Je propose des missions de conseil en architecture sécurisée et des tests d'intrusion adaptés à vos contraintes techniques.

Discuter de votre besoin

Articles connexes

Durcir un service systemd pour .NET 8+ : les arbitrages spécifiques au runtime

JIT et MemoryDenyWriteExecute, Type=notify face à ProtectProc=invisible, chemins d'écriture sous ProtectSystem=strict : les arbitrages ASP.NET Core pour un service systemd durci.