Skip to content

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 GUESSES in 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