Le dummy hash Argon2 : 50 millisecondes entre ton username enum et ta tranquillité

Dans le précédent article, je racontais un bug de conteneur PKCS#12 sur Windows. Pendant l'audit du même service d'authentification, on a trouvé quelque chose de plus subtil : un timing oracle sur le endpoint de login.

Le principe est simple. Tu envoies un POST /login avec un email qui n'existe pas : réponse en 2ms. Tu envoies le même POST avec un email qui existe mais un mauvais mot de passe : réponse en 55ms. Même status code, même body. Mais 53ms de différence.

Un attaquant qui envoie 50 requêtes par email candidat obtient un histogramme bimodal parfaitement lisible. Pas besoin de cracker quoi que ce soit : il sait quels comptes existent.

Pourquoi le gap existe

Argon2id est un KDF (Key Derivation Function) conçu pour être lent. C'est son job. Avec des paramètres standard — time=1, memory=64MiB, threads=4 — un appel à argon2.CompareHashAndPassword prend entre 40 et 80ms selon la machine.

Le handler de login fait ceci :

func (h *LoginHandler) Handle(email, password string) error {
    user, err := h.repo.FindByEmail(email)
    if err != nil {
        return ErrInvalidCredentials // 1-2ms
    }

    if !argon2.CompareHashAndPassword(user.PasswordHash, password) {
        return ErrInvalidCredentials // 50-80ms
    }

    return nil
}

Le problème est mécanique : si l'utilisateur n'existe pas, on retourne immédiatement. Si l'utilisateur existe, on passe par Argon2. Le temps de réponse leak l'existence du compte.

C'est un pattern classique, référencé dans l'OWASP ASVS (V2.2.1) et pourtant encore présent dans une majorité de codebases que j'ai auditées.

Le fix : le dummy hash

L'idée est simple : si l'utilisateur n'existe pas, on fait quand même un CompareHashAndPassword — contre un hash pré-calculé qui ne correspond à rien. Le résultat est ignoré, mais le temps CPU est consommé.

type LoginHandler struct {
    repo      UserRepository
    dummyHash []byte // pre-calcule au demarrage
}

func NewLoginHandler(repo UserRepository) *LoginHandler {
    // Genere un dummy hash avec les memes params que le vrai hasher
    dummy, _ := argon2.GenerateFromPassword(
        []byte("dummy-password-never-matches"),
        argon2.DefaultParams(),
    )
    return &LoginHandler{repo: repo, dummyHash: dummy}
}

func (h *LoginHandler) Handle(email, password string) error {
    user, err := h.repo.FindByEmail(email)
    if err != nil {
        // User inconnu : on fait quand meme le travail crypto
        argon2.CompareHashAndPassword(h.dummyHash, []byte(password))
        return ErrInvalidCredentials
    }

    if !argon2.CompareHashAndPassword(user.PasswordHash, []byte(password)) {
        return ErrInvalidCredentials
    }

    return nil
}

Trois lignes ajoutées dans le handler. La latence est maintenant normalisée : ~55ms que l'utilisateur existe ou non. L'attaquant ne peut plus distinguer les deux cas.

Le piège du param bump

Six mois plus tard, tu suis les nouvelles recommandations OWASP. Tu passes memory de 64 à 128 MiB. Le hasher utilise les nouveaux params pour les prochains mots de passe. Tout a l'air de fonctionner.

Sauf que le dummy hash a été calculé au démarrage avec les anciens params. CompareHashAndPassword sur le dummy prend ~30ms (anciens params). Sur un vrai hash récent : ~90ms (nouveaux params). L'oracle revient, subtilement.

Le couplage est implicite : le dummy et le hasher doivent utiliser les mêmes paramètres, mais rien dans le code ne force cette synchronisation. C'est le genre de régression qui passe les tests unitaires et qui nécessite une mesure de timing pour être détectée.

La solution robuste

Ne pas hard-coder les params du dummy. Les lire depuis la même config que le hasher :

func NewLoginHandler(repo UserRepository, params argon2.Params) *LoginHandler {
    dummy, _ := argon2.GenerateFromPassword(
        []byte("dummy-password-never-matches"),
        params, // memes params que le vrai hasher
    )
    return &LoginHandler{repo: repo, dummyHash: dummy}
}

Le dummy est recalculé à chaque démarrage du service avec les params courants. Si tu bumpes les params, tu redémarres le service, le dummy suit.

Pour du hot-reload de config sans redémarrage, tu peux utiliser un sync.Once qui se reset quand la config change. Mais en pratique, un changement de params Argon2 nécessite un redémarrage de toute façon (les anciens hashes doivent être migrés progressivement).

Le test de non-régression

Tu peux écrire un test de timing. Il sera flaky par nature — le timing dépend de la charge machine. Mais avec un seuil large et assez d'itérations, il attrape les régressions grossières :

func TestLoginTimingConsistency(t *testing.T) {
    handler := NewLoginHandler(repo, argon2.DefaultParams())

    const N = 20
    var knownDurations, unknownDurations []time.Duration

    for i := 0; i < N; i++ {
        start := time.Now()
        handler.Handle("exists@example.com", "wrong-password")
        knownDurations = append(knownDurations, time.Since(start))

        start = time.Now()
        handler.Handle("doesnotexist@example.com", "wrong-password")
        unknownDurations = append(unknownDurations, time.Since(start))
    }

    knownAvg := average(knownDurations)
    unknownAvg := average(unknownDurations)
    diff := abs(knownAvg - unknownAvg)

    // Seuil a 20% de la moyenne - assez large pour ne pas flaker,
    // assez strict pour attraper un delta de 30ms+
    threshold := knownAvg / 5
    if diff > threshold {
        t.Fatalf("timing leak: known=%v unknown=%v diff=%v threshold=%v",
            knownAvg, unknownAvg, diff, threshold)
    }
}

Mieux encore : un test structurel qui vérifie que le dummy hash utilise les mêmes params que le hasher. Pas de timing, pas de flakiness :

func TestDummyHashUsesCurrentParams(t *testing.T) {
    params := argon2.Params{Time: 1, Memory: 128 * 1024, Threads: 4}
    handler := NewLoginHandler(repo, params)

    decoded := argon2.DecodeHash(handler.dummyHash)
    if decoded.Memory != params.Memory {
        t.Fatalf("dummy hash memory=%d, expected=%d", decoded.Memory, params.Memory)
    }
    if decoded.Time != params.Time {
        t.Fatalf("dummy hash time=%d, expected=%d", decoded.Time, params.Time)
    }
}

Conclusion

Le dummy hash est un pattern simple mais fragile. Sa fragilité vient du couplage implicite entre deux configurations qui doivent rester synchronisées. Rends ce couplage explicite — un seul point de vérité pour les params — ou il te trahira au prochain bump.

Ce qu'il faut retenir : la sécurité d'un endpoint de login ne se mesure pas uniquement au status code et au body de la réponse. Le temps fait partie de la surface d'attaque.

Maintenant que le login est solide — pas d'enumeration, pas de timing leak — il reste la question du brute force. Comment gérer le lockout sans leaker l'existence du compte ? C'est le sujet du prochain article.

Commentaires (0)