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.jsonqui supportereloadOnChange. -
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.credne 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
- systemd.io/CREDENTIALS/ : spécification du protocole credentials, description exhaustive des directives et du cycle de vie.
-
systemd.exec(5)
: référence des directives
LoadCredential=,LoadCredentialEncrypted=,SetCredential=,SetCredentialEncrypted=etImportCredential=. -
systemd-ask-password(1)
: documentation du flag
-net du comportement de sortie. - AddKeyPerFile, Microsoft Learn : documentation du fournisseur de configuration ASP.NET Core.
- Fournisseur de configuration Azure Key Vault, Microsoft Learn : intégration Azure Key Vault dans ASP.NET Core, incluant l'authentification par identité managée.
- AWS Secrets Manager et configuration .NET, AWS Blog : fournisseur de configuration personnalisé pour AWS Secrets Manager, avec support du rechargement périodique.
- Configuration dans ASP.NET Core, Microsoft Learn : ordre de priorité des fournisseurs, hiérarchie de clés et séparateurs.
- Durcir un service systemd pour .NET 8+ : les arbitrages spécifiques au runtime : premier article de cette série, prérequis pour les propriétés d'isolation décrites ici.