L’objectif de cette activité est de comparer le mécanisme de l’authentification classique (mot de passe) à celui de la preuve à divulgation nulle de connaissance (cf ZKP).
Cette activité aborde aussi le concept d’améliorations progressives (dans le sens ou l’authentification reste possible même sans le JavaScript).
Remarques :
- Cette activité n’utilise aucun cadriciel afin de se focaliser sur l’authentification.
- Le code est presque complet : le principal travail consiste à l’analyser ; les réponses aux questions de réflexion doivent être rédigées.
Authentification classique (rappel)
Dans un système d’authentification classique, l’utilisateur (via son client) envoie un secret (mot de passe) au serveur. Le serveur compare ce secret avec celui stocké (généralement en base de donnéees).
En réalité, le serveur ne stocke pas le mot de passe mais son condensé (hash) calculé avec un sel :
- Le sel est une chaîne aléatoire ajoutée au mot de passe avant hachage pour empêcher les attaques par tables arc-en-ciel et garantir un condensé différent pour des mots de passe identiques ; le sel est stocké en clair avec le consensé.
- Une table arc-en-ciel (rainbow table) contient des condensés de mot de passe connus (issus d’un dictionnaire) précalculés permettant de retrouver rapidement un mot de passe à partir de son condensé.
- Un mot de passe ne peut pas être retrouvé à partir de son condensé : pour vérifier la correspondance, il faut calculer le condensé du mot de passe saisit par l’utilisateur (en utilisant le même sel que celui stocké).
Ce mécanisme d’authentification présente des inconvénients :
- Si un attaquant parvient à récupérer les condensés des mots de passe, il peut réaliser une attaque par force brute pour les casser.
- Une attaque par force brute, qui consiste a essayer toutes les combinaisons possibles de caractères, est d’autant plus coûteuse que les mots de passe sont longs et constitués de classes différentes de caractères (majuscules, minuscules, chiffres, caractères spéciaux) ; en pratique, ce type d’attaque s’appuie généralement sur un dictionnaire comme RockYou, combiné à des substitutions (a → @, e → €, s → 5…).
- Le mot de passe peut être intercepté (d’où l’importance du protocole HTTPS).
Des algotithmes comme Argon2ID, BCrypt ou YesCrypt rendent difficiles les attaques par force brute car ils sont intentionnellement coûteux en mémoire et en temps de calcul, y compris sur GPU.
Il est recommandé pour les utilisateurs d’utiliser un gestionnaire de mots de passe (celui intégré au navigateur, BitWarden, KeePass…) et de définir un mot de passe long, complexe, aléatoire et différent pour chaque service.
Authentification Défi-Réponse (ZKP)
Dans une approche Défi-Reponse, l’authentification se fait à divulgation nulle de connaissance en s’appuyant sur la cryptographie à clés publiques.
- Le serveur génère un défi (chaîne aléatoire) et l’envoie au client.
- Le client signe ce défi avec la clé privée de l’utilisateur et renvoie la signature ; la clé privée n’est pas transmise au serveur — cf ZKP.
- Le serveur vérifie la signature avec la clé publique qu’il possède.
Les inconvénients de l’authentification classiques sont ainsi résolus :
- Le serveur ne stocke pas de secret (ni de condensé), et il n’est pas possible de retrouver une clé privée à partir d’une clé publique.
- Aucun secret ne circule sur le réseau.
Attention, il est important que le client ne puisse pas choisir le défi, car cela permettrait à un attaquant de rejouer une ancienne requête d’authentification qu"il serait parvenu à capturer.
Compléments techniques
Lors de l’envoi du formulaire d’authentification, le serveur doit mémoriser le défi généré, par exemple en session. Sa réponse comprend alors un cookie contenant l’identifiant de session.
Si l’authentification réussit, le serveur doit mémoriser l’état :
- dans la session
- ou, sans session, en générant un jeton (cf JWT) qu’il envoie (cookie) au client.
Généralement, la session expire après une certaine durée d’inactivité.
Sur l’hébergement web :
- créer un dossier
auth; - créer un sous-dossier
db(dansauth) ; - placer le script
init-auth-db.shdans le dossierdbet le rendre exécutable (chmod +x init-auth-db.sh) ; - exécuter le script
./init-auth-db.shdepuis le dossierdbpour créer deux utilisateurs, un avec mot de passe et l’autre sans (noter le secret !) ; - vérifier le résultat :
ls -l: le fichierdb.sqlitedoit avoir le droit d’écriture pour tous ;echo "SELECT * FROM Auth" | sqlite3 db.sqlitedoit afficher l’ utilisateur.
Script init-auth-db.sh
Ce script suivant permet de mettre en place une table dédiée à l’authentification des utilisateurs et de les inscrire. Quelques explications :
- Le champ
keysert à enregistrer la clé publique — ou le condensé du mot de passe dans le cas d’une authentification classique. - Le champ
totpsert à générer le code à usage unique (TOTP) pour une authentification forte (avec second facteur) — non utilisé dans ici. - Le champ
recoverysert à la récupération du compte en cas de perte de clé privée (ou mot de passe oublié) ; il doit alors contenur un secret (envoyé par courriel) et une date limite d’utilisation (de l’ordre de quelques minutes).
#!/bin/bash
set -euo pipefail
readonly TOTP_ISSUER="web.sio.local"
if [ ! -f db.sqlite ]; then
echo "CREATE TABLE Auth (id INTEGER PRIMARY KEY AUTOINCREMENT, "\
"email VARCHAR(32) NOT NULL UNIQUE, key VARCHAR(64),"\
"totp VARCHAR(32), recovery VARCHAR(32), locking INT DEFAULT 0);" | sqlite3 db.sqlite
#chmod g+rw . db.sqlite
chmod 707 . ; chmod 606 db.sqlite
fi
echo -n "Email: "; read email
if [ -z "$email" ]; then exit; fi
echo -n "Password (leave empty for ZKA): "; read password
echo -n "Use TOTP [y/N]: "; read use_totp
if [ -z "$password" ]; then
priv=$(openssl genpkey -algorithm ED25519) #éviter les fichiers intermédiaires
pub=$(echo -e "$priv" | openssl pkey -pubout)
key=$(echo -e "$pub" | sed '1d;$d') #| tr -d'\n'
secret=$(echo -e "$priv" | sed '1d;$d') #| tr -d'\n'
else
salt=$(openssl rand 16) #chaîne binaire
#saltHex=$(echo "$salt" | od -An -tx1) #→ hexadécimal
key=$(echo -n "$password" | argon2 "$salt" -id -e) #ne pas oublier "-n" !
secret=$password
fi
echo "INSERT INTO Auth (email, key) VALUES ('$email', '$key');" | sqlite3 db.sqlite
if [ "$use_totp" == "y" ] || [ "$use_totp" == "Y" ]; then
totp=$(openssl rand 20 | base32)
echo "UPDATE Auth SET totp='$totp' WHERE email = '$email'" | sqlite3 db.sqlite
fi
echo -e "\nEmail: $email"
echo "Secret: $secret"
if [ "$use_totp" == "y" ] || [ "$use_totp" == "Y" ]; then
uri="otpauth://totp/$TOTP_ISSUER:$email?secret=$totp&issuer=$TOTP_ISSUER"
echo "TOTP: $(echo "$totp" | sed -E -e 's/([A-Z2-7]{4})/\1 /g' -e 's/ $//')"
qrencode -t ANSIUTF8 "$uri"
fi
Questions de réflexion
Analyser le script et expliquer :
set -euo pipefail;if [ ! -f db.sqlite ]; then;- la contrainte
UNIQUEsur le champemail; chmod a+w . db.sqlite;- repérer les commandes qui génèrent :
- la clé privée,
- la clé publique,
- le condensé (hash) du mot de passe ;
- le sel — encodé en base64 — est présent (en clair) dans le condensé ; vérifier
qu’il correspond (cf ligne commentée
saltHex=…) ; - indiquer (avec justifications à l’appui) si le serveur stocke des secrets, et, le cas échéant, expliquer comment ils pourraient être exploités.
Arborescence de l’application
Organisation du dossier auth :
├── auth.js
├── auth.php
├── check-auth.php
├── db
│ ├── .htaccess
│ ├── db.sqlite
│ └── init-auth-db.sh
├── index.php
└── logout.php
Fichier index.php
Ce fichier — qui sert de point d’entrée — contient un contenu protégé (inaccessible
si l’utilisateur n’est pas authentifié) ; il s’appuie sur check-auth.php
pour vérifier si l’utilisateur est authentifié et le rediriger vers auth.php
si ce n’est pas le cas.
<?php require "check-auth.php"; ?>
<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="UTF-8"/>
<title>Welcome</title>
<link rel="stylesheet" href="styles.css" integrity="sha256-oMAvPtG9s6sdeBZJdAy9tHWT10hViKkooHuBneJkCrw="/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head><body>
<h1>Welcome</h1>
<p>Welcome <?=$email?>! This content is protected.</p>
<p><a href="logout.php">Logout</a></p>
</body></html>
Fichier check-auth.php
<?php
session_start();
if (!isset($_SESSION['email'])) {
header('location: auth.php?reason=' . urlencode('Authentication required'));
exit;
}
$now = time();
if ($now > $_SESSION['ts']) {
header('location: auth.php?reason=' . urlencode('Session expired'));
exit;
}
$_SESSION['ts'] = $now + INACTIVITY_TIMEOUT; //prolonge la session;
?>
Fichier auth.php
Ce fichier gère l’authentification avec signature (ZKP), une clé privée ou un mot de passe. Il peut être appelé :
- avec la méthode
GETpour obtenir le formulaire d’authentification ; - avec la méthode
POSTpour soumettre les codes d’accès ;
<?php
session_start();
if ($_SERVER['REQUEST_METHOD'] == 'GET') {
$challenge = bin2hex(random_bytes(32));
$_SESSION['challenge'] = $challenge;
?>
<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="UTF-8"/>
<title>Authentication</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<style>h1 { text-align: center; }
label { font-weight: bold;}
input { box-sizing: border-box; width: 100%; border: 1px solid #ccc; padding: 0.25em;}
button { float:right; padding: 0.25em 1em; }
label, input, button { display: block; }
label, button { margin-top: 1em; }
p.error { color: red; }
</style><!--FIXME: CSP-->
<script src="auth.js" defer="defer"></script>
</head><body>
<h1>Authentication</h1>
<form id="auth" method="POST">
<input type="hidden" id="challenge" value="<?=$challenge?>"/>
<label for="email">Email *:</label>
<input type="email" id="email" name="email" required="required"/>
<label for="password">Secret *:</label>
<input type="password" id="password" name="password" required="required"/>
<button type="submit">Login</button>
<p><a href="register.php">Register</a>
<?php if (isset($_GET['reason'])) { ?>
<span class="error"><?=htmlspecialchars($_GET['reason'], ENT_QUOTES, 'UTF-8')?></span>
<?php } ?>
</p>
</form>
</body></html>
<?php
} else {
//convertit hexa → str, et garantit une longueur de 64 octets.
function loadHexSignature(string $hexSig): string {
$padding = str_repeat('0', SODIUM_CRYPTO_SIGN_BYTES /*64*/);
if (strlen($hexSig) % 2 == 0 && preg_match('/^[0-9a-fA-F]*$/', $hexSig)) {
$sig = substr($padding . hex2bin($hexSig), -SODIUM_CRYPTO_SIGN_BYTES);
} else {
$sig = $padding;
}
return $sig;
}
//décode le base64, extrait la clé et garantit une longueur de 32 octets
function loadRawKey(string $b64Key): string {
$rawKey = substr(base64_decode($b64Key), -SODIUM_CRYPTO_SIGN_SEEDBYTES); //mode non strict
if (strlen($rawKey) < SODIUM_CRYPTO_SIGN_SEEDBYTES /*32*/) {
$rawKey .= str_repeat('0', SODIUM_CRYPTO_SIGN_SEEDBYTES - strlen($rawKey));
}
return $rawKey;
}
$pdo = new PDO('sqlite:db/db.sqlite'); //FIXME: utiliser un fichier de configuration
if (!isset($_POST['email']) || '' === $_POST['email'] ||
!isset($_POST['password']) || '' === $_POST['password']) {
header($_SERVER['SERVER_PROTOCOL'] . ' 400 Bad Request');
die('error: missing email or password');
}
$stmt = $pdo->prepare('SELECT key FROM Auth WHERE email = ?');
$stmt->execute([$_POST['email']]);
$key = $stmt->fetchColumn();
//le champ key de la table peut être :
$pubRaw = loadRawKey($row['key']); //la clé publique!
$hash = $row['key']; //le condensé du mot de passe
//le champ password du formulaire peut être :
$sig = loadHexSignature($password); //la signature (ZKP)
$privRaw = sodium_crypto_sign_seed_keypair(loadRawKey($password)); //la clé privée (JS désactivé)
$chlg = $_SESSION['challenge'] ?? bin2hex(random_bytes(32)); //si le challenge n'est pas en session, échec assuré
$success = sodium_crypto_sign_verify_detached($sig, $chlg, $pubRaw) || //soit la signature est valide
$pubRaw === sodium_crypto_sign_publickey($privRaw) || //soit la clé privée correspond à la publique
password_verify($password, $hash); //soit le mot de passe correspond au condensé
if ($success) {
session_regenerate_id(true);
$_SESSION['email'] = $_POST['email'];
$_SESSION['ts'] = time() + 60; //1 minute
header('location: index.php');
} else {
header($_SERVER['SERVER_PROTOCOL'] . ' 401 Unauthorized');
header('location: auth.php?reason=' . urlencode('Codes d\'accès incorrects'));
}
}
?>
Remarque ; ce code contient une erreur à corriger : il ne gère pas le cas où l’email saisit n’existe pas.
Fichier auth.js
Ce fichier sert à intercepter la soumission du formulaire en remplaçant la clé privée saisie (à priori depuis un gestionnaire de mots de passe) par la signature du défi.
"use strict";
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("auth").onsubmit = async e => {
e.preventDefault();
try {
const secretInput = document.getElementById("password");
//une exception sera levée si la saisie ne correspond pas à une clé privée / est un mot de passe
const secret = Uint8Array.fromBase64(secretInput.value); //base64 → bytes
const key = await crypto.subtle.importKey("pkcs8", secret, { name: "Ed25519" }, false, ["sign"]);
const challengeInput = document.getElementById("challenge");
const challenge = new TextEncoder().encode(challengeInput.value); //hexa → bytes
const signature = await crypto.subtle.sign("Ed25519", key, challenge);
secretInput.value = new Uint8Array(signature).toHex(); //bytes → hexa
} catch (e) {}
e.target.submit();
};
});
Remarque : pour uniformiser la déclaration des fonctions événementielles de
rappels, réécrire document.getElementById("auth").onsubmit = async e => { … }
en utilisant la méthode addEventListener (à la place de onsubmit).
Autres fichiers
- Compléter le fichier
logout.phpqui permet de se déconnecter (en détruisant la session) et de rediriger l’utilisateur vers la page d’authentification. - Compléter le fichier
.htaccesspour empêcher le téléchargement de la base de données.
Questions de réflexion
Ces points doivent être abodés en tenant compte des problématiques de sécurité, mais aussi de qualité.
- Code PHP 1 :
- Expliquer le rôle de la fonction
urlencode. - Expliquer le bloc
if ($now > $_SESSION['ts']) { … }(fichiercheck-auth.php) et comment est gérée le maintien de la connexion en cas d’activité.
- Expliquer le rôle de la fonction
- Code HTML :
- Expliquer ce qu’est la CSP et le problème avec le code HTML.
- Expliquer l’attribut
defer="defer"d’une balise<script>. - Rappler le rôle des attributs
for(accessibilité),idetname.
- Code Javascript :
- Expliquer le rôle de la directive
"use strict" - Expliquer
document.addEventListener("DOMContentLoaded", fonctionDeRappel). - Expliquer
e.preventDefault()ete.target.submit(). - Expliquer le rôle du champ caché
challenge. - Déboguer (pas à pas) la fonction qui calcule la signature.
- Expliquer ce qui se passe lorsque le JavaScript lève une exception.
- Expliquer le rôle de la directive
- Code PHP 2 :
- Analyser comment le PHP gère les différents types de contenu pour le
champ
keyde la base de données, et les différents types de valeurs pour le champpassworddu formulaire. - Expliquer l’instruction
session_regenerate_id().
- Analyser comment le PHP gère les différents types de contenu pour le
champ
Expliquer pourquoi la signature permet de prouver l’identité de l’utilisateur (cf cryptographie à clés publiques).
L’enregistrement des nouveaux utilisateurs
Ajouter une page permettant aux utilisateurs de s’enregistrer :
- soit en définissant un mot de passe,
- soit en laissant le serveur leur générer une paire de clés,
- ou encore en soumettant une clé publique au serveur.
Limiter les tentatives
Adapter le code pour limiter les tentatives de connexion (verrouillage d’un
compte pendant 5’ au troisième essai infructueux - cf champ locking de la table).
Sécurité en profondeur
Améliorer la sécurité de l’application :
- Correction de la faille XSS.
- Forcer le passage en HTTPS si la requête est en HTTP.
- Au niveau des entêtes HTTP, se documenter et :
- durcir la politique des cookies (
secure,httponly) ; - forcer le HSTS ;
- durcir la politique CSP : seul le contenu en provenance du même serveur
est autorisé (cf
self) et le contenu intégré au HTML (CSS et Javascript notamment) doit être rigouresement interdit.
- durcir la politique des cookies (
- Ajout des SRI pour les fichiers
Javascript et le CSS (cf utilitaire
sha256sum).
Journalisation des accès
Enregistrer dans un fichier de journal (log) les tentatives de connexion (adresse de courriel, date / heure, adresse IP, réussite / échec). Ce fichier journal doit être protégé (empêcher son téléchargement).
Prouver son identité sans divulguer de secret suit le même processus que la signature de documents :
- le serveur envoie un document au client (le défi pour l’authentification) ;
- le client signe le document (avec la clé privée) et renvoie le document signé ;
- le serveur vérifie la signature.
Remarque : la solution développée dans cette activitée est hybride :
- preuve à divulgation nulle de connaissance ;
- comparaison de la clé privée à la clé publique - ce qui n’est sans doute pas une bonne idée, car la clé privée est transmise au serveur (utilile pour l’amélioration progressive) ;
- authentification classique (mot de passe, sel, condensé).