Skip to content
Terug naar blog
Beveiliging

bcrypt-fout “72 bytes”: waarom ook korte wachtwoorden falen

bcrypt 5.0 werpt "password cannot be longer than 72 bytes" bij een wachtwoord van 14 bytes. Boosdoener: de 255-byte probe van passlib. Gratis bcrypt-tool.

12 min leestijd

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 wachtwoordWanneer de fout optreedtGrondoorzaakGa naar
Langer dan 72 bytesBij de aanroep die de hash berekentWerkelijk te lang. bcrypt 5.0 werpt een fout, bcrypt 4.x kapt stilletjes afSectie 2 en 3
Onder 72 bytes, met passlibBij de eerste aanroep in het procesDe 255-byte probe van passlib. Heeft niets met je wachtwoord te makenSectie 4
Bevat Chinees, Japans of emojiLijkt kort, is het nietTekens zijn geen bytesSectie 3
Ging pas falen na een dependency-upgradeNa de deployDe breaking change van bcrypt 5.0Sectie 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:

WachtwoordWaardeBytes
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-bytesByte N+1 verschilt bijZelfde hash?
7071false
7172false
7273true
7374true

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 tekenVoorbeeldBytes per teken72 bytes komt neer op
ASCII Latijnse lettersA172 tekens
Chinese Han-tekens324 tekens
Japanse kana324 tekens
Emoji🔒418 tekens
Cyrillischя236 tekens
Duitse umlautenü236 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:

  1. De eerste aanroep zet de initialisatie van de backend in gang: _calc_checksum_stub_requires_backend()set_backend().
  2. _load_backend_mixin leest bcrypt.__about__.__version__. Dat attribuut bestaat niet, dus volgt er een AttributeError. passlib slikt die door en print (trapped) error reading bcrypt version.
  3. De initialisatie loopt door naar _finalize_backend_mixin (passlib/handlers/bcrypt.py:421), dat detect_wrap_bug(IDENT_2A) aanroept.
  4. detect_wrap_bug (zelfde bestand, :378) verifieert een vaste probe van 255 bytes.
  5. bcrypt 5.0.0 werpt een ValueError bij alles boven de 72 bytes, dus blaast de probe zichzelf op.
  6. 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:

Versiehasattr(bcrypt, "__about__")Print trapped-waarschuwingpasslib werkt
bcrypt 5.0.0FalseJaNee (ValueError)
bcrypt 4.3.0FalseJaJa

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:

Invoerbcrypt 4.3.0bcrypt 5.0.0
72 bytesOKOK
73 bytesOK (stilletjes afgekapt)ValueError
100 bytesOK (stilletjes afgekapt)ValueError
255 bytesOK (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.

Tags: bcrypt password-hashing passlib python debugging security

Gerelateerde artikelen

Alle artikelen bekijken