A hash is not encryption¶
Level: 101 · for anyone starting from zero
One line: A hash has no key and no inverse. Nothing "decrypts" a SHA-256, because the input is not in there — a megabyte and an empty string both come out as the same 32 bytes.
The confusion, and why it costs money¶
"The passwords were encrypted" is what a breach notice says. What it almost always means is hashed, and the difference decides what the breach cost.
Encryption is a pair of functions, and both take a key. You can go forwards and, holding the key, backwards. Hashing is one function and takes no key. There is no going back — not "it is hard", but there is no second function to call. Section 3 of the run below is that difference stated as two API shapes, and section 5 of the shell run is the same difference visible on a command line: openssl enc refuses to run without -K, and openssl enc -d undoes it; openssl dgst takes no key and has no -d.
But the correction cuts both ways, and the second half is the one people miss:
A hash on its own hides nothing, because anybody can hash a guess.
The attacker holding your stolen digest does not reverse it. They hash 123456, compare, hash password, compare. Section 5 of the Python run does exactly that and finds the password on the sixth try, from a list of ten. That is why chapter 08 exists and why a password store needs a deliberately slow function rather than a fast one.
The three properties are three different claims¶
| Property | An attacker cannot… | Work, for an n-bit digest |
|---|---|---|
| Preimage | given a digest, find any input producing it | 2ⁿ |
| Second preimage | given one input, find a different one matching it | 2ⁿ |
| Collision | find any two inputs that match | 2^(n/2) |
That last row is the birthday bound, it is true of every hash function that has ever existed, and it is why "MD5 is broken" and "MD5 still resists preimages" are both true. Collisions in MD5 are found in seconds; nobody can invert one. Which of those matters depends entirely on what you were using it for — a collision breaks a signature or a deduplication key, and does nothing at all to a stored password digest.
It is also why a digest's length is not a strength dial. SHA-512 is not "more encrypted" than SHA-256. There is no key in either.
In Python¶
Verified output of a_hash_is_not_encryption_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. ANY INPUT, ALWAYS THE SAME SIZE OUT
sha256(b'' ) -> 32 bytes, 256 bits
sha256(b'abc' ) -> 32 bytes, 256 bits
sha256(1,000,000 bytes of 'a' ) -> 32 bytes, 256 bits
A megabyte and an empty string land in the same 32 bytes.
So the input cannot be 'in there' -- there is nowhere for it to be.
2. AND IT AGREES WITH THE PUBLISHED VECTORS
sha256(b'' ) = e3b0c44298fc1c149afbf4c8996fb924... matches the NIST vector
sha256(b'abc' ) = ba7816bf8f01cfea414140de5dae2223... matches the NIST vector
3. THERE IS NO SECOND FUNCTION
Encryption is a PAIR, and both halves take a key:
encrypt(key, message) -> ciphertext
decrypt(key, ciphertext) -> message
Hashing is one function, and it takes no key at all:
sha256(message) -> digest
There is no sha256_decrypt. Not 'it is hard'; it does not exist,
and could not: 2^256 digests cannot address every possible input.
4. ONE BIT IN, HALF THE BITS OUT
sha256(b'...fox') = 9ecb36561341d18eb65484e833efea61edc74b84cf5e6ae1...
sha256(b'...foy') = 5b6526fa3a37c7cff561005fed7da80899a431e172e01fd6...
bits that differ: 131 of 256 (51%)
One letter changed and the digest has nothing in common with the
old one. That is the avalanche property, and it is why a digest is
a fingerprint: no partial credit, no 'close'.
5. BUT A HASH ON ITS OWN HIDES NOTHING, BECAUSE ANYONE CAN HASH A GUESS
stolen from a database: 1c8bfe8f801d79745c4631d09fff36c82aa37fc4cce4fc946683d7b336b63032
The attacker cannot reverse it. They do not need to:
guess '123456' -> no
guess 'password' -> no
guess '123456789' -> no
guess 'qwerty' -> no
guess 'abc123' -> no
guess 'letmein' -> MATCH, after 6 tries
Ten tries, on a list anybody can download. A real one runs billions
per second on a GPU. 'The passwords were hashed' is not a defence;
it is the beginning of a sentence -- see chapter 08.
6. THE THREE PROPERTIES ARE THREE DIFFERENT CLAIMS
preimage : given d, find any m with hash(m) = d
second preimage : given m1, find m2 != m1 with the same hash
collision : find ANY m1 != m2 that hash alike
Collision is much easier than the other two, for every hash, always --
the birthday bound. Work needed, roughly:
md5 128-bit digest: preimage 2^128, collision 2^64
sha1 160-bit digest: preimage 2^160, collision 2^80
sha256 256-bit digest: preimage 2^256, collision 2^128
That is why MD5 is 'broken' (collisions are found in seconds) and
still resists preimages -- both true, and not a contradiction.
Two notes on how this program is built, because they are house rules worth seeing once.
The digests are checked against published vectors, in the program. sha256("abc") has one right answer that NIST published and every implementation on earth agrees with, so section 2 asserts it rather than recording whatever this machine said. An answer key holding a value nobody else has agreed on is recording an accident.
Section 4 counts the changed bits rather than claiming avalanche. One letter changed; 131 of the 256 output bits moved — close to the half you would get from an unrelated random value, which is the property, stated as a measurement.
In the terminal¶
Verified output of a_hash_is_not_encryption_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
1. THE PUBLISHED TEST VECTOR, FROM THE COMMAND LINE
$ printf 'abc' | openssl dgst -sha256 -r
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad *stdin
ba7816bf...20015ad is NIST's published SHA-256 of 'abc'.
Your machine, any machine, any implementation: the same 32 bytes.
2. ANY SIZE IN, ONE SIZE OUT
$ printf '' | openssl dgst -sha256 -binary | wc -c | tr -d ' '
32
$ head -c 1000000 /dev/zero | openssl dgst -sha256 -binary | wc -c | tr -d ' '
32
Nothing, and a megabyte, both produce 32 bytes.
3. THE DIGEST SIZE IS THE ALGORITHM'S, NOT THE INPUT'S
$ printf 'abc' | openssl dgst -md5 -r
900150983cd24fb0d6963f7d28e17f72 *stdin
$ printf 'abc' | openssl dgst -sha1 -r
a9993e364706816aba3e25717850c26c9cd0d89d *stdin
$ printf 'abc' | openssl dgst -sha512 -r
ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f *stdin
16, 20 and 64 bytes. Longer is not 'more encrypted' -- there is no key
in any of them. It is how much room a collision has to hide in.
4. ONE BYTE CHANGED, NOTHING IN COMMON
$ printf 'the quick brown fox' | openssl dgst -sha256 -r
9ecb36561341d18eb65484e833efea61edc74b84cf5e6ae1b81c63533e25fc8f *stdin
$ printf 'the quick brown foy' | openssl dgst -sha256 -r
5b6526fa3a37c7cff561005fed7da80899a431e172e01fd6ebc3c10975414198 *stdin
5. NOW LOOK AT WHAT ENCRYPTION'S COMMAND LINE NEEDS AND HASHING'S DOES NOT
$ printf 'attack at dawn' | openssl enc -aes-256-cbc -K 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff -iv 000102030405060708090a0b0c0d0e0f | xxd -p
62aee7f7741010e5b35a0b1383255fa2
-K is a key and -iv is an initialisation vector. Take either away and
the command refuses. Then run it backwards:
$ printf 'attack at dawn' | openssl enc -aes-256-cbc -K 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff -iv 000102030405060708090a0b0c0d0e0f | openssl enc -d -aes-256-cbc -K 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff -iv 000102030405060708090a0b0c0d0e0f
attack at dawn
That round trip is what a cipher IS: -d undoes it, given the key.
There is no -d for dgst. Not missing -- there is nothing for it to do.
Every openssl line above prints identically under LibreSSL and OpenSSL 3, which took some care and is worth knowing about before you write your own: a bare openssl dgst -sha256 prints a naked hex string on macOS's LibreSSL and SHA2-256(stdin)= … on Linux's OpenSSL 3. Same digest, different line — the nastiest shape a platform split can take, because a red CI line then reads as though the cryptography disagreed. -r is the spelling both agree on. CONTRIBUTING.md has the measured table.
What a hash is actually for¶
Nothing on this page says hashes are weak — they are one of the most solid things in the field. It says they answer a different question. A hash is a fingerprint, and the jobs it is right for all have the shape "are these two things the same?":
- Integrity — this file is the file I published (as long as the digest reached you by a channel the attacker did not control; if they can change both, a hash proves nothing, which is chapter 06).
- Identity — git names every object by its digest, so a commit id is a claim about content.
- Deduplication and indexing — equal digest, equal content, near enough.
- Inside a construction that adds a key — HMAC, or a KDF. This is where the hash stops being naked and starts being able to keep a secret.
If you are coming from Python or ABAP¶
Python. hashlib is stdlib and needs no dependency: hashlib.sha256(b"abc").hexdigest(). Two things people trip on — it takes bytes, not str, and that is deliberate (a hash of "café" is a hash of an encoding decision you have to make on purpose; the sibling library ↗ is about exactly that). And hashlib.pbkdf2_hmac and hashlib.scrypt are in the same module, so "we had no dependency available" is never a reason to have stored a bare digest.
ABAP. cl_abap_message_digest computes the digests (SHA256, and MD5/SHA1 which are still there and should not be chosen for anything new); cl_abap_hmac is the keyed one, and is what you want the moment the question is "did somebody change this" rather than "are these the same". Both take an xstring, which makes the encoding decision explicit in a way the character API does not — convert with cl_abap_codepage=>convert_to( ) and be deliberate about which code page. (Not machine-checked — CI cannot run ABAP; check the class and method names against your own system before relying on them.)
Try it¶
cd 04_Hashing/a_hash_is_not_encryption/examples
python3 a_hash_is_not_encryption_py.py
bash a_hash_is_not_encryption_sh.sh
- Add your own password to
GUESSESin the Python example and watch how fast the loop gets there. Then consider that a GPU does that list at billions per second. - Hash the same string in two encodings —
"café".encode("utf-8")and.encode("latin-1")— and confirm the digests are unrelated. Which one did your database store? - Without the machine: a 256-bit digest has 2²⁵⁶ possible values, and there are far more possible inputs than that. So collisions must exist. Why is that not a problem, and what would have to change for it to become one?
See also¶
- 04_Hashing — the chapter
- Hashing a password — the correction to section 5, in full
- HMAC, and comparing in constant time — what happens when you add a key to a hash
- Encode and decode are verbs ↗ — why
hashlibinsists on bytes