Skip to content

How-to : Code de vérification numérique

Pattern éprouvé par FT188 verifylog — Code de vérification SMS/email à 6 chiffres avec protection contre le brute-force, comparaison à temps constant, et prévention du replay. ATK-01〜12 tous Pass.


Ce que cela couvre

Un flux de vérification de contact (email ou téléphone) :

  1. Demander un code — le serveur génère un code aléatoire à 6 chiffres, le livre hors bande
  2. Soumettre le code — l'utilisateur soumet le code ; 3 tentatives max avant verrouillage
  3. Vérification du statut — vérifier si une vérification a été complétée

Garanties de sécurité :

PréoccupationTechnique
Brute force3 tentatives max → 429 Locked
Attaque temporellehash_equals() comparaison à temps constant
Replay du codeCode vérifié retourne 410 Gone
Énumération d'utilisateursPOST /verifications retourne toujours 202
Mass assignmentcode_hash/verified_at définis côté serveur uniquement
Injection SQLParamètre de chemin entier uniquement (garde ctype_digit + strlen > 18)
Confusion de typeVérification is_string() avant ctype_digit()
ReDoSctype_digit() O(n) — pas de regex

Schéma

sql
CREATE TABLE verifications (
    id             INTEGER PRIMARY KEY AUTOINCREMENT,
    contact        TEXT    NOT NULL,
    code_hash      TEXT    NOT NULL,   -- SHA-256 du code à 6 chiffres
    attempts_count INTEGER NOT NULL DEFAULT 0,
    max_attempts   INTEGER NOT NULL DEFAULT 3,
    verified_at    TEXT,               -- NULL = en attente
    expires_at     TEXT    NOT NULL,
    created_at     TEXT    NOT NULL
);

code_hash stocke hash('sha256', $code) — jamais le code en clair.


API

MéthodeCheminDescription
POST/verificationsDemander un code (toujours 202)
POST/verifications/{id}/checkSoumettre le code (3 tentatives max)
GET/verifications/{id}Vérification du statut (aucun code révélé)

Pattern central : Génération de code et stockage par hash

php
// Générer un code à 6 chiffres aléatoire cryptographiquement sûr
$plainCode = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$codeHash  = hash('sha256', $plainCode);

// Stocker le hash — JAMAIS le code en clair
INSERT INTO verifications (contact, code_hash, expires_at, created_at)
VALUES (:contact, :code_hash, :expires_at, :now)

// Retourner plainCode à l'appelant (pour livraison) — ne jamais le stocker ni le journaliser
return ['verification' => $v, 'plainCode' => $plainCode];

random_int(0, 999999) utilise CSPRNG. str_pad(..., 6, '0', STR_PAD_LEFT) assure les zéros initiaux (ex: 000042).


Pattern central : Comparaison à temps constant

php
// ATK-10: hash_equals prévient l'attaque temporelle
// $v->codeHash = SHA-256 stocké depuis la DB
// $submittedCode = entrée utilisateur (chaîne de 6 chiffres)
$valid = hash_equals($v->codeHash, hash('sha256', $submittedCode));

Pourquoi pas === : === court-circuite à la première discordance — un attaquant peut mesurer les différences de timing entre "premier byte erroné" et "tous les bytes erronés" pour affiner le code correct caractère par caractère. hash_equals() est à temps constant quelle que soit la discordance.


Pattern central : Comptage des tentatives en premier

php
public function check(int $id, string $submittedCode): string
{
    $v = $this->fetchById($id);

    if ($v === null)        return 'not_found';
    if ($v->isVerified())   return 'already';   // ATK-11: garde contre le replay
    if ($v->isLocked())     return 'locked';    // ATK-05: garde contre le brute force
    if ($v->isExpired())    return 'expired';

    // Incrémenter AVANT la vérification — prévient l'exploitation de condition de course
    UPDATE verifications SET attempts_count = attempts_count + 1 WHERE id = :id

    // ATK-10: comparaison à temps constant
    $valid = hash_equals($v->codeHash, hash('sha256', $submittedCode));

    if ($valid) {
        UPDATE verifications SET verified_at = :now WHERE id = :id
        return 'verified';
    }

    return 'wrong';
}

Incrémenter les tentatives avant la comparaison garantit qu'une course concurrente pour vérifier le même code ne peut pas contourner la limite.


Pattern central : Prévention de l'énumération d'utilisateurs

php
// POST /verifications — retourne TOUJOURS 202
// Même si le contact est invalide ou si la livraison échoue
private function handleRequest(ServerRequestInterface $request): ResponseInterface
{
    $contact = V::str($body['contact'] ?? null, self::MAX_CONTACT_LEN);

    if ($contact === null || $contact === '') {
        return $this->responseFactory->create(['error' => '...'], 422); // uniquement pour vide/null
    }

    // le succès ou l'échec de livraison est invisible pour l'appelant
    $this->repository->create($contact);

    return $this->responseFactory->create(['id' => $v->id, 'expires_in' => 600], 202);
}

Un 404 ou 422 pour un contact inconnu révèle "ce contact n'est pas enregistré." Toujours 202.


Pattern central : Validation du type et format du code

php
$raw = $body['code'] ?? null;

// ATK-07: confusion de type — le code doit être une chaîne
if (!is_string($raw)) {
    return $this->responseFactory->create(['error' => 'code must be a 6-digit string.'], 422);
}

// ATK-09: ReDoS — ctype_digit est O(n), pas une regex
// ATK-09: vérification de longueur exacte — pas "au moins 6"
if (!ctype_digit($raw) || strlen($raw) !== 6) {
    return $this->responseFactory->create(['error' => 'code must be exactly 6 digits.'], 422);
}

is_string() avant ctype_digit() rejette les entiers JSON, booléens et tableaux. ctype_digit() est sûr contre le ReDoS (temps linéaire).


Design des réponses

ScénarioStatutCorps
Code correct200{verified: true}
Code erroné, tentatives restantes422{error: "Incorrect code.", attempts_left: N}
Tentatives max atteintes429{error: "Too many failed attempts. Request a new code."}
Déjà vérifié (replay)410{error: "This verification has already been completed."}
Expiré410{error: "Verification has expired. Request a new code."}
Non trouvé404{error: "Verification not found."}

ATK-01〜12 tous Pass

ATKAttaqueDéfense
01Injection SQL dans {id}ctype_digit() + garde strlen > 18
02IDOR — check avec l'ID de vérification d'un autreMême 404 — pas d'oracle de propriété
03Mass assignment (code_hash/verified_at depuis body)Défini côté serveur uniquement
04XSS dans contactSortie JSON uniquement — pas de rendu HTML. Contact non retourné dans la réponse
05Brute force du code à 6 chiffres3 échecs → 429 Locked
06Contournement d'authverified_at défini par le serveur uniquement
07Confusion de type (code comme int/bool/array)is_string() + ctype_digit()
08Dépassement d'entier dans {id}Garde strlen > 18
09Entrée de code style ReDoSctype_digit() O(n)
10Attaque temporelle sur la comparaison du codehash_equals() temps constant
11Replay du code après succès410 Gone
12Injection CRLF dans les en-têtesPSR-7 rejette au niveau HTTP

Résultats des tests (FT188)

48 tests / 103 assertions — tous PASS
PHPStan level 8 — pas d'erreurs
PHP CS Fixer — propre
ATK-01〜12 tous Pass

Source : ../NENE2-FT/verifylog/

Publié sous licence MIT.