Don't we all remember the following common setup from our introductory security course? Bob wants to send a secret message to Alice. In order to obtain a key for encrypting the message, Alice and Bob first use Diffie-Hellman (DH) to exchange a fresh session key. With this fresh session key, Bob symmetrically encrypts the message and sends it to Alice. Carol volunteers to transmit the messages between Bob and Alice. Here is the setup:
Alice Carol Bob
| | |
| DH values from Alice | |
|------------------------------->| DH values from Alice |
| |------------------------------->|
| | compute session key
| | |
| | DH values from Bob |
| DH values from Bob |<-------------------------------|
|<-------------------------------| |
compute session key | encrypt message
| | with session key
| | |
| | encrypted message |
| encrypted message |<-------------------------------|
|<-------------------------------| |
decrypt message | |
with session key
One of the first things we learn in our introductory security course is that Carol could Man-in-the-Middle (MitM) the DH exchange to obtain session keys with Alice and Bob herself, while poor Alice and poor Bob still believe they are talking privately with each other. The next thing an introductory security course teaches us is how to prevent this attack. And here is how this article differs from an introductory security course: Bob has the misconception that he can use encryption to prevent unauthorized modification. As the title suggests, this does not work out well for Bob. Neighbors, don't act like Bob.
Let us hear the story of Alice, Bob, and Carol. Bob will make five different attempts to transmit the encrypted message to Alice. He will try to use RSA encryption to prevent a MitM attack. The protocol aborts prematurely if Carol could break the key before Bob has sent the message.
I hear our quality-conscious readers ask "Story?", surely followed by "PoC or GTFO!". Esteemed reader, don't worry, the text you are reading right now was generated by \texttt{poc.py}. You can follow along by \texttt{git clone https://github.com/diekmann/encryption-is-not-integrity.git}.
"Couldn't Bob just use TLS?", you might ask. For sure! A TLS handshake would authenticate the DH values and everything would be fine. But using a ready-made TLS implementation would also be boring. Furthermore, the handshake sketched above is not TLS. In the course of this story, Bob will use parts of the OpenSSL library to do parts of the DH handshake for him. Will this help? Let the story begin.
Alice and Carol are just returning from their introductory security course. Bob, who also attended the lecture, walks over to Alice. "If a message is encrypted, an attacker cannot read it and thus cannot modify it," Bob says to Alice. Alice knows that encryption does not provide integrity and immediately wants to call bullshit on Bob's claim. But she hesitates for a moment. Bob won't appreciate an abstract explanation anyway. "Let's see where this is going," she thinks and agrees to follow his explanation. "I hope there will be code?" Alice responds. Bob nods.
"Carol, come over, Bob is explaining crypto," Alice shouts to Carol.
Bob starts explaining, "Let's first create a fresh session key so I can send a secret message to you, Alice."
Alice agrees, this sounds like a good idea.
To make the scenario realistic, Alice makes sure that neither Bob nor Carol can see her screen.
She opens her python3 shell and is about to generate some DH values.
"We need a large prime
FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1
29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD
EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245
E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED
EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D
C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F
83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D
670C354E 4ABC9804 F1746C08 CA237327 FFFFFFFF FFFFFFFF
This is a 1536-bit prime.
Alice notes fascinated, "this prime has
>>> import decimal
Alice has reproduced the calculation.
By the way, the generator
A small refresher on DH follows. Note that the RFC uses `^' for exponentiation.
=== BEGIN SNIPPET RFC 2631 ===
2.1.1. Generation of ZZ
[...] the shared secret ZZ is generated as follows:
ZZ = g ^ (xb * xa) mod p
Note that the individual parties actually perform the computations:
ZZ = (yb ^ xa) mod p = (ya ^ xb) mod p
where ^ denotes exponentiation
ya is party a's public key; ya = g ^ xa mod p
yb is party b's public key; yb = g ^ xb mod p
xa is party a's private key
xb is party b's private key
p is a large prime
=== END SNIPPET RFC 2631 ===
Alice takes the initiative, "Okay, I generate a secret value
This is what Alice and Bob plan to do:
Alice Carol Bob
xa = random() | |
ya = pow(g, xa, p) | xb = random()
| | yb = pow(g, xb, p)
| (ya, g, p) | |
|------------------------------->| (ya, g, p) |
| |------------------------------->|
| | ZZ_b = pow(ya, xb, p)
| | |
| | yb |
| yb |<-------------------------------|
|<-------------------------------| |
ZZ_a = pow(yb, xa, p) | ciphertext =
| | Enc(ZZ_b, message)
| | ciphertext |
| ciphertext |<-------------------------------|
|<-------------------------------| |
Dec(ZZ_a, ciphertext)
= message
"Let's go then," Bob says. "Wait," Alice intervenes, "DH is only secure against passive attackers. An active attacker could MitM our exchange." Alice and Bob look at Carol, she smiles. Alice continues, "What did you say in the beginning?" "Right," Bob says, "we must encrypt our DH values, so Carol cannot MitM us." Fortunately, Alice and Bob have 4096-bit RSA keys and have securely distributed their public keys beforehand.
"Okay, what should I do?" Alice asks. She knows exactly what to do, but Bob's stackoverflow-driven approach to crypto may prove useful in the course of this story. Bob types into Alice's terminal:
>>> import Crypto.PublicKey.RSA
>>> def RSA_enc(k_pub, msg):
... return k_pub.encrypt(msg, None)[0]
He comments, "We can ignore this None and only need the first value from the tuple. Both exist only for compatibility." Bob is right about that and we now have a convenient textbook RSA encryption function at hand.
Now Alice and Bob are ready for their DH exchange. In contrast to their original sketch, they will encrypt their DH values with RSA. Alice generates:
>>> xa = int.from_bytes(os.urandom(192), byteorder='big')
>>> ya = pow(g, xa, p)
and sends
>>> RSA_enc(k_Bob_pub, (ya, g, p))
Alice sends 93cd059154d2f419... [504 bytes omitted]. How does Alice send the message? She hands it over to Carol. Carol starts fiddling around with with the data. "What are you doing?" Bob asks. Alice replies, "It is encrypted, those were your words. Carol will deliver the message to you."
Carol forwards 2ba6f82bb1459ac3... [504 bytes omitted]. Bob decrypts with his private RSA key, parses ya, g, p from the message, and computes
>>> xb = int.from_bytes(os.urandom(192), byteorder='big')
>>> yb = pow(g, xb, p)
>>> ZZ_b = pow(ya, xb, p)
and sends
>>> RSA_enc(k_Alice_pub, yb)
Bob sends 4ecd5e62e4ba7dc0... [504 bytes omitted]. Carol forwards a different message. Alice performs her part to finish the DH handshake. Carol exclaims, "The key is 1!" Bob and Alice check. Carol is right. How can Carol know the established keys? Bob is right about one thing, the DH values were encrypted, so a trivial textbook DH MitM attack does not work since Carol cannot get the ya and yb values. But she doesn't need to. This is what happened so far:
Alice Carol Bob
| | |
| RSA(k_Bob_pub, (ya, g, p)) | |
|------------------------------->| |
| | RSA(k_Bob_pub, (1, g, p)) |
| |------------------------------->|
| | RSA decrypt
| | ZZ_b = pow(1, xb, p)
| | |
| | |
| | RSA(k_Alice_pub, yb) |
| |<-------------------------------|
| RSA(k_Alice_pub, 1) |
|<-------------------------------|
RSA decrypt
ZZ_a = pow(1, xa, p)
The prime
"No No," Bob protests, "these values are not allowed in DH." Alice checks RFC 2631 and quotes: <<The following algorithm MAY be used to validate a received public key y [...] Verify that y lies within the interval [2,p-1]. If it does not, the key is invalid.>> Bob replies, "So y = 1 is clearly invalid, you must not do this Carol." Alice objects, "The check is optional, see this all-caps MAY there?" But Bob feels certain that he is right and insists, "Any library would reject this key!"
"Sure, we'll give it a try." Alice responds. She sticks to her old code because the RFC clearly states the check optional, but Bob can reject the weak values.
Alice sends 0ec05db5b90f9c44... [504 bytes omitted].
Carol, testing the same trick again, forwards 2ba6f82bb1459ac3... [504 bytes omitted].
Bob now uses \texttt{pyca/cryptography} with the openssl backend to do the DH computation.
Maybe just doing ZZ_b = pow(ya, xb, p)
was too simple?
Let's see what happens when we use some part of the OpenSSL library (wrapped by \texttt{pyca/cryptography}) to perform the same computation.
A word of clarification: The OpenSSL library is only used to implement the DH part on Bob's side, the exchange is not tunneled over TLS.
The RSA-part remains unchanged.
>>> from cryptography.hazmat.primitives.asymmetric import dh
>>> from cryptography.hazmat.backends import openssl
>>> pn = dh.DHParameterNumbers(p, g)
>>> parameters = pn.parameters(openssl.backend)
>>> xb = parameters.generate_private_key()
>>> # feed ya to the openssl library backend
>>> alice_public_key = dh.DHPublicNumbers(ya, pn).public_key(openssl.backend)
>>> assert alice_public_key.key_size == 1536 # 1536-bit MODP group of our prime
>>> yb = xb.public_key().public_numbers().y
>>> ZZ_b = xb.exchange(alice_public_key)
And indeed, the last line aborts with the exception `ValueError: Public key value is invalid for this exchange.' Alice and Bob abort the handshake. This is what happened so far:
Alice Carol Bob
| | |
| RSA(k_Bob_pub, (ya, g, p)) | |
|------------------------------->| |
| | RSA(k_Bob_pub, (1, g, p)) |
| |------------------------------->|
| | RSA decrypt
| | With (ya = 1, g, p)
| | using openssl.backend to
| | compute ZZ_b ...
| | raise ValueError
"Now you must behave, Carol. We will no longer accept your MitMed values. Now that we prohibit the two bad DH values and everything is encrypted, we are 100% secure," Bob says.
Alice and Bob try the handshake again. Carol cannot send
>>> pc = pow(2, 1536) - 1
>>> xc = int.from_bytes(os.urandom(192), byteorder='big')
>>> yc = pow(g, xc, pc)
Well,
>>> iv = os.urandom(16)
>>> aeskey = kdf128(ZZ_b) # squash the key to 128 bit
>>> ct = aes128_ctr(iv, aeskey, b'Hey Alice! See, this is perfectly secure now.')
>>> wire = "{},{}".format(hexlify(iv).decode('ascii'), hexlify(ct).decode('ascii'))
Bob sends the IV and the ciphertext message 5160472bfd1cac6508442decf660d4c3,2a65c41b71ec0b15f9eabd180dd7b31d22b9a13e1f42626dedbbf0aae8b0875904fde9d5ed6b5172369602392b. In summary, this is what happened so far:
Alice Carol Bob
| | |
| RSA(k_Bob_pub, (ya, g, p)) | |
|------------------------------->| |
| | RSA(k_Bob_pub, (yc, g, pc)) |
| |------------------------------->|
| | RSA decrypt
| | using openssl.backend
| | ZZ_b = pow(yc, xb, pc)
| | |
| | |
| | RSA(k_Alice_pub, yb) |
| |<-------------------------------|
| RSA(k_Alice_pub, garbage) | |
|<-------------------------------| ciphertext =
RSA decrypt | Enc(ZZ_b, message)
ZZ_a = garbage2 | ciphertext |
| |<-------------------------------|
Carol chose a great `prime'
>>> iv, ct = map(unhexlify, wire.split(','))
>>> for i in range(1536):
... keyguess = pow(2, i)
... msg = aes128_ctr(iv, kdf128(keyguess.to_bytes(192, byteorder='big')), ct)
... try:
... if not all(c in string.printable for c in msg.decode('ascii')):
... continue
... except UnicodeDecodeError: #not ASCII
... continue
... break
The brute-forced key is 8452712498170643941637436558664265704301557216577944354047371344426782440907597751590676094202515006314790319892114058862117560952042968596008623655407033230534186943984081346699704282822823056848387726531379014466368452684024987821414350380272583623832617294363807973376, or in hex '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' (exactly one bit set). Carol is correct. She immediately shouts out the message `Hey Alice! See, this is perfectly secure now.' Bob is depressed. "Why doesn't my code work?", he asks. "Probably DH is not strong enough and we need to use elliptic curve DH?", he conjectures. "Maybe Carol even has a quantum computer hidden in her pocket, let me find a post-quantum replacement for Diffie-Hellman, ..." he continues. Carol interferes, "The same ideas of my attack also apply to ECDH or a post-quantum drop-in replacement with the same properties. Don't waste your time on this line of thought. If you cannot use textbook DH, ECDH (or the post-quantum candidates) won't help."
Alice tries to put Bob on the right track, "Maybe RSA encryption does not help, but can we use RSA differently? Remember, encryption itself does not not provide integrity." "Of course," Bob replies, "we need to sign the DH values. And signing with RSA is just encryption with the private key." "Don't forget the padding," Alice is trying to help, but Bob immediately codes:
>>> import Crypto.PublicKey.RSA
>>> def RSA_sign(k_priv, msg):
... # ignore the compatibility parameters
... return k_priv.sign(msg, None)[0]
>>> def RSA_verify(k_pub, msg, signature):
... # ignore the compatibility parameters
... return k_pub.verify(msg, (signature, None))
Again, Bob is right about ignoring the compatibility parameters. However, Carol smiles as Bob completely ignored Alice's comment about padding.
"Let's hardcode the prime
>>> "{},{}".format(ya, RSA_sign(k_Alice_priv, ya))
Alice sends 21864ab5b11d6e3c...[184 bytes of y omitted],3be6f4a171ced349...[504 bytes of signature omitted]. Carol just forwards 1,1. Bob parses the values, verifies the signature correctly and performs his step of the DH exchange.
>>> ya, signature = map(int, wire.split(','))
>>> if not RSA_verify(k_Alice_pub, ya, signature):
>>> print("Signature verification failed")
>>> return 'reject'
[...]
>>> return "{},{}".format(yb, RSA_sign(k_Bob_priv, yb))
Bob sends 75bebf2a6f8d3c86...[184 bytes of y omitted],7a984192593d8148...[504 bytes of signature omitted].
Carol just forwards 1,1.
Alice smiles as she receives the values.
Nevertheless, she performs the signature verification professionally.
Both the signature check at Bob and the signature check at Alice were successful and Alice and Bob agreed on a shared key.
This is what happened so far, where \texttt{RSA} corresponds to RSA_sign
as defined above:
Alice Carol Bob
| | |
| ya, RSA(k_Alice_priv, ya) | |
|------------------------------->| |
| | 1,1 |
| |------------------------------->|
| | RSA_verify(k_Alice_pub, 1, 1)
| | ZZ_b = pow(1, xb, p)
| | |
| | |
| | yb, RSA(k_Bob_priv, yb) |
| |<-------------------------------|
| 1,1 |
|<-------------------------------|
RSA_verify(k_Bob_pub, 1, 1)
ZZ_a = pow(1, xa, p)
Carol exclaims "The key is 1!"
Bob is all lost, "How could this happen again? I checked the signature!"
"Indeed," Carol explains, "but you should have listened to Alice's remark about the padding.
RSA signatures are not just the textbook RSA operation with the private key.
Plain textbook RSA is just
Bob replaces the sign and verify functions:
>>> from cryptography.hazmat.primitives import hashes
>>> from cryptography.hazmat.primitives.asymmetric import padding
>>> def RSA_sign(k_priv, msg):
>>> return k_priv.sign(
... msg,
... padding.PSS(
... mgf=padding.MGF1(hashes.SHA256()),
... salt_length=padding.PSS.MAX_LENGTH
... ),
... hashes.SHA256()
... )
The RSA_verify
function is replaced accordingly.
Now Alice and Bob can try their handshake again. Alice sends f3f4038a99b5e123...[184 bytes of y omitted],9596b7dce3859800...[504 bytes of signature omitted]. Carol forwards the message unmodified. Bob looks at Carol suspiciously. "I cannot modify this without breaking the signature," Carol replies. "Probably the DH prime is a bit too small for the future; Logjam predicts 1024-bit breakage. Maybe you could use fresh DH values for each exchange or switch to ECDH to be ready for the future, ... But I'm out of ideas for attack I could carry out on my slow laptop against your handshake for now." Carol concludes.
Bob sends 24a82fb86f0b68da...[184 bytes of y omitted],18a672407fe02adb...[504 bytes of signature omitted]. Carol forwards the message unmodified. Finally, Alice and Bob established a shared key and Carol does not know it.
Alice Carol Bob
| | |
| ya, RSA(k_Alice_priv, ya) | |
|------------------------------->| |
| | ya, RSA(k_Alice_priv, ya) |
| |------------------------------->|
| | RSA_verify(k_Alice_pub, ...)
| | ZZ_b = pow(ya, xb, p)
| | |
| | |
| | yb, RSA(k_Bob_priv, yb) |
| |<-------------------------------|
| yb, RSA(k_Bob_priv, yb) |
|<-------------------------------|
RSA_verify(k_Bob_pub, ...)
ZZ_a = pow(yb, xa, p)
To complete the scenario, Bob uses the freshly established key to send an encrypted message to Alice.
>>> iv = os.urandom(16)
>>> aeskey = kdf128(ZZ_b) # squash the key to 128 bit
>>> ct = aes128_ctr(iv, aeskey, b'Hey Alice! See, this is perfectly secure now.')
>>> wire = "{},{}".format(hexlify(iv).decode('ascii'), hexlify(ct).decode('ascii')
Bob sends the IV and the ciphertext message 48e42bbabea4c7e4aea92491250e6765,cf1b0eed31212fc2401371caf82bc3c7b413b1ef03cefecf7f6f817748772d8ed97e64cf4356212fdc2033e860. Carol remembers the plaintext Bob sent in run 3. She realizes that this run's ciphertext has exactly the same length as the plaintext in run 3. Carol forwards a ciphertext which is slightly shorter: 48e42bbabea4c7e4aea92491250e6765,c21014bf093d32c84a5c71f0ee6e8188b45b91f257c2ea9d667e8a3f Alice reads out loud the message she received and decrypted: `Encryption is not Integrity.' Bob shouts, "This is not the message! How can this happen? Did Carol break AES-CTR?" Alice and Carol answer simultaneously, "AES-CTR is secure encryption, but Encryption is not Integrity."