Vérifier qu’un mot de passe respecte la politique

Décider si un mot de passe choisi à l’inscription peut être accepté, sans le faire sortir de votre infrastructure.

Pas besoin d’IA

GratuitRapideVos données partent chez un tiers

Le verdict

N0, parce que la norme énonce des règles et non des jugements, et parce que la seule exigence qui demande une donnée extérieure se satisfait sans envoyer la donnée : cinq caractères du SHA-1 partent, le service rend huit cents suffixes, et la comparaison se fait chez vous. Le test de la fiche lit l’adresse demandée et vérifie que ni le mot de passe, ni son empreinte complète, ni même son suffixe ne s’y trouvent. Le reste du code est surtout fait de ce qu’il ne fait pas : aucune règle de composition, parce que la norme les interdit et parce qu’elles produisent « Motdepasse1! », que la liste de fuites attrape et qu’aucune règle de composition n’attrape.

Les règles de la SP 800-63B, et la liste de fuites en k-anonymat

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitRapide

Python

snippets/check-a-password-against-a-policy/n0.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Contrôler un mot de passe contre la politique que la norme demande vraiment.

Niveau N0. La NIST SP 800-63B-4 est courte, et ses exigences sont des règles,
pas des jugements : quinze caractères pour un mot de passe employé seul, au
moins soixante-quatre permis, aucune règle de composition, aucun changement
périodique, et une comparaison à une liste de mots de passe déjà connus pour
avoir fuité. Quatre des cinq sont de l'arithmétique sur une chaîne.

Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
jugement.

Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
jugement.

La cinquième est la seule qui sorte de la machine, et elle en sort presque
rien. Le service Have I Been Pwned prend les cinq premiers caractères du
SHA-1 du mot de passe et rend tous les suffixes qui commencent par eux — huit
cents environ — si bien que le mot de passe n'est jamais envoyé, que le
service ne peut pas savoir lequel des huit cents était demandé, et que la
réponse est un compte. C'est ce que fait `pwned_count` plus bas, et cela mérite
d'être lu : tout l'argument de confidentialité tient en deux lignes.

Quelque chose sort donc de votre infrastructure — cinq caractères d'une
empreinte, jamais le mot de passe —, et le bloc de risques de cette fiche le
déclare : `third-party`. Et parce que ce contrôle tourne sur un chemin
d'inscription, un service qui ne répond pas ne lève pas ici : l'échec revient
en `blocklist-unavailable` dans `reasons`, avec `breaches` nul, et c'est
l'appelant qui décide d'accepter ou de refuser l'inscription. Refuser tout le
monde parce qu'un tiers est en panne est une panne qu'on n'a pas choisie ;
accepter en silence, c'est sauter sans le dire le seul contrôle que la norme
met en SHALL.

Ce que ce fichier ne fait délibérément pas, c'est compter les majuscules, les
chiffres et les symboles. La norme l'interdit — « Verifiers and CSPs SHALL
NOT impose other composition rules » — parce que ces règles poussent vers
« Motdepasse1! », que la liste de fuites attrape et qu'aucune règle de
composition n'attrape.
"""

from __future__ import annotations

import hashlib
import unicodedata

# SP 800-63B-4, section 3.1.1.2: fifteen characters for a password used as a
# single factor, eight when it is only one factor among several.
MINIMUM = 15
MINIMUM_WITH_SECOND_FACTOR = 8

# The standard says « SHOULD permit a maximum password length of at least 64
# characters »: sixty-four is a floor on the ceiling, not the ceiling. Taking
# it as one turns down a seventy-character passphrase — six words out of a
# password manager — which is a good password refused by a rule of shape, in
# an entry whose whole thesis is that rules of shape push people towards bad
# ones. Two hundred and fifty-six is the usual ceiling; it exists to bound the
# hashing, not to judge anything.
MAXIMUM = 256

RANGE_URL = "https://api.pwnedpasswords.com/range/"

# Reasons that block. A blocklist that did not answer is reported and never
# decided: refusing every sign-up because a third party is down is an outage
# you did not choose, and accepting in silence is the check the standard puts
# in a SHALL, skipped without a word. The caller decides; `reasons` is what it
# decides on.
BLOCKING = ("not-text", "too-short", "too-long", "context-word", "breached")


def pwned_count(password: str, fetch=None) -> int:
    """
    How many breaches this password appears in, without sending the password.

    Only the first five characters of its SHA-1 leave: the service answers with
    every suffix sharing that prefix, and the comparison happens here. The
    `Add-Padding` header makes every answer the same size, so the length of the
    response says nothing either.
    """
    if fetch is None:  # pragma: no cover - needs a network
        def fetch(url: str) -> str:
            import urllib.request

            request = urllib.request.Request(url, headers={"Add-Padding": "true"})
            with urllib.request.urlopen(request, timeout=5) as response:
                return response.read().decode("utf-8")

    digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
    for line in fetch(RANGE_URL + digest[:5]).splitlines():
        suffix, _, count = line.strip().partition(":")
        if suffix == digest[5:]:
            return int(count)
    return 0


def check_password(password, *, second_factor: bool = False, context=(), fetch=None) -> dict:
    """
    Say whether this password may be accepted, and why not when it may not.

    `context` is the short list of words a password must not be — the site's
    name, the account's local part — which the standard allows a blocklist to
    carry. It is compared after normalisation and without case, and nothing
    else about the password's shape is judged.

    `fetch` is injected by the tests; in production it is an HTTPS request.
    It runs on a sign-up path, so a service that does not answer must not raise
    here: the failure comes back as `blocklist-unavailable` in `reasons`, with
    `breaches` at None, and `acceptable` says what the rest of the check says.
    Only `BLOCKING` reasons make a password unacceptable.
    """
    if not isinstance(password, str):
        return _report(False, ["not-text"], None, 0)

    # The standard asks for NFC, so that a password typed with a composed « é »
    # and one typed with « e » plus an accent are the same password.
    normalised = unicodedata.normalize("NFC", password)
    length = len(normalised)
    reasons = []
    floor = MINIMUM_WITH_SECOND_FACTOR if second_factor else MINIMUM
    if length < floor:
        reasons.append("too-short")
    if length > MAXIMUM:
        reasons.append("too-long")
    # Lower case rather than case folding, so that the two languages of this
    # page compare the same way: folding turns « ß » into « ss », lowering
    # leaves it alone, and JavaScript has no folding.
    folded = normalised.lower()
    if any(folded == unicodedata.normalize("NFC", word).lower() for word in context):
        reasons.append("context-word")

    # The blocklist is asked last, and only for a password that could still be
    # accepted: a request that will change nothing is a request not to make.
    breaches = None
    if not reasons:
        try:
            breaches = pwned_count(normalised, fetch)
        except Exception:  # noqa: BLE001 - see BLOCKING: an outage is not a verdict
            reasons.append("blocklist-unavailable")
        else:
            if breaches:
                reasons.append("breached")
    blocking = [reason for reason in reasons if reason in BLOCKING]
    return _report(not blocking, reasons, breaches, length)


def _report(acceptable: bool, reasons: list, breaches: int | None, length: int) -> dict:
    return {"acceptable": acceptable, "reasons": reasons, "breaches": breaches, "length": length}

JavaScript

snippets/check-a-password-against-a-policy/n0.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Contrôler un mot de passe contre la politique que la norme demande vraiment.
 *
 * Niveau N0. La NIST SP 800-63B-4 est courte, et ses exigences sont des règles,
 * pas des jugements : quinze caractères pour un mot de passe employé seul, au
 * moins soixante-quatre permis, aucune règle de composition, aucun changement
 * périodique, et une comparaison à une liste de mots de passe déjà connus pour
 * avoir fuité. Quatre des cinq sont de l'arithmétique sur une chaîne.
 *
 * Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
 * plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
 * dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
 * dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
 * toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
 * défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
 * jugement.
 *
 * Le « au moins soixante-quatre permis » est un plancher sur le plafond, pas le
 * plafond : le prendre pour un plafond refuse une phrase de passe de soixante-
 * dix caractères, ce que produit un gestionnaire réglé sur six mots, c'est-à-
 * dire un bon mot de passe refusé par une règle de forme, dans une fiche dont
 * toute la thèse est que les règles de forme mènent aux mauvais. Le plafond par
 * défaut est donc à deux cent cinquante-six, et il borne le hachage, pas le
 * jugement.
 *
 * La cinquième est la seule qui sorte de la machine, et elle en sort presque
 * rien. Le service Have I Been Pwned prend les cinq premiers caractères du
 * SHA-1 du mot de passe et rend tous les suffixes qui commencent par eux — huit
 * cents environ — si bien que le mot de passe n'est jamais envoyé, que le
 * service ne peut pas savoir lequel des huit cents était demandé, et que la
 * réponse est un compte. C'est ce que fait `pwnedCount` plus bas, et cela mérite
 * d'être lu : tout l'argument de confidentialité tient en deux lignes.
 *
 * Quelque chose sort donc de votre infrastructure — cinq caractères d'une
 * empreinte, jamais le mot de passe —, et le bloc de risques de cette fiche le
 * déclare : `third-party`. Et parce que ce contrôle tourne sur un chemin
 * d'inscription, un service qui ne répond pas ne lève pas ici : l'échec revient
 * en `blocklist-unavailable` dans `reasons`, avec `breaches` nul, et c'est
 * l'appelant qui décide d'accepter ou de refuser l'inscription. Refuser tout le
 * monde parce qu'un tiers est en panne est une panne qu'on n'a pas choisie ;
 * accepter en silence, c'est sauter sans le dire le seul contrôle que la norme
 * met en SHALL.
 *
 * Ce que ce fichier ne fait délibérément pas, c'est compter les majuscules, les
 * chiffres et les symboles. La norme l'interdit — « Verifiers and CSPs SHALL
 * NOT impose other composition rules » — parce que ces règles poussent vers
 * « Motdepasse1! », que la liste de fuites attrape et qu'aucune règle de
 * composition n'attrape.
 */

import { createHash } from 'node:crypto';

// SP 800-63B-4, section 3.1.1.2: fifteen characters for a password used as a
// single factor, eight when it is only one factor among several.
export const MINIMUM = 15;
export const MINIMUM_WITH_SECOND_FACTOR = 8;

// The standard says « SHOULD permit a maximum password length of at least 64
// characters »: sixty-four is a floor on the ceiling, not the ceiling. Taking
// it as one turns down a seventy-character passphrase — six words out of a
// password manager — which is a good password refused by a rule of shape, in
// an entry whose whole thesis is that rules of shape push people towards bad
// ones. Two hundred and fifty-six is the usual ceiling; it exists to bound the
// hashing, not to judge anything.
export const MAXIMUM = 256;

export const RANGE_URL = 'https://api.pwnedpasswords.com/range/';

// Reasons that block. A blocklist that did not answer is reported and never
// decided: refusing every sign-up because a third party is down is an outage
// you did not choose, and accepting in silence is the check the standard puts
// in a SHALL, skipped without a word. The caller decides; `reasons` is what it
// decides on.
export const BLOCKING = ['not-text', 'too-short', 'too-long', 'context-word', 'breached'];

/**
 * How many breaches this password appears in, without sending the password.
 *
 * Only the first five characters of its SHA-1 leave: the service answers with
 * every suffix sharing that prefix, and the comparison happens here. The
 * `Add-Padding` header makes every answer the same size, so the length of the
 * response says nothing either.
 */
export async function pwnedCount(password, fetchRange) {
  const get = fetchRange ?? (async (url) => {
    /* c8 ignore next 3 -- needs a network */
    const response = await fetch(url, { headers: { 'Add-Padding': 'true' } });
    return response.text();
  });

  const digest = createHash('sha1').update(password, 'utf8').digest('hex').toUpperCase();
  const body = await get(RANGE_URL + digest.slice(0, 5));
  for (const line of body.split(/\r?\n/)) {
    const [suffix, count] = line.trim().split(':');
    if (suffix === digest.slice(5)) return Number(count);
  }
  return 0;
}

/**
 * Say whether this password may be accepted, and why not when it may not.
 *
 * `context` is the short list of words a password must not be — the site's
 * name, the account's local part — which the standard allows a blocklist to
 * carry. It is compared after normalisation and without case, and nothing else
 * about the password's shape is judged.
 *
 * `fetchRange` is injected by the tests; in production it is an HTTPS request.
 * It runs on a sign-up path, so a service that does not answer must not throw
 * here: the failure comes back as `blocklist-unavailable` in `reasons`, with
 * `breaches` at null, and `acceptable` says what the rest of the check says.
 * Only `BLOCKING` reasons make a password unacceptable.
 *
 * @param {string} password
 * @param {{secondFactor?: boolean, context?: string[], fetchRange?: Function}} [options]
 */
export async function checkPassword(password, { secondFactor = false, context = [], fetchRange } = {}) {
  if (typeof password !== 'string') return report(false, ['not-text'], null, 0);

  // The standard asks for NFC, so that a password typed with a composed « é »
  // and one typed with « e » plus an accent are the same password.
  const normalised = password.normalize('NFC');
  const length = [...normalised].length;
  const reasons = [];
  const floor = secondFactor ? MINIMUM_WITH_SECOND_FACTOR : MINIMUM;
  if (length < floor) reasons.push('too-short');
  if (length > MAXIMUM) reasons.push('too-long');
  // Lower case rather than case folding, so that the two languages of this
  // page compare the same way: folding turns « ß » into « ss », lowering
  // leaves it alone, and JavaScript has no folding.
  const folded = normalised.toLowerCase();
  if (context.some((word) => word.normalize('NFC').toLowerCase() === folded)) {
    reasons.push('context-word');
  }

  // The blocklist is asked last, and only for a password that could still be
  // accepted: a request that will change nothing is a request not to make.
  let breaches = null;
  if (reasons.length === 0) {
    try {
      breaches = await pwnedCount(normalised, fetchRange);
      if (breaches) reasons.push('breached');
    } catch {
      // See BLOCKING: an outage is not a verdict.
      reasons.push('blocklist-unavailable');
    }
  }
  const blocking = reasons.filter((reason) => BLOCKING.includes(reason));
  return report(blocking.length === 0, reasons, breaches, length);
}

const report = (acceptable, reasons, breaches, length) => ({
  acceptable, reasons, breaches, length,
});

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

La liste de fuites est un double local, et les comptes affichés sont les siens. Ce qui compte ici est ailleurs : l’adresse demandée, montrée sous chaque cas, ne contient que cinq caractères du haché.

  1. Une phrase de passe française

    Donné

    le chat dort sur le radiateur

    Ce qu’il renvoie

    { "acceptable": true, "reasons": [], "breaches": 0, "length": 29 }

    Ce qu’il en conclut

    Accepté

    Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/409F0

  2. Le mot de passe que produisent les règles de composition

    Donné

    Motdepasse1!

    Ce qu’il renvoie

    { "acceptable": false, "reasons": [ "too-short" ], "breaches": null, "length": 12 }

    Ce qu’il en conclut

    Refusé — trop court

    Rien n’est sorti de la machine : le refus s’est joué avant la liste.

  3. Seize lettres identiques, sans majuscule ni chiffre

    Donné

    aaaaaaaaaaaaaaaa

    Ce qu’il renvoie

    { "acceptable": false, "reasons": [ "breached" ], "breaches": 3, "length": 16 }

    Ce qu’il en conclut

    Refusé — présent dans une fuite

    Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/3499C

  4. Une phrase de passe célèbre

    Donné

    correct horse battery staple

    Ce qu’il renvoie

    { "acceptable": false, "reasons": [ "breached" ], "breaches": 120, "length": 28 }

    Ce qu’il en conclut

    Refusé — présent dans une fuite

    Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/ABF7A

  5. Le nom du site lui-même

    Donné

    Boulangerie-Martin

    Ce qu’il renvoie

    { "acceptable": false, "reasons": [ "context-word" ], "breaches": null, "length": 18 }

    Ce qu’il en conclut

    Refusé — c’est le nom du site

    Rien n’est sorti de la machine : le refus s’est joué avant la liste.

  6. Le prénom et l’année de naissance de la fille du titulaire

    Donné

    Clementine-2019-Martin

    Ce qu’il renvoie

    { "acceptable": true, "reasons": [], "breaches": 0, "length": 22 }

    Ce qu’il en conclut

    Accepté

    Ce qui est sorti de la machine : https://api.pwnedpasswords.com/range/E6EA2

    Là, ça casseVingt-deux caractères, aucune fuite connue : la politique l’accepte, et c’est le prénom de la fille du titulaire suivi de son année de naissance. Le contrôle ne connaît que ce qui a déjà fuité ; ce qu’un proche devinerait en trois essais ne laisse aucune trace dans une liste. C’est la limite du niveau, et elle ne se referme pas avec un modèle : elle se referme avec un second facteur.

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’un fournisseur externe
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Le mot de passe ne sort pas : seuls cinq caractères de son empreinte SHA-1 sont transmis, et le test de la fiche vérifie ce qui part
  • Dépendance à un service tiers pour la liste de fuites : sa disponibilité conditionne ce contrôle, et l’appelant doit décider quoi faire quand il ne répond pas

Point de rupture

Le contrôle ne connaît que ce qui a déjà fuité : « Clementine-2019-Martin » — vingt-deux caractères, le prénom et l’année de naissance de la fille du titulaire — n’est dans aucune liste, et passe. Le témoin est dans le même test : « correct horse battery staple », assez long lui aussi, est refusé parce qu’il a fuité.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus, et c’est la conclusion de la norme elle-même : ce qu’un proche devinerait en trois essais ne laisse aucune trace dans une liste de fuites. La réponse est un second facteur, pas une règle de plus sur le mot de passe — et `context` ne compare que des mots entiers, pas des morceaux : « Boulangerie-Martin-2026 » passe une liste qui porte « Boulangerie-Martin ».

Les quatre options

  1. RecommandéN0Les règles de la SP 800-63B, et la liste de fuites en k-anonymatGratuit · Rapide
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

L’outil classique existe et s’appelle zxcvbn : il estime le nombre d’essais nécessaires en cherchant des motifs — mots du dictionnaire, dates, suites de clavier — et c’est un indicateur utile à afficher pendant la saisie. Il n’est pas mis dans l’échelle ici parce qu’il ne répond pas à la même question : la norme demande une décision, pas une note, et ses dictionnaires sont ceux de l’anglais dans le portage Python, ce qui surestime une phrase de passe française. Il est en outre orthogonal à l’exigence qui fait le travail — la comparaison à la liste de fuites —, qu’il ne remplace pas.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Un modèle spécialisé auto-hébergé pour juger un mot de passe devrait être entraîné sur des mots de passe, c’est-à-dire sur des fuites, et il en garderait la trace dans ses poids. Il rendrait de surcroît un score là où ce contrôle doit rendre une décision et sa raison : un formulaire d’inscription doit dire à quelqu’un pourquoi son choix est refusé, et un nombre entre zéro et un n’est pas une phrase qu’on affiche.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Envoyer le mot de passe choisi par un utilisateur à un fournisseur tiers est la seule chose que cette fiche existe pour refuser : c’est le secret même, en clair, sorti de votre infrastructure, journalisé chez quelqu’un d’autre, pour obtenir un avis que quatre comparaisons de longueur et une requête en k-anonymat rendent exactement. Aucun gain imaginable ne compense la sortie de la donnée.

Pour aller plus loin

Source des chiffres

Métadonnées