Erreur bcrypt « 72 octets » : pourquoi les mots de passe courts échouent aussi
Deux problèmes distincts produisent le même message, et un seul des deux a un rapport avec votre mot de passe.
Si votre mot de passe dépasse réellement la limite des 72 octets de bcrypt, bcrypt lit les 72 premiers octets et jette le reste. Nous avons haché deux mots de passe de 82 octets partageant leurs 72 premiers octets, sous un sel fixe. Tous deux ont produit $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, et bcrypt.compareSync(p2, hash(p1)) a renvoyé true. Le second mot de passe ouvre le compte du premier.
Si votre mot de passe est manifestement court et que vous obtenez malgré tout ceci :
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
alors le message se trompe de cause (« le mot de passe ne peut pas dépasser 72 octets, tronquez-le manuellement si nécessaire »). Avec passlib 1.7.4 et bcrypt 5.0.0, un mot de passe de 14 octets le déclenche.
Le coupable, dans ce cas, est une sonde d’auto-test fixe de 255 octets, interne à passlib. Elle s’exécute une seule fois, à l’initialisation du backend, avant même que votre mot de passe n’atteigne l’appel de hachage. bcrypt 5.0.0 rejette la sonde, l’exception s’échappe, et vous lisez une plainte à propos d’un mot de passe que personne n’a saisi.
Le monkey patch __about__ qui domine les résultats de recherche pour cette erreur ne la corrige pas. Nous l’avons rejoué dans un processus vierge, patch appliqué avant import passlib, et la ValueError est revenue à l’identique.
Tri en 30 secondes : dans quel cas êtes-vous
| Votre mot de passe | Moment de l’erreur | Cause racine | Aller à |
|---|---|---|---|
| Plus de 72 octets | À l’appel de hash | Vraiment trop long. bcrypt 5.0 lève une exception, bcrypt 4.x tronque en silence | Sections 2 et 3 |
| Moins de 72 octets, avec passlib | Au premier appel du processus | La sonde de 255 octets de passlib. Aucun rapport avec votre mot de passe | Section 4 |
| Contient du chinois, du japonais ou des emoji | Semble court, ne l’est pas | Les caractères ne sont pas des octets | Section 3 |
| Échoue depuis une mise à jour de dépendance | Après un déploiement | Le changement de comportement de bcrypt 5.0 | Sections 4 et 5 |
Si vous êtes dans la deuxième ligne, passez directement à la section 4. Rien dans les deux sections suivantes ne vous servira, et le correctif est différent.
Ce que la limite des 72 octets de bcrypt fait à votre mot de passe
Pourquoi bcrypt s’arrête à 72
bcrypt repose sur Blowfish, et il injecte votre mot de passe comme clé Blowfish. Blowfish étend sa clé en un tableau P de 18 sous-clés de 32 bits chacune. Cela fait 18 × 4 = 72 octets de matière clé, et la boucle d’expansion revient au début de la clé une fois les 18 emplacements remplis.
Le plafond est donc structurel, pas un tampon configurable que quelqu’un aurait oublié d’agrandir. Toute implémentation conforme de bcrypt, sur n’importe quelle plateforme, a la même limite : c’est pourquoi vous retrouvez le nombre 72 aussi bien en Python qu’en Node, en Go, en Java ou en PHP.
Deux mots de passe différents, un seul hash
La troncature des mots de passe par bcrypt est une propriété de sécurité, pas un simple désagrément de longueur.
Avec bcryptjs 3.0.3 et le sel fixe $2a$10$abcdefghijklmnopqrstuv, nous avons haché deux mots de passe de 82 octets chacun :
| Mot de passe | Valeur | Octets |
|---|---|---|
| p1 | "A"×72 + "XXXXXXXXXX" | 82 |
| p2 | "A"×72 + "ZZZZZZZZZZ" | 82 |
Tous deux ont produit la même empreinte :
$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S
Deux mots de passe différents, un seul hash : true. Et la conséquence qui en découle :
bcrypt.compareSync(p2, hash(p1)) // true
Un attaquant qui connaît les 72 premiers octets d’une longue phrase secrète peut y accoler n’importe quoi et s’authentifier. Chaque octet situé au-delà de la frontière n’ajoute rigoureusement rien à la robustesse du hash stocké, quel que soit le soin avec lequel vos utilisateurs l’ont choisi. Si vous voulez confronter un hash que vous possédez déjà à un mot de passe candidat sans écrire de script, vous pouvez générer et vérifier des hashs bcrypt dans le navigateur et observer vous-même ce comportement.
Où tombe exactement la frontière
Nous avons resserré le seuil octet par octet, en gardant un préfixe commun et en modifiant exactement un octet après celui-ci :
| Octets de préfixe identiques | L’octet N+1 diffère en | Même hash ? |
|---|---|---|
| 70 | 71 | false |
| 71 | 72 | false |
| 72 | 73 | true |
| 73 | 74 | true |
L’octet 72 compte encore. L’octet 73 est le premier à ne plus compter, sans dégradé ni mélange partiel entre les deux. La vérification tient en quelques lignes si vous voulez la refaire sur votre propre bibliothèque.
Les caractères ne sont pas des octets
bcrypt compte des octets UTF-8, et vos utilisateurs tapent des caractères. Pour l’ASCII, les deux nombres coïncident. L’écart commence dès qu’une équipe sort d’un marché anglophone.
| Type de caractère | Exemple | Octets par caractère | 72 octets équivalent à |
|---|---|---|---|
| Lettres latines ASCII | A | 1 | 72 caractères |
| Sinogrammes chinois | 密 | 3 | 24 caractères |
| Kana japonais | あ | 3 | 24 caractères |
| Emoji | 🔒 | 4 | 18 caractères |
| Cyrillique | я | 2 | 36 caractères |
| Trémas allemands | ü | 2 | 36 caractères |
Nous avons confirmé les deux extrêmes : avec un mot de passe en chinois, les différences après le 24e caractère sont ignorées (true), et avec un mot de passe en emoji, celles après le 18e le sont aussi (true).
Une phrase secrète chinoise de 25 caractères paraît généreuse dans un champ de mot de passe. Elle a déjà franchi la ligne. Un utilisateur qui choisit 20 emoji dépasse la limite depuis deux caractères, sans qu’aucun message le lui signale.
Mesurer la longueur en octets dans votre propre code
Une vérification de longueur écrite sur un nombre de caractères passera alors que la valeur sous-jacente est déjà trop longue. Mesurez des octets :
# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))
Dans les navigateurs sans Buffer, new TextEncoder().encode(pw).length donne le même nombre. Placez ce contrôle devant votre appel de hachage et renvoyez un vrai message de validation, plutôt que de laisser la bibliothèque lever une exception en production. Si vous en profitez pour revoir votre politique de longueur minimale, comment la robustesse d’un mot de passe se mesure réellement explique ce qu’une règle de longueur vous achète, et ce qu’elle ne vous achète pas.
Pourquoi les mots de passe courts échouent aussi : la sonde de 255 octets de passlib
C’est ce cas-là qui envoie la plupart des gens vers un moteur de recherche : votre mot de passe fait quatorze caractères et la bibliothèque affirme qu’il dépasse 72 octets.
Reproduire le problème
Trois lignes, sous Python 3.14.5 avec bcrypt 5.0.0 et passlib 1.7.4 :
from passlib.hash import bcrypt
bcrypt.hash("short-password") # 14 octets
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Quatorze octets en entrée, une plainte sur 72 octets en sortie. L’erreur bcrypt de passlib est bien réelle, mais le nombre qu’elle contient décrit tout autre chose.
La pile d’appels complète
Voici ce qui s’exécute, tracé dans passlib 1.7.4 :
- Le premier appel déclenche l’initialisation du backend :
_calc_checksum→_stub_requires_backend()→set_backend(). _load_backend_mixinlitbcrypt.__about__.__version__. L’attribut n’existe pas, donc uneAttributeErrorest levée. passlib l’avale et affiche(trapped) error reading bcrypt version.- L’initialisation se poursuit dans
_finalize_backend_mixin(passlib/handlers/bcrypt.py:421), qui appelledetect_wrap_bug(IDENT_2A). detect_wrap_bug(même fichier,:378) vérifie une sonde fixe de 255 octets.- bcrypt 5.0.0 lève une
ValueErrorpour tout ce qui dépasse 72 octets : la sonde explose donc sur elle-même. - L’exception remonte jusqu’à votre site d’appel. Vous voyez un message sur 72 octets qui n’a jamais concerné votre entrée.
Toute la séquence se produit une fois par processus, au premier hash ou à la première vérification. C’est pour cela que la panne se reproduit à l’identique, quel que soit l’argument que vous passez.
À quoi ressemble la sonde
secret = (b"0123456789" * 26)[:255]
Cette constante vient du bug de bouclage du bcrypt de BSD, divulgué par Openwall en 2012, où les clés longues repartaient au début et s’effondraient en hashs plus faibles. passlib vérifie au démarrage si le backend qu’il vient de charger porte ce défaut, et refuse de faire confiance à un backend qui le porterait.
detect_wrap_bug n’est pas un bug de passlib. C’est du code défensif qui fait exactement ce pour quoi il a été écrit, avec un vecteur de test valide depuis plus d’une décennie. Ce qui a changé, c’est que bcrypt 5.0.0 traite désormais une entrée de 255 octets comme une erreur plutôt que comme quelque chose à hacher. L’auto-test qui passait est devenu un auto-test impossible à rattraper. La discussion pyca/bcrypt dans l’issue #1082 détaille la collision entre les deux bibliothèques.
Pourquoi le correctif __about__ ne règle rien
Cherchez cette erreur et on vous répétera, encore et encore, que bcrypt a supprimé __about__ et que le restaurer répare passlib. Les deux moitiés de cette affirmation sont fausses, et voici la mesure qui le montre :
| Version | hasattr(bcrypt, "__about__") | Affiche l’avertissement trapped | passlib fonctionne |
|---|---|---|---|
| bcrypt 5.0.0 | False | Oui | Non (ValueError) |
| bcrypt 4.3.0 | False | Oui | Oui |
bcrypt 4.3.0 n’a pas non plus __about__. Il affiche la même ligne (trapped) error reading bcrypt version. Et passlib tourne dessus sans broncher. L’attribut manquant n’est donc pas la ligne de partage entre ce qui marche et ce qui casse. Le changement de comportement de la ValueError en 5.0.0, si.
Ce qui signifie que le correctif populaire ne peut pas fonctionner, et de fait il ne fonctionne pas :
import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__) # avant d'importer passlib
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# toujours ValueError: password cannot be longer than 72 bytes, ...
Nous l’avons exécuté dans un processus vierge, patch appliqué avant import passlib, justement pour que personne ne puisse imputer l’échec à un ordre d’import. Il échoue quand même. Tout ce que ce correctif obtient, c’est de faire taire un avertissement inoffensif. La sonde de 255 octets de l’étape 4 est une étape distincte, qui n’a jamais consulté __about__, et elle explose dans les deux cas.
Ce que bcrypt 5.0 a réellement changé
Le changement cassant de bcrypt 5.0 tient en une ligne de comportement, avec un large rayon d’impact :
| Entrée | bcrypt 4.3.0 | bcrypt 5.0.0 |
|---|---|---|
| 72 octets | OK | OK |
| 73 octets | OK (tronqué en silence) | ValueError |
| 100 octets | OK (tronqué en silence) | ValueError |
| 255 octets | OK (tronqué en silence) | ValueError |
La troncature de la colonne 4.x n’est pas une façon de parler. Sous 4.3.0, hash(73 octets) et hash(100 octets) construits à partir du même préfixe ressortent identiques : true.
bcrypt 5.0 est donc la plus correcte des deux : jeter discrètement de la matière clé est pire que s’arrêter, et s’arrêter est ce qu’une bibliothèque de hachage doit faire quand elle ne peut pas honorer son entrée. La mise à jour n’en est pas indolore pour autant. Du code qui perdait silencieusement des octets depuis des années lève désormais une exception, et si ce chemin de code passe par passlib, il lève avant même que votre entrée n’entre en jeu.
Ce tableau ne vous concerne pas de la même manière selon que vous appelez bcrypt directement ou via passlib. Dans le premier cas, la mise à jour est visible : vous récupérez une exception à l’inscription ou à la connexion, à un endroit du code qui vous appartient, avec une trace pointant vers votre propre appel de hachage. Ajoutez une vérification de longueur en octets devant, et l’affaire est réglée en une après-midi.
Si vous passez par passlib, rien ne se voit jusqu’au moment où tout casse d’un coup. La panne n’est pas proportionnelle au nombre d’utilisateurs ayant de longs mots de passe, puisqu’elle ne dépend pas du tout de ce que saisit l’utilisateur. Chaque hachage et chaque vérification du processus échoue, dès le premier appel, sur une base de code où rien n’a bougé côté gestion des mots de passe. Voilà pourquoi cela se manifeste comme un incident de déploiement plutôt que comme un rapport de bug, et pourquoi le texte de l’erreur envoie les gens chercher exactement au mauvais endroit.
Corriger le problème
Si vous pouvez modifier le code
Abandonnez passlib et appelez bcrypt directement. La dernière version publiée de passlib est la 1.7.4 et le projet est silencieux depuis longtemps : cette couche vous apporte très peu sur un projet qui n’a besoin que de bcrypt.
import bcrypt
password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password, hashed) # True
hashpw et checkpw prennent tous deux des octets : encodez à la frontière et laissez le reste de votre code travailler en str. Vous perdez au passage la détection de backend et sa sonde d’auto-test, donc aussi le mode de défaillance qui vous parle d’un mot de passe que vous n’avez pas fourni. Si vous voulez inspecter le hash obtenu, ou vérifier celui que votre application a produit, le générateur bcrypt tourne entièrement dans votre navigateur. Les serveurs qui utilisent bcrypt pour l’authentification Basic HTTP subissent la même contrainte structurelle dans un autre format de fichier, ce que le guide htpasswd détaille pas à pas.
Si vous ne pouvez pas modifier le code aujourd’hui
Épinglez sous la version 5 :
bcrypt<5
Nous avons vérifié bcrypt 4.3.0 avec passlib 1.7.4, et cela fonctionne. C’est un garrot, pas une réparation. Vous restez sur une version dont le comportement face à un mot de passe long est de jeter des octets en silence, c’est-à-dire exactement le problème que la 5.0 est venue supprimer. Mettez une date sur cet épinglage et planifiez la migration.
Si vos utilisateurs saisissent vraiment de longues phrases secrètes
Hachez d’abord le mot de passe une fois avec SHA-256, encodez l’empreinte en base64, puis passez le résultat à bcrypt :
import base64, hashlib, bcrypt
def prehash(password: str) -> bytes:
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
hashed = bcrypt.hashpw(prehash(pw), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(prehash(pw), hashed)
La sortie fait toujours 44 octets, confortablement sous 72, quelle que soit la longueur de l’entrée. Et elle restaure la propriété que la troncature détruisait : les deux mots de passe de 82 octets du début de l’article, passés dans cette fonction, donnent checkpw(prehash(p2), hash(prehash(p1))) = False. La collision a disparu.
L’étape base64 fait un vrai travail, ne la supprimez donc pas. Une empreinte SHA-256 brute est du binaire arbitraire et peut contenir des octets NUL, que les implémentations de bcrypt gèrent de façon incohérente. base64 vous donne une chaîne ASCII sans NUL et de longueur fixe. Appliquez la même fonction à l’inscription et à la connexion, sinon tous les hashs existants cessent de se vérifier.
Ce qu’il ne faut pas faire
Le monkey patch __about__ ne fonctionne pas. La section 4 en donne la mesure. Si quelqu’un de votre équipe s’apprête à le coller, les quatre lignes ci-dessus lui feront gagner une après-midi.
Tronquer vous-même avec pw[:72] est pire que de ne rien faire. Cela reconvertit un échec bruyant en échec silencieux, et cela recrée dans votre propre code la collision de la section 2. Vous réimplémenteriez à la main le comportement exact que bcrypt 5.0 est venu éliminer, et contrairement à la version de la bibliothèque, la vôtre n’avertira jamais personne. Si vous avez besoin que les longs mots de passe fonctionnent, pré-hachez. Sinon, validez la longueur en octets et rejetez avec un message clair.
Et les hashs déjà présents dans votre base
Quelles lignes sont concernées
Seuls les comptes dont les propriétaires se sont inscrits avec un mot de passe de plus de 72 octets. Pour la plupart des produits grand public, l’ensemble est réduit, et pour tout ce qui est purement ASCII, cela se limite d’ordinaire aux amateurs de phrases secrètes. Pour les produits dont les utilisateurs saisissent du chinois, du japonais ou des emoji, la section 3 s’applique et l’ensemble concerné peut être bien plus large qu’un audit qui ne compte pas les octets ne le laisse croire.
Vous ne pouvez pas identifier ces lignes à partir des hashs. Une empreinte bcrypt est de largeur fixe et ne garde aucune trace de la longueur de son entrée. Si vous avez journalisé la longueur des mots de passe à l’inscription, ce journal est votre seul inventaire. La plupart des équipes ne l’ont pas fait, et le reconstituer après coup est impossible : prévoyez donc de travailler à l’aveugle, pas à partir d’une liste.
Vous ne pouvez pas recalculer en masse
Il n’y a pas de texte en clair à re-hacher, ce qui est précisément l’intérêt de stocker des hashs. La migration doit donc être paresseuse : mettez à niveau chaque compte à la prochaine authentification réussie de son propriétaire, pendant que vous détenez brièvement le clair en mémoire.
def login(user, password: str) -> bool:
if not verify_legacy(password, user.password_hash):
return False
if needs_rehash(user.password_hash):
user.password_hash = hash_new_scheme(password)
save(user)
return True
Vérifiez d’abord avec l’ancien schéma, et re-hachez seulement ensuite. Inverser ces deux étapes réécrit le hash stocké avant que vous n’ayez confirmé que le mot de passe était correct. Stockez un identifiant de schéma à côté de chaque hash pour que needs_rehash soit une comparaison de champ plutôt qu’une supposition, et attendez-vous à une longue traîne de comptes dormants qui ne se connecteront jamais. Ceux-là, vous les traiterez à la réinitialisation du mot de passe, pas de force.
Quand une migration complète en vaut la peine
Si vous écrivez déjà le chemin de re-hachage paresseux, c’est le moment le moins coûteux que vous aurez jamais pour changer l’algorithme en dessous. Le plafond de 72 octets n’existe pas dans Argon2id, et la comparaison entre Argon2id, bcrypt et scrypt explique quand le changement se rentabilise et quand rester sur bcrypt reste le bon choix. L’aide-mémoire OWASP sur le stockage des mots de passe est la référence à laquelle confronter vos paramètres.
Ne lancez pas une migration uniquement à cause de cette erreur. Si vos mots de passe restent confortablement sous 72 octets, bcrypt demeure un choix sain et la section 6 a déjà réglé votre problème.
FAQ
Pourquoi bcrypt dit-il que mon mot de passe dépasse 72 octets alors qu’il est court ?
Parce que le message porte sur la sonde interne de passlib, pas sur votre mot de passe. Au premier appel, passlib exécute detect_wrap_bug avec une chaîne de test fixe de 255 octets. bcrypt 5.0.0 lève une ValueError au-delà de 72 octets, la sonde échoue et l’erreur remonte jusqu’à votre appel. Un mot de passe de 14 octets suffit à la déclencher.
bcrypt ignore-t-il vraiment tout ce qui suit les 72 octets ?
Oui, bcrypt ignore complètement tout ce qui suit les 72 octets. Deux mots de passe de 82 octets partageant leurs 72 premiers octets produisent le hash identique $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, et chacun se vérifie contre le hash de l’autre. La frontière est nette : une différence à l’octet 72 change le hash, une différence à l’octet 73 non.
La limite de 72 octets est-elle un problème de sécurité ?
Oui, la limite de 72 octets de bcrypt pose problème pour les longues phrases secrètes. Quiconque connaît les 72 premiers octets peut ajouter des octets arbitraires et s’authentifier : tout octet au-delà de la limite n’apporte rien. Pour les mots de passe sous 72 octets, cela ne change strictement rien. Le pré-hachage avec SHA-256 supprime l’exposition si les longues entrées doivent compter intégralement.
Combien de caractères font 72 octets ?
72 octets font 72 lettres ASCII, mais cela dépend de l’encodage : 36 caractères cyrilliques ou à tréma, 24 sinogrammes chinois, 24 kana japonais, ou 18 emoji. bcrypt compte des octets UTF-8 et non des caractères : mesurez donc avec len(pw.encode("utf-8")) en Python ou Buffer.byteLength(pw, "utf8") en Node.
Corriger __about__ répare-t-il l’erreur passlib ?
Non, le correctif __about__ ne répare pas l’erreur passlib. Nous l’avons appliqué avant import passlib dans un processus vierge et la ValueError s’est déclenchée quand même. bcrypt 4.3.0 n’a pas non plus __about__ et fonctionne très bien avec passlib, ce qui prouve que l’attribut manquant n’est pas la cause. Le correctif ne fait que taire l’avertissement (trapped) error reading bcrypt version.
Dois-je rétrograder bcrypt en dessous de 5.0 ?
Comme mesure d’urgence, oui : bcrypt 4.3.0 avec passlib 1.7.4 fonctionne. Mais la branche 4.x tronque en silence tout ce qui dépasse 72 octets, ce qui est justement le comportement que la 5.0 est venue supprimer : traitez l’épinglage comme temporaire et migrez vers un appel direct à bcrypt.
Puis-je simplement tronquer moi-même le mot de passe à 72 octets ?
Non, ne tronquez pas vous-même un mot de passe à 72 octets. pw[:72] recrée la collision décrite plus haut à l’intérieur de votre propre code, silencieusement, sans aucun avertissement de la bibliothèque pour l’attraper. Soit vous pré-hachez avec SHA-256 et base64 pour que les longues entrées restent distinctes, soit vous validez la longueur en octets en amont et rejetez avec un message d’erreur clair.
Qu’advient-il des mots de passe déjà hachés avant ma correction ?
Les hashs bcrypt déjà stockés continuent de se vérifier, car votre chemin de vérification tronque de la même façon que le chemin de hachage. Seuls les comptes inscrits avec des mots de passe de plus de 72 octets sont affaiblis, et vous ne pouvez pas les recalculer sans le texte en clair. Re-hachez paresseusement à la prochaine connexion réussie, et traitez les comptes dormants à la réinitialisation.