Chapitre 1219 minIntermédiaire
Sécuriser une application C# : secrets, mots de passe et données
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- repérer ce qu'un utilisateur peut lire ou modifier sur son poste ;
- sortir la chaîne de connexion du code et du dépôt Git ;
- créer un compte MySQL qui n'a que les droits utiles ;
- hacher et vérifier un mot de passe avec BCrypt ou PBKDF2 ;
- protéger les données de santé d'une application métier.
4 exercices corrigés
Sécuriser un client lourd, c'est partir d'un constat : l'application tourne sur un poste que l'utilisateur contrôle. Il peut lire les fichiers de configuration, décompiler l'exécutable, observer le trafic réseau. Ce chapitre applique ce constat à GSB Ordonnances : où ranger la chaîne de connexion, quels droits donner au compte MySQL, comment stocker les mots de passe des utilisateurs, et comment protéger des données de santé. Les principes généraux de la sécurité applicative sont dans le cours Sécurité applicative.
Ce que l'utilisateur d'un poste peut voir
Une application .NET est compilée en code intermédiaire, qui contient encore les noms des classes, des méthodes et les chaînes de caractères. Des outils libres de décompilation reconstruisent un code C# très proche de l'original en quelques secondes. Conséquences :
- une chaîne de connexion écrite dans le code est lisible par quiconque copie l'exécutable ;
- un fichier
appsettings.jsonposé à côté de l'exécutable est lisible avec le Bloc-notes ; - une vérification faite seulement dans l'interface (un bouton caché pour les non-administrateurs) se contourne.
Sortir la chaîne de connexion du code
La chaîne de connexion change selon l'environnement (ton poste, le serveur de test, la production) et contient un mot de passe. Elle n'a rien à faire dans le code source, ni dans le dépôt Git : un mot de passe poussé sur GitHub reste dans l'historique même après correction.
On lit la configuration avec le paquet Microsoft.Extensions.Configuration, qui superpose plusieurs sources, la dernière l'emportant :
dotnet add Gsb.Ui package Microsoft.Extensions.Configuration.Json
dotnet add Gsb.Ui package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add Gsb.Ui package Microsoft.Extensions.Configuration.UserSecrets
using Microsoft.Extensions.Configuration;
IConfiguration config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true) // 1. le fichier livré avec l'application
.AddUserSecrets(typeof(Program).Assembly, optional: true) // 2. les secrets du développeur
.AddEnvironmentVariables(prefix: "GSB_") // 3. les variables d'environnement
.Build();
string? chaine = config.GetConnectionString("Gsb");
| Source | Où se trouve-t-elle ? | Usage |
|---|---|---|
appsettings.json | À côté de l'exécutable | Les réglages non secrets : adresse du serveur, nom de la base, SslMode. Un modèle appsettings.exemple.json est versionné, le vrai fichier ne l'est pas |
| Secrets utilisateur | Dans le profil Windows du développeur (%APPDATA%\Microsoft\UserSecrets\…), hors du dossier du projet | Ton mot de passe de développement : il ne peut pas partir dans Git par erreur |
| Variables d'environnement | Le système ou la session | Les postes de test, l'intégration continue (secrets dans un pipeline) |
Les secrets utilisateur s'initialisent et se renseignent en ligne de commande, dans le dossier du projet :
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Gsb" "Server=localhost;Database=gsb_ordonnances;User ID=gsb_dev;Password=…"
Avec la variable d'environnement, le séparateur : devient un double tiret bas : GSB_ConnectionStrings__Gsb. Le fichier appsettings.json ressemble à ceci, et doit être copié dans le dossier de sortie (propriété « Copier dans le répertoire de sortie » à « Copier si plus récent ») :
{
"ConnectionStrings": {
"Gsb": "Server=srv-bdd.cabinet.local;Database=gsb_ordonnances;User ID=gsb_app;SslMode=Required"
}
}
Les secrets utilisateur ne servent qu'en développement. Sur les postes du cabinet, le mot de passe peut être chiffré pour l'utilisateur Windows avec l'API DPAPI de Windows : seul ce compte Windows, sur ce poste, peut le déchiffrer.
using System.Security.Cryptography; // paquet System.Security.Cryptography.ProtectedData
using System.Text;
byte[] chiffre = ProtectedData.Protect(Encoding.UTF8.GetBytes(chaine), null, DataProtectionScope.CurrentUser);
File.WriteAllBytes(fichier, chiffre);
// au démarrage suivant
string relue = Encoding.UTF8.GetString(
ProtectedData.Unprotect(File.ReadAllBytes(fichier), null, DataProtectionScope.CurrentUser));
DPAPI protège contre la copie du fichier sur une autre machine ou sa lecture par un autre compte, pas contre l'utilisateur lui-même : il reste une protection de second rang.
Un compte MySQL qui n'a que les droits utiles
L'application ne se connecte jamais avec root. On lui crée un compte qui n'a que les droits dont elle a besoin, sur les seules tables qu'elle utilise, et depuis le seul réseau du cabinet : c'est le moindre privilège (GRANT et REVOKE).
CREATE USER 'gsb_app'@'192.168.10.%' IDENTIFIED BY 'un-mot-de-passe-long-et-unique';
GRANT SELECT, INSERT, UPDATE, DELETE ON gsb_ordonnances.patient TO 'gsb_app'@'192.168.10.%';
GRANT SELECT, INSERT ON gsb_ordonnances.ordonnance TO 'gsb_app'@'192.168.10.%';
GRANT SELECT, INSERT ON gsb_ordonnances.contenir TO 'gsb_app'@'192.168.10.%';
GRANT SELECT ON gsb_ordonnances.medecin TO 'gsb_app'@'192.168.10.%';
GRANT SELECT ON gsb_ordonnances.medicament TO 'gsb_app'@'192.168.10.%';
GRANT SELECT ON gsb_ordonnances.utilisateur TO 'gsb_app'@'192.168.10.%';
Ce compte ne peut ni supprimer une table, ni créer un compte, ni modifier une ordonnance déjà émise, ni lire la base d'une autre application. Si son mot de passe fuit, les dégâts restent limités, et le pare-feu du serveur n'accepte de toute façon les connexions au port 3306 que depuis le réseau du cabinet. Ajoute SslMode=Required dans la chaîne de connexion pour que les données de santé ne circulent jamais en clair.
Stocker les mots de passe des utilisateurs : le hachage
L'application a ses propres utilisateurs (la secrétaire, les médecins), rangés dans la table utilisateur. Leur mot de passe ne doit jamais y être stocké en clair, ni même chiffré : on stocke une empreinte (hash) calculée par une fonction de hachage lente, avec un sel aléatoire. À la connexion, on recalcule l'empreinte du mot de passe saisi et on la compare. Si la table fuit, l'attaquant n'obtient pas les mots de passe, et la lenteur volontaire de la fonction rend très coûteux de les retrouver par essais successifs (stocker un mot de passe).
| Algorithme | À utiliser ? | Pourquoi |
|---|---|---|
| Stockage en clair, Base64 | Jamais | Lisible directement en cas de fuite |
| MD5, SHA-1, SHA-256 seuls | Jamais pour un mot de passe | Trop rapides : des milliards d'essais par seconde sur une carte graphique |
| bcrypt | Oui | Lent, salé, paramétrable (facteur de coût) |
| PBKDF2 avec SHA-256 | Oui | Inclus dans .NET, sans paquet ; l'OWASP recommande au moins 600 000 itérations |
| Argon2id | Oui, le plus récent | Résiste aussi aux attaques par matériel spécialisé ; nécessite une bibliothèque tierce en .NET |
Avec BCrypt.Net-Next. Le paquet BCrypt.Net-Next génère le sel, le range dans l'empreinte et vérifie en une ligne :
string empreinte = BCrypt.Net.BCrypt.HashPassword("Ordonnance-2026!", workFactor: 12);
// $2a$12$KaivI.GwRD8Nh62xfaOgIuRJf5gEFyf74DbwX3HvtRMiJXNE1UTvS (60 caractères)
bool correct = BCrypt.Net.BCrypt.Verify("Ordonnance-2026!", empreinte); // true
bool faux = BCrypt.Net.BCrypt.Verify("ordonnance-2026!", empreinte); // false
L'empreinte contient tout ce qu'il faut pour vérifier : $2a$ (la variante de bcrypt), 12$ (le facteur de coût : 2¹² tours) et, sur les 22 caractères suivants, le sel. Hacher deux fois le même mot de passe donne deux empreintes différentes, puisque le sel change. Avec un facteur de 12, un calcul prend de l'ordre d'un quart de seconde sur un PC récent : imperceptible pour l'utilisateur, très coûteux pour un attaquant qui essaie des millions de mots de passe. Note aussi que bcrypt ne prend en compte que les 72 premiers octets du mot de passe.
Avec PBKDF2, sans paquet. .NET fournit Rfc2898DeriveBytes.Pbkdf2. On génère soi-même le sel et on range dans la base l'algorithme, le nombre d'itérations, le sel et l'empreinte :
using System.Security.Cryptography;
public static class MotDePassePbkdf2
{
private const int Iterations = 600_000;
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
public static string Hacher(string motDePasse)
{
byte[] sel = RandomNumberGenerator.GetBytes(16);
byte[] cle = Rfc2898DeriveBytes.Pbkdf2(motDePasse, sel, Iterations, Algo, 32);
return $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(sel)}${Convert.ToBase64String(cle)}";
}
public static bool Verifier(string motDePasse, string stocke)
{
string[] parties = stocke.Split('$');
if (parties.Length != 4 || parties[0] != "pbkdf2-sha256") return false;
int iterations = int.Parse(parties[1]);
byte[] sel = Convert.FromBase64String(parties[2]);
byte[] attendu = Convert.FromBase64String(parties[3]);
byte[] calcule = Rfc2898DeriveBytes.Pbkdf2(motDePasse, sel, iterations, Algo, attendu.Length);
return CryptographicOperations.FixedTimeEquals(calcule, attendu);
}
}
CryptographicOperations.FixedTimeEquals compare en temps constant : une comparaison ordinaire s'arrête au premier octet différent, et la durée de la réponse renseignerait un attaquant sur le nombre d'octets corrects.
La fenêtre de connexion
Le service d'authentification de la couche métier vérifie l'identifiant et le mot de passe. Il calcule une vérification bcrypt même quand l'identifiant n'existe pas, pour que la réponse prenne le même temps dans les deux cas : sinon, un attaquant saurait quels identifiants existent en mesurant le temps de réponse.
public class AuthService(IUtilisateurRepository depot)
{
private static readonly string EmpreinteLeurre = BCrypt.Net.BCrypt.HashPassword("leurre", 12);
public static string Hacher(string motDePasse) =>
BCrypt.Net.BCrypt.HashPassword(motDePasse, workFactor: 12);
public async Task<Utilisateur?> ConnecterAsync(string login, string motDePasse)
{
Utilisateur? utilisateur = await depot.TrouverParLoginAsync(login);
string empreinte = utilisateur?.HashMotDePasse ?? EmpreinteLeurre;
bool correct = BCrypt.Net.BCrypt.Verify(motDePasse, empreinte);
return correct && utilisateur is not null ? utilisateur : null;
}
}
Le formulaire de connexion ajoute trois protections : un message unique (« Identifiant ou mot de passe incorrect », jamais « identifiant inconnu »), une attente croissante après chaque échec, et une limite d'essais.
private async void btnConnexion_Click(object? sender, EventArgs e)
{
btnConnexion.Enabled = false;
lblErreur.Text = "";
try
{
Utilisateur = await _auth.ConnecterAsync(txtLogin.Text.Trim(), txtMotDePasse.Text);
if (Utilisateur is not null)
{
DialogResult = DialogResult.OK;
return;
}
_echecs++;
txtMotDePasse.Clear();
if (_echecs >= EssaisMax)
{
MessageBox.Show("Trop d'essais. L'application va se fermer.", "Connexion",
MessageBoxButtons.OK, MessageBoxIcon.Stop);
DialogResult = DialogResult.Cancel;
return;
}
lblErreur.Text = "Identifiant ou mot de passe incorrect.";
await Task.Delay(TimeSpan.FromSeconds(_echecs)); // 1 s, puis 2 s, puis 3 s…
}
catch (Exception)
{
lblErreur.Text = "Serveur injoignable. Réessaie dans un instant.";
}
finally
{
btnConnexion.Enabled = true;
}
}
Un compteur dans le formulaire se contourne en relançant l'application. Pour un vrai verrouillage, le nombre d'échecs et la date du dernier échec sont enregistrés dans la table utilisateur, côté serveur. Les règles de mots de passe (longueur, renouvellement, double authentification) sont détaillées dans le cours Authentification.
Les rôles : cacher ne suffit pas
La secrétaire gère les patients ; seul un médecin crée une ordonnance. Dans l'interface, on masque les boutons inutiles (btnNouvelleOrdonnance.Visible = utilisateur.Role == "medecin";). Mais masquer est du confort : la vraie vérification se fait dans la couche métier (OrdonnanceService refuse une ordonnance créée par un utilisateur qui n'a pas le rôle médecin, voir le chapitre 8 ; le chapitre 13 la teste) et, idéalement, dans la base ou l'API. Le contrôle d'accès côté serveur est le sujet du chapitre contrôle d'accès défaillant.
Des données de santé
Les informations sur la santé d'un patient (ses ordonnances, ses traitements) sont des données de santé, une catégorie particulière du RGPD (article 9) dont le traitement est en principe interdit, sauf exceptions, comme la prise en charge médicale par des professionnels soumis au secret. Pour l'application, cela impose (RGPD : principes et obligations) :
- la minimisation : ne collecter et n'afficher que ce qui est utile (l'export CSV du chapitre 4 ne contient pas le numéro de sécurité sociale) ;
- la confidentialité : connexion chiffrée (
SslMode=Required), comptes nominatifs, droits par rôle, poste verrouillé en cas d'absence ; - la traçabilité : journaliser qui a consulté ou modifié quoi, sans écrire de données de santé dans les journaux ;
- un hébergement adapté : en France, un prestataire qui héberge des données de santé pour le compte d'un cabinet doit être certifié HDS (hébergeur de données de santé).
La propriété Form.FormScreenCaptureMode, apparue avec .NET 10, peut empêcher les logiciels de capture d'écran de photographier une fenêtre sensible (FormScreenCaptureMode = ScreenCaptureMode.HideContent;) : une protection d'appoint, pas une garantie.
Exercices
Exercice 1 sur 4 Trouve les failles Réussi À revoir
Relève toutes les failles de sécurité de ce code de connexion, et dis comment corriger chacune.
private void btnConnexion_Click(object? sender, EventArgs e)
{
var conn = new MySqlConnection("Server=192.168.10.5;Database=gsb_ordonnances;User ID=root;Password=Gsb2026");
conn.Open();
var cmd = new MySqlCommand("SELECT role FROM utilisateur WHERE login = '" + txtLogin.Text
+ "' AND motDePasse = '" + txtMdp.Text + "'", conn);
object? role = cmd.ExecuteScalar();
if (role == null) MessageBox.Show("Identifiant inconnu ou mot de passe faux");
else new FrmPatients(role.ToString()!).Show();
}
Voir la réponseMasquer la réponse de l'exercice 1
- Chaîne de connexion dans le code : lisible par décompilation et poussée dans Git. La lire depuis la configuration (fichier non versionné, secrets utilisateur, variable d'environnement, DPAPI).
- Compte
root: un attaquant qui récupère la chaîne contrôle tout le serveur. Créer un compte applicatif aux droits minimaux, limité au réseau du cabinet. - Mot de passe faible et partagé (
Gsb2026) : utiliser un mot de passe long et unique. - Injection SQL : la saisie est concaténée dans la requête. Utiliser une requête paramétrée.
- Mot de passe stocké en clair dans la colonne
motDePasseet comparé en SQL : stocker une empreinte bcrypt ou PBKDF2 et la vérifier en C# avecBCrypt.Verify. - Connexion non chiffrée (pas de
SslMode=Required) : les mots de passe et les données de santé circulent en clair sur le réseau. - Aucune limite d'essais ni attente : on peut essayer des milliers de mots de passe. Ajouter une attente croissante et un verrouillage côté serveur.
- Connexion jamais fermée (pas de
using) : sans gravité pour la sécurité, mais à corriger.
Le message unique « identifiant inconnu ou mot de passe faux » est, lui, correct : il ne dit pas lequel des deux est faux.
Tu avais trouvé ?
Exercice 2 sur 4 Les droits du compte applicatif Réussi À revoir
L'application doit maintenant permettre à la secrétaire de corriger la posologie d'une ligne d'ordonnance, mais jamais de supprimer une ordonnance. Quelle instruction ajoutes-tu au compte gsb_app ? Faut-il lui donner DELETE sur contenir ?
Voir la réponseMasquer la réponse de l'exercice 2
GRANT UPDATE (posologie) ON gsb_ordonnances.contenir TO 'gsb_app'@'192.168.10.%';
Le droit est accordé sur la seule colonne posologie : le compte ne peut modifier ni le médicament, ni la durée, ni le numéro d'ordonnance. Non, il ne faut pas donner DELETE sur contenir : ce n'est pas demandé, et ce droit permettrait de vider une ordonnance de ses lignes. La règle « la secrétaire et pas le médecin » se vérifie, elle, dans la couche métier, puisque les deux utilisent le même compte MySQL.
Tu avais trouvé ?
Exercice 3 sur 4 Vérifier un mot de passe Réussi À revoir
La table utilisateur contient, pour secretariat, l'empreinte $2a$12$KaivI.GwRD8Nh62xfaOgIuRJf5gEFyf74DbwX3HvtRMiJXNE1UTvS.
- Quel est le facteur de coût ?
- Un collègue propose de calculer
BCrypt.HashPassword(saisie)et de comparer le résultat à la colonne avec==. Pourquoi cela ne marche-t-il jamais ? - Que faut-il écrire à la place ?
Voir la réponseMasquer la réponse de l'exercice 3
- 12 (le nombre après
$2a$) : 2¹² = 4 096 tours. HashPasswordtire un nouveau sel à chaque appel : l'empreinte obtenue est différente de celle stockée, même pour le bon mot de passe. La comparaison avec==est toujours fausse.BCrypt.Net.BCrypt.Verify(saisie, empreinteStockee), qui relit le sel et le coût dans l'empreinte stockée, recalcule et compare.
Tu avais trouvé ?
Exercice 4 sur 4 Où ranger le secret ? Réussi À revoir
Pour chaque situation, choisis où se trouve la chaîne de connexion.
- Ton poste de développement personnel.
- Le serveur d'intégration continue qui lance les tests d'intégration.
- Les postes Windows du cabinet, en production.
- Le dépôt GitHub du projet.
Voir la réponseMasquer la réponse de l'exercice 4
- Dans les secrets utilisateur (
dotnet user-secrets set …), hors du dossier du projet. - Dans une variable secrète du pipeline, exposée comme variable d'environnement
GSB_ConnectionStrings__Gsb. - Dans
appsettings.jsonpour les réglages non secrets, et le mot de passe chiffré par DPAPI pour le compte Windows de l'utilisateur ; mieux encore, pas de mot de passe de base du tout si l'application passe par une API. - Nulle part : seulement un
appsettings.exemple.jsonsans mot de passe, et le vrai fichier dans le.gitignore.
Tu avais trouvé ?