bcrypt-fout “72 bytes”: waarom ook korte wachtwoorden falen
Twee verschillende problemen leveren dezelfde melding op, en maar één ervan heeft iets met je wachtwoord te maken.
Zit je wachtwoord echt boven de bcrypt 72 bytes-limiet, dan leest bcrypt de eerste 72 bytes en gooit de rest weg. We hebben onder een vaste salt voor twee wachtwoorden van 82 bytes met dezelfde eerste 72 bytes een hash berekend. Beide leverden $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S op, en bcrypt.compareSync(p2, hash(p1)) gaf true terug. Het tweede wachtwoord logt in op het account van het eerste.
Is je wachtwoord overduidelijk kort en krijg je dit toch:
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
(“wachtwoord mag niet langer zijn dan 72 bytes”), dan klopt de melding niet over de oorzaak. Onder passlib 1.7.4 met bcrypt 5.0.0 lokt een wachtwoord van 14 bytes hem al uit.
De boosdoener is daar een vaste zelftest-probe van 255 bytes binnen passlib. Die draait één keer, bij het initialiseren van de backend, nog voordat je wachtwoord de aanroep bereikt die de hash berekent. bcrypt 5.0.0 weigert de probe, de exception ontsnapt, en jij leest een klacht over een wachtwoord dat niemand heeft ingetypt.
Eén ding meteen rechtzetten: de __about__-monkeypatch die de zoekresultaten voor deze fout domineert, lost het niet op. We hebben hem opnieuw uitgevoerd in een schoon proces, met de patch toegepast vóór import passlib, en de ValueError kwam ongewijzigd terug.
Triage in 30 seconden: welk geval ben jij
| Jouw wachtwoord | Wanneer de fout optreedt | Grondoorzaak | Ga naar |
|---|---|---|---|
| Langer dan 72 bytes | Bij de aanroep die de hash berekent | Werkelijk te lang. bcrypt 5.0 werpt een fout, bcrypt 4.x kapt stilletjes af | Sectie 2 en 3 |
| Onder 72 bytes, met passlib | Bij de eerste aanroep in het proces | De 255-byte probe van passlib. Heeft niets met je wachtwoord te maken | Sectie 4 |
| Bevat Chinees, Japans of emoji | Lijkt kort, is het niet | Tekens zijn geen bytes | Sectie 3 |
| Ging pas falen na een dependency-upgrade | Na de deploy | De breaking change van bcrypt 5.0 | Sectie 4 en 5 |
Zit je in rij 2, sla dan door. Niets in de volgende twee secties helpt je, en de oplossing is een andere.
Wat de 72 bytes-limiet van bcrypt met je wachtwoord doet
Waarom bcrypt stopt bij 72
bcrypt is gebouwd op Blowfish en voert je wachtwoord in als de Blowfish-sleutel. Blowfish breidt zijn sleutel uit tot een P-array van 18 subsleutels, elk 32 bits breed. Dat is 18 × 4 = 72 bytes aan sleutelmateriaal, en zodra alle 18 posities gevuld zijn, springt de uitbreidingslus terug naar het begin van de sleutel.
Het plafond is dus structureel. Het is geen luie implementatie en geen instelbare buffer die iemand vergeten is te verhogen. Elke bcrypt-implementatie die zich aan de specificatie houdt, kent op elk platform dezelfde limiet, en daarom kom je het getal 72 tegen in Python, Node, Go, Java en PHP.
Twee verschillende wachtwoorden, één hash
bcrypt password truncation is een beveiligingsprobleem en geen ongemak rond lengte. Met bcryptjs 3.0.3 en de vaste salt $2a$10$abcdefghijklmnopqrstuv hebben we voor twee wachtwoorden van elk 82 bytes een hash berekend:
| Wachtwoord | Waarde | Bytes |
|---|---|---|
| p1 | "A"×72 + "XXXXXXXXXX" | 82 |
| p2 | "A"×72 + "ZZZZZZZZZZ" | 82 |
Beide leverden dezelfde digest op:
$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S
Twee verschillende wachtwoorden, dezelfde hash. Daarmee slaagt ook de vergelijking:
bcrypt.compareSync(p2, hash(p1)) // true
Een aanvaller die de eerste 72 bytes van een lange passphrase kent, kan er van alles achter plakken en zich alsnog authenticeren. Elke byte voorbij de grens draagt precies nul bij aan de sterkte van de opgeslagen hash, hoe zorgvuldig je gebruikers die bytes ook hebben gekozen. Wil je een hash die je al hebt vergelijken met een kandidaat-wachtwoord zonder daar een script voor op te tuigen, dan kun je bcrypt-hashes in je browser maken en verifiëren en hetzelfde gedrag zelf bekijken.
Waar de grens precies valt
We hebben het afkappunt byte voor byte ingesloten, met een gedeelde prefix en precies één afwijkende byte daarna:
| Identieke prefix-bytes | Byte N+1 verschilt bij | Zelfde hash? |
|---|---|---|
| 70 | 71 | false |
| 71 | 72 | false |
| 72 | 73 | true |
| 73 | 74 | true |
Byte 72 telt nog mee. Byte 73 is de eerste die dat niet doet. Er is geen geleidelijke overgang en geen gedeeltelijke menging. Wil je het lokaal bevestigen, dan kost zo’n test tegen je eigen bibliotheek een paar minuten.
Tekens zijn geen bytes
bcrypt telt UTF-8-bytes, en je gebruikers typen tekens. Voor ASCII vallen die twee getallen toevallig samen, en precies daarom bijt dit teams op het moment dat ze buiten een Engelstalige markt uitrollen.
| Soort teken | Voorbeeld | Bytes per teken | 72 bytes komt neer op |
|---|---|---|---|
| ASCII Latijnse letters | A | 1 | 72 tekens |
| Chinese Han-tekens | 密 | 3 | 24 tekens |
| Japanse kana | あ | 3 | 24 tekens |
| Emoji | 🔒 | 4 | 18 tekens |
| Cyrillisch | я | 2 | 36 tekens |
| Duitse umlauten | ü | 2 | 36 tekens |
We hebben beide uitersten bevestigd: bij een Chinees wachtwoord worden verschillen na het 24e teken genegeerd (true), en bij een emoji-wachtwoord worden verschillen na het 18e genegeerd (true).
Een Chinese passphrase van 25 tekens ziet er royaal uit in een wachtwoordveld. Toch is de grens al overschreden. Een gebruiker die 20 emoji kiest, zit al twee tekens over de limiet en hoort dat nooit.
Bytelengte meten in je eigen code
Lengtecontroles die op het aantal tekens zijn geschreven, gaan door terwijl de onderliggende waarde al te lang is. Meet bytes:
# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))
In browsers zonder Buffer geeft new TextEncoder().encode(pw).length hetzelfde getal. Zet deze controle vóór de aanroep die de hash berekent en geef een echte validatiemelding terug, in plaats van de bibliotheek om drie uur ‘s nachts te laten beslissen. Kijk je meteen ook opnieuw naar je beleid voor minimale lengte, dan behandelt hoe wachtwoordsterkte werkelijk wordt gemeten wat een lengteregel je oplevert en wat niet.
Waarom ook korte wachtwoorden falen: de 255-byte probe van passlib
Je wachtwoord is veertien tekens lang, en de bibliotheek houdt vol dat het boven de 72 bytes zit. Dit is het geval waarmee de meeste mensen bij een zoekmachine belanden.
Zo reproduceer je het
Drie regels, op Python 3.14.5 met bcrypt 5.0.0 en passlib 1.7.4:
from passlib.hash import bcrypt
bcrypt.hash("short-password") # 14 bytes
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Veertien bytes erin, een klacht over 72 bytes eruit. De passlib bcrypt error is echt, maar het getal erin beschrijft iets heel anders.
De volledige call stack
Onderstaande volgorde is getraceerd door passlib 1.7.4 heen:
- De eerste aanroep zet de initialisatie van de backend in gang:
_calc_checksum→_stub_requires_backend()→set_backend(). _load_backend_mixinleestbcrypt.__about__.__version__. Dat attribuut bestaat niet, dus volgt er eenAttributeError. passlib slikt die door en print(trapped) error reading bcrypt version.- De initialisatie loopt door naar
_finalize_backend_mixin(passlib/handlers/bcrypt.py:421), datdetect_wrap_bug(IDENT_2A)aanroept. detect_wrap_bug(zelfde bestand,:378) verifieert een vaste probe van 255 bytes.- bcrypt 5.0.0 werpt een
ValueErrorbij alles boven de 72 bytes, dus blaast de probe zichzelf op. - De exception borrelt door naar jouw aanroeppunt. Je ziet een melding over 72 bytes die nooit over jouw invoer ging.
De hele reeks gebeurt één keer per proces, bij de eerste hash of verificatie. Daarom is de storing zo goed reproduceerbaar en maakt het niet uit wat je meegeeft.
Hoe de probe eruitziet
secret = (b"0123456789" * 26)[:255]
Die constante komt uit de wraparound-bug in bcrypt van BSD die Openwall in 2012 openbaar maakte, waarbij lange sleutels rondliepen en tot zwakkere hashes inklapten. passlib controleert bij het opstarten of de zojuist geladen backend dat gebrek vertoont, en weigert een backend te vertrouwen die dat doet.
detect_wrap_bug is geen bug in passlib. Het is defensieve code die exact doet waarvoor ze geschreven is, met een testvector die al meer dan tien jaar geldig is. Wat er veranderd is: bcrypt 5.0.0 behandelt invoer van 255 bytes nu als een fout in plaats van als iets om een hash van te berekenen. Een zelftest die vroeger slaagde, levert daardoor een fout op die niemand opvangt. De discussie in pyca/bcrypt issue #1082 behandelt de botsing tussen beide bibliotheken.
Waarom de __about__-patch het niet oplost
Zoek op deze fout en je krijgt keer op keer te horen dat bcrypt __about__ heeft verwijderd en dat passlib weer werkt zodra je het terugzet. Beide helften daarvan kloppen niet. De meting:
| Versie | hasattr(bcrypt, "__about__") | Print trapped-waarschuwing | passlib werkt |
|---|---|---|---|
| bcrypt 5.0.0 | False | Ja | Nee (ValueError) |
| bcrypt 4.3.0 | False | Ja | Ja |
bcrypt 4.3.0 heeft evenmin een __about__. Het print dezelfde regel (trapped) error reading bcrypt version. En passlib draait erop zonder morren. Het ontbrekende attribuut is dus niet de scheidslijn tussen werkend en kapot. De gewijzigde ValueError-afhandeling in 5.0.0 wel.
Daarmee kán de populaire patch niet werken, en dat doet hij ook niet:
import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__) # vóór het importeren van passlib
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# nog steeds ValueError: password cannot be longer than 72 bytes, ...
We hebben dit in een schoon proces uitgevoerd, met de patch toegepast vóór import passlib, juist zodat niemand de storing aan de importvolgorde kan toeschrijven. Het faalt nog steeds. Het enige wat de patch bereikt, is het dempen van een onschuldige waarschuwing. De 255-byte probe in stap 4 is een losse fase die __about__ sowieso nooit heeft geraadpleegd, en die ontploft hoe dan ook.
Wat bcrypt 5.0 werkelijk veranderde
De bcrypt 5.0 breaking change is één regel gedrag met een grote schadestraal:
| Invoer | bcrypt 4.3.0 | bcrypt 5.0.0 |
|---|---|---|
| 72 bytes | OK | OK |
| 73 bytes | OK (stilletjes afgekapt) | ValueError |
| 100 bytes | OK (stilletjes afgekapt) | ValueError |
| 255 bytes | OK (stilletjes afgekapt) | ValueError |
Het afkappen in de 4.x-kolom is geen stijlfiguur. Onder 4.3.0 komen hash(73 bytes) en hash(100 bytes) opgebouwd uit dezelfde prefix gelijk uit: true.
bcrypt 5.0 is hier dus de correctere bibliotheek. Sleutelmateriaal stilletjes weggooien is erger dan de aanroep weigeren, en weigeren is precies wat een hash-bibliotheek hoort te doen als ze de meegegeven invoer niet kan honoreren. Dat maakt de upgrade nog niet pijnloos. Code die jarenlang ongemerkt bytes verloor, werpt nu een fout, en zit dat codepad achter passlib, dan werpt het die fout voordat jouw invoer überhaupt in beeld komt.
De tabel pakt voor twee soorten teams heel anders uit. Roep je bcrypt rechtstreeks aan, dan is de upgrade zichtbaar: je krijgt een exception bij registratie of inloggen, op een plek in de code die van jou is, met een stack trace die naar je eigen aanroep wijst. Zet er een controle op bytelengte voor en je bent in een middag klaar.
Ga je via passlib, dan is de upgrade onzichtbaar tot hij totaal is. De storing is niet evenredig aan het aantal gebruikers met lange wachtwoorden, want ze hangt helemaal niet van gebruikersinvoer af. Elke hash en elke verificatie in het proces faalt, vanaf de eerste aanroep, in een codebase waarin niets rond wachtwoordopslag is gewijzigd. Daarom duikt dit op als een deploy-incident in plaats van als een bugmelding, en daarom stuurt de fouttekst mensen precies de verkeerde kant op.
Zo los je het op
Als je de code kunt aanpassen
Laat passlib vallen en roep bcrypt rechtstreeks aan. De laatste release van passlib was 1.7.4 en het project is al lang stil, dus die laag levert je weinig op in een project dat alleen bcrypt nodig heeft:
import bcrypt
password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password, hashed) # True
hashpw en checkpw nemen allebei bytes aan, dus encodeer op de grens en laat de rest van je code met str werken. Er is geen detectie van backends en geen zelftest-probe die klaagt over een wachtwoord dat je nooit hebt aangeleverd. Wil je de resulterende hash met eigen ogen bekijken of er een verifiëren die je app heeft geproduceerd, dan draait de bcrypt-generator volledig in je browser. Servers die bcrypt voor HTTP Basic Auth gebruiken, hebben dezelfde structurele beperking in een ander bestandsformaat, en de htpasswd-gids loopt daar doorheen.
Als je de code vandaag niet kunt aanpassen
Pin onder 5:
bcrypt<5
We hebben bcrypt 4.3.0 met passlib 1.7.4 geverifieerd en het werkt. Wees wel duidelijk over wat je zojuist koopt. Dit is een tourniquet, geen reparatie. Je blijft op een versie waarvan het gedrag bij een bcrypt long password neerkomt op het stilletjes weggooien van bytes, precies het probleem dat 5.0 moest stoppen. Zet een datum op de pin en plan de overstap.
Als je gebruikers echt lange passphrases typen
Bereken eerst één keer een SHA-256-hash van het wachtwoord, encodeer de digest naar base64 en geef dat aan bcrypt mee:
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)
De uitvoer is altijd 44 bytes, ruim onder 72, ongeacht de lengte van de invoer. En het herstelt de eigenschap die het afkappen kapotmaakte: de twee wachtwoorden van 82 bytes uit de opening geven hierdoorheen checkpw(prehash(p2), hash(prehash(p1))) = False. De collision is weg.
De base64-stap doet echt werk, dus laat hem niet weg. Een rauwe SHA-256-digest is willekeurige binaire data en kan NUL-bytes bevatten, die bcrypt-implementaties inconsistent afhandelen. base64 geeft je een NUL-vrije ASCII-string van vaste lengte. Pas dezelfde functie toe bij registratie én bij inloggen, anders verifieert geen enkele bestaande hash nog.
Wat je niet moet doen
De __about__-monkeypatch werkt niet. Sectie 4 bevat de meting. Staat iemand in je team op het punt hem te plakken, dan besparen de vier regels hierboven diegene een middag.
Zelf afkappen met pw[:72] is erger dan niets doen. Het zet een luide storing terug om in een stille, en het maakt de collision uit sectie 2 opnieuw, in je eigen code. Je bouwt met de hand precies het gedrag na dat bcrypt 5.0 moest uitroeien, en anders dan bij de bibliotheekversie waarschuwt jouw variant nooit iemand. Moeten lange wachtwoorden werken, bereken dan vooraf een hash. Zo niet, valideer dan de bytelengte en weiger met een duidelijke melding.
En de hashes die al in je database staan
Welke rijen zijn geraakt
Alleen accounts waarvan de eigenaar zich met een wachtwoord van boven de 72 bytes registreerde. Voor de meeste consumentenproducten is dat een kleine groep, en bij alles wat alleen ASCII kent, gaat het meestal om liefhebbers van passphrases. Bij producten met gebruikers die Chinees, Japans of emoji typen, geldt sectie 3 en kan de geraakte groep veel groter zijn dan een byteblinde audit doet vermoeden.
Je kunt die rijen niet aan de hashes herkennen. Een bcrypt-digest heeft een vaste breedte en houdt niets bij over de lengte van de invoer. Heb je bij registratie de wachtwoordlengte gelogd, dan is dat log je enige inventaris. De meeste teams hebben dat niet gedaan, en achteraf reconstrueren kan niet, dus plan rond het niet-weten in plaats van rond een lijst.
Bulk herberekenen kan niet
Er is geen platte tekst om opnieuw een hash van te berekenen, en dat is precies het punt van hashes opslaan. De migratie moet dus lui verlopen: werk elk account bij zodra de eigenaar de volgende keer succesvol authenticeert, terwijl je de platte tekst even in het geheugen hebt.
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
Verifieer eerst met het oude schema, en bereken pas daarna opnieuw een hash. Draai je die twee stappen om, dan overschrijf je de opgeslagen hash voordat je hebt bevestigd dat het wachtwoord klopte. Sla naast elke hash een schema-aanduiding op, zodat needs_rehash een veldvergelijking is en geen gok, en reken op een lange staart slapende accounts die nooit inloggen. Die pak je bij het opnieuw instellen van het wachtwoord, niet met dwang.
Wanneer een volledige migratie loont
Schrijf je toch al het pad voor het luie opnieuw berekenen, dan is dat het goedkoopste moment dat je ooit krijgt om het onderliggende algoritme te wisselen. Het plafond van 72 bytes bestaat niet in Argon2id, en de vergelijking van Argon2id en bcrypt behandelt wanneer de overstap zichzelf terugverdient en wanneer bij bcrypt blijven de juiste keuze is. De OWASP Password Storage Cheat Sheet is de referentie om je parameters tegen af te zetten.
Begin geen migratie alleen vanwege deze fout. Zitten je wachtwoorden ruim onder 72 bytes, dan blijft bcrypt een degelijke keuze en heeft sectie 6 je probleem al opgelost.
FAQ
Waarom zegt bcrypt dat mijn wachtwoord langer is dan 72 bytes terwijl het kort is?
Omdat de melding over de interne probe van passlib gaat, niet over je wachtwoord. Bij de eerste aanroep draait passlib detect_wrap_bug met een vaste teststring van 255 bytes. bcrypt 5.0.0 werpt een ValueError bij alles boven de 72 bytes, dus faalt de probe en komt de fout bij jouw aanroeppunt boven. Een wachtwoord van 14 bytes lokt het al uit.
Negeert bcrypt echt alles na 72 bytes?
Ja — bcrypt negeert elke byte na 72, volledig. Twee wachtwoorden van 82 bytes met dezelfde eerste 72 bytes leveren de identieke hash $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S op, en elk verifieert tegen de hash van de ander. De grens is exact: een verschil op byte 72 verandert de hash, een verschil op byte 73 niet.
Is de limiet van 72 bytes een beveiligingsprobleem?
Ja, voor lange passphrases is de 72 bytes-limiet van bcrypt een beveiligingsprobleem. Wie de eerste 72 bytes kent, kan er willekeurige bytes achter plakken en zich authenticeren, dus voegt elke byte voorbij de limiet niets toe. Voor wachtwoorden onder 72 bytes verandert het helemaal niets. Vooraf een SHA-256-hash berekenen haalt de blootstelling weg als lange invoer volledig moet meetellen.
Hoeveel tekens zijn 72 bytes?
Hoeveel tekens 72 bytes zijn, hangt af van de codering. 72 ASCII-letters, 36 Cyrillische of umlauttekens, 24 Chinese Han-tekens, 24 Japanse kana, of 18 emoji. bcrypt telt UTF-8-bytes in plaats van tekens, dus meet met len(pw.encode("utf-8")) in Python of Buffer.byteLength(pw, "utf8") in Node.
Lost een patch op __about__ de passlib-fout op?
Nee, de __about__-patch lost de passlib-fout niet op. We hebben de patch in een schoon proces vóór import passlib toegepast en de ValueError kwam alsnog. bcrypt 4.3.0 mist __about__ eveneens en werkt prima met passlib, wat bewijst dat het ontbrekende attribuut niet de oorzaak is. De patch dempt alleen de waarschuwing (trapped) error reading bcrypt version.
Moet ik bcrypt downgraden naar onder 5.0?
Als tijdelijke noodgreep kun je bcrypt naar onder 5.0 downgraden. bcrypt 4.3.0 met passlib 1.7.4 werkt. Maar 4.x kapt alles voorbij 72 bytes stilletjes af, en dat is nu juist het gedrag dat 5.0 moest stoppen, dus behandel de pin als tijdelijk en stap over op bcrypt rechtstreeks aanroepen.
Kan ik het wachtwoord niet gewoon zelf afkappen tot 72 bytes?
Doe dat niet: kap het wachtwoord niet zelf af tot 72 bytes. pw[:72] maakt de hierboven beschreven collision opnieuw binnen je eigen code, stilletjes, zonder waarschuwing van een bibliotheek om op te vangen. Bereken vooraf een SHA-256-hash met base64 zodat lange invoer onderscheidend blijft, of valideer de bytelengte vooraf en weiger met een duidelijke foutmelding.
Wat gebeurt er met wachtwoorden waarvan de hash al berekend was voordat ik dit fixte?
Bestaande bcrypt-hashes blijven verifiëren, omdat je verificatiepad op dezelfde manier afkapt als het pad dat de hash berekende. Alleen accounts die met wachtwoorden van meer dan 72 bytes zijn geregistreerd, zijn verzwakt, en die kun je zonder platte tekst niet herberekenen. Bereken lui opnieuw een hash bij de volgende geslaagde login, en pak slapende accounts bij het opnieuw instellen van het wachtwoord.