Password hashing with bcrypt, content digests, HMAC, constant-time comparison, and streaming file hashes — including the 72-byte bcrypt limit that breaks passphrases.
Hashing (sillo.helpers.hashing)
Section titled “Hashing (sillo.helpers.hashing)”Hashing is one-way. You put data in, you get a fixed-size fingerprint out, and there is no supported way back. That property makes it the right tool for exactly two jobs and the wrong tool for everything else.
The two jobs are storing passwords (you need to check a password without being able to read it) and verifying integrity (you need to know whether two things are identical without comparing them byte by byte). This module covers both, plus the constant-time comparison you need to make either safe.
If you need the original value back, you do not want hashing. You want Crypto, which encrypts and decrypts.
from sillo.helpers import hashingPassword hashing needs bcrypt:
uv add bcryptEverything else is stdlib-only and always available.
The most important distinction on this page
Section titled “The most important distinction on this page”There are two families of function here and they are not interchangeable.
Password hashing (hash_password, verify_password) uses bcrypt, which is deliberately, expensively slow. A single call takes around 250 milliseconds of CPU. That is the point: it makes offline brute-forcing of a stolen database economically painful.
Digests (md5, sha1, sha256, sha512, digest) are deliberately fast. A modern CPU computes hundreds of megabytes of SHA-256 per second, and a GPU rig computes billions of hashes per second.
Password hashing
Section titled “Password hashing”hash_password(password) -> str
Section titled “hash_password(password) -> str”Hashes a password with bcrypt, generating a fresh random salt each time.
hashed = hashing.hash_password("correct-horse-battery")# "$2b$12$3HCTa1AWrcvE/..." 60 charactersThe output is a 60-character string that packs four things together: the algorithm identifier ($2b$), the cost factor ($12$), the 22-character salt, and the 31-character hash. You store that one string. You do not need a separate salt column — the salt is already in there, which is why verify_password needs only the password and the hash.
Hashing the same password twice gives two different strings, because the salt differs:
hashing.hash_password("same") != hashing.hash_password("same") # TrueThat is correct and required. It means an attacker who sees two identical hashes in your database learns nothing, and it defeats precomputed rainbow tables.
verify_password(password, hashed) -> bool
Section titled “verify_password(password, hashed) -> bool”Checks a candidate password against a stored hash. It reads the salt and cost factor out of the stored hash, recomputes, and compares in constant time.
hashing.verify_password("correct-horse-battery", hashed) # Truehashing.verify_password("wrong", hashed) # Falsehashing.verify_password("", hashed) # FalseThe full login flow:
from sillo import silloAppfrom sillo.helpers.hashing import hash_password, verify_password
app = silloApp()
@app.post("/register")async def register(request, response): body = await request.json user = await User.create( email=body["email"], password=hash_password(body["password"]), # never store the raw value ) return response.json({"id": user.id}, status_code=201)
@app.post("/login")async def login(request, response): body = await request.json user = await User.get_or_none(email=body["email"])
# Always run the same work whether or not the account exists, so response # timing does not reveal which emails are registered. stored = user.password if user else _DUMMY_HASH valid = verify_password(body["password"], stored)
if not user or not valid: return response.json({"error": "Invalid credentials"}, status_code=401) return response.json({"token": issue_token(user)})_DUMMY_HASH is a bcrypt hash of any throwaway string, computed once at import. Without it, a request for an unknown email returns in a millisecond while a request for a known email takes 250ms, and an attacker times the difference to enumerate your users.
needs_rehash(hashed, rounds=12) -> bool
Section titled “needs_rehash(hashed, rounds=12) -> bool”Reports whether a stored hash was computed with a lower cost factor than you now require. Hardware gets faster, so the cost factor that was expensive in 2015 is cheap today, and you want to raise it over time.
hashing.needs_rehash(hashed, rounds=12) # False — hashed at the current costhashing.needs_rehash(hashed, rounds=14) # True — hashed cheaper than you now wantYou cannot upgrade a hash on its own, because you do not have the password. You can only upgrade it at login, in the one moment the plaintext is briefly in memory:
if verify_password(candidate, user.password): if needs_rehash(user.password, rounds=14): user.password = hash_password(candidate) await user.save(update_fields=["password"]) return issue_token(user)Users who log in are migrated silently. Users who never log in keep the old cost, which is fine — an unused account is not an active risk, and you will never get their plaintext again.
Raising the cost factor doubles login CPU time per increment. Cost 12 is roughly 250ms; cost 14 is roughly one second. Measure on your production hardware before raising it, because login latency is user-visible and bcrypt occupies a whole CPU core for the duration.
Digests
Section titled “Digests”Five functions produce a hex-encoded digest of arbitrary data. All accept str or bytes; strings are UTF-8 encoded first.
hashing.md5("hello") # 32 hex charshashing.sha1("hello") # 40 hex charshashing.sha256("hello") # 64 hex charshashing.sha512("hello") # 128 hex chars
hashing.digest("hello") # sha256 by defaulthashing.digest("hello", algorithm="sha512") # explicitdigest dispatches by name and raises for an unknown algorithm, which makes it the right choice when the algorithm comes from configuration.
Legitimate uses of a fast digest:
- Cache keys. Hash a long or awkward key into a fixed-size one.
- Change detection. Store the digest of a document; if it changes, re-index.
- Deduplication. Two uploads with the same SHA-256 are the same file.
- ETags. See Content Negotiation.
from sillo.helpers.hashing import sha256
@app.post("/upload")async def upload(request, response): form = await request.form data = await form["file"].read() fingerprint = sha256(data)
existing = await Blob.get_or_none(digest=fingerprint) if existing: # Byte-identical file already stored; link rather than duplicate. return response.json({"id": existing.id, "deduplicated": True})
blob = await Blob.create(digest=fingerprint, size=len(data)) return response.json({"id": blob.id, "deduplicated": False}, status_code=201)HMAC and constant-time comparison
Section titled “HMAC and constant-time comparison”hmac_digest(key, data, algorithm="sha256") -> str
Section titled “hmac_digest(key, data, algorithm="sha256") -> str”A keyed hash. Anyone can compute sha256(data), but only someone holding the key can compute hmac_digest(key, data). That makes it a proof of authenticity, not just integrity.
signature = hashing.hmac_digest("shared-secret", "user=42&role=admin")# 64 hex chars, unreproducible without the secretThe standard use is verifying an inbound webhook:
from sillo.helpers.hashing import hmac_digest, constant_time_compare
@app.post("/webhooks/stripe")async def stripe_webhook(request, response): raw = await request.body # the exact bytes, before JSON parsing sent = request.headers.get("stripe-signature", "") expected = hmac_digest(WEBHOOK_SECRET, raw)
if not constant_time_compare(sent, expected): return response.json({"error": "Bad signature"}, status_code=400)
event = await request.json await handle_event(event) return response.json({"received": True})Two details in that example are not optional. Sign the raw body, not the re-serialized JSON — key order and whitespace change after a parse/dump round trip, and the signature stops matching. And compare with constant_time_compare, not ==.
constant_time_compare(a, b) -> bool
Section titled “constant_time_compare(a, b) -> bool”Compares two values in time that does not depend on where they first differ.
hashing.constant_time_compare("secret", "secret") # Truehashing.constant_time_compare("secret", "s3cret") # Falsehashing.constant_time_compare("a", "abcdef") # FalseFile hashing
Section titled “File hashing”hash_file(path, algorithm="sha256", chunk_size=65536) -> str
Section titled “hash_file(path, algorithm="sha256", chunk_size=65536) -> str”Hashes a file by streaming it in chunks, so memory use stays flat regardless of file size.
hashing.hash_file("upload.bin") # sha256hashing.hash_file("upload.bin", algorithm="md5") # md5hashing.hash_file("upload.bin", chunk_size=1048576) # 1 MiB chunksThe result is identical to hashing the whole file in memory — chunk_size affects only the memory/syscall trade-off, never the digest. A missing path raises OSError.
random_salt(length=16) -> str
Section titled “random_salt(length=16) -> str”Hex-encoded random bytes from secrets. Note that the argument is a byte count, so the returned string is twice as long:
len(hashing.random_salt()) # 32 characters (16 bytes, hex-encoded)len(hashing.random_salt(32)) # 64 charactersYou do not need this for passwords. hash_password generates and embeds its own salt. random_salt is for the cases where you are building something else that needs a nonce or a unique per-record value.
What not to do
Section titled “What not to do”Do not store passwords with a digest function. sha256, md5, and friends are too fast. Use hash_password.
Do not add your own salt to hash_password. bcrypt generates and stores one. Prepending your own achieves nothing and breaks verify_password.
Do not compare secrets with ==. Use constant_time_compare.
Do not sign parsed JSON. Sign the raw request body. Re-serializing changes key order and whitespace, and the signature stops matching.
Do not let unbounded passwords reach hash_password. Cap length at 72 bytes in validation, and do not truncate silently.
Do not call hash_file in an async handler. It blocks the event loop for every connected client. Thread it or queue it.
Do not use needs_rehash without also rehashing. Detecting a weak hash and doing nothing leaves the weak hash in place.
Do not log a hash. A bcrypt hash is not a password, but it is offline-crackable material. Treat the column as a secret.
Performance
Section titled “Performance”| Operation | Rough cost | Notes |
|---|---|---|
hash_password | ~250 ms | Cost factor 12. Deliberate. One full CPU core. |
verify_password | ~250 ms | Same cost as hashing, by design |
sha256 of 1 KB | microseconds | Effectively free |
hmac_digest of 1 KB | microseconds | Effectively free |
hash_file of 1 GB | seconds | Disk-bound; thread it |
The password functions dominate any endpoint they appear on. A login handler cannot serve more concurrent requests than you have CPU cores, because each one occupies a core for a quarter of a second. That is the correct trade, but plan capacity around it and rate-limit login endpoints so an attacker cannot use your own hashing cost as a denial-of-service lever. See Rate Limiting.
Function reference
Section titled “Function reference”| Function | Signature | Returns |
|---|---|---|
hash_password | (password: str) | 60-char bcrypt hash |
verify_password | (password: str, hashed: str) | bool, constant-time |
needs_rehash | (hashed: str, rounds: int = 12) | bool |
md5 | (data: str | bytes) | 32 hex chars |
sha1 | (data: str | bytes) | 40 hex chars |
sha256 | (data: str | bytes) | 64 hex chars |
sha512 | (data: str | bytes) | 128 hex chars |
digest | (data: str | bytes, algorithm: str = "sha256") | Hex digest; raises on unknown algorithm |
hmac_digest | (key, data, algorithm: str = "sha256") | Keyed hex digest |
constant_time_compare | (a, b) | bool |
hash_file | (path: str, algorithm: str = "sha256", chunk_size: int = 65536) | Hex digest; raises OSError |
random_salt | (length: int = 16) | Hex string, 2 × length chars |
Related
Section titled “Related”- Crypto — for values you need to read back
- Strings — token generation and masking
- JWT — signed tokens built on HMAC
- Authentication — the full auth stack
- Rate Limiting — protecting expensive login endpoints