AES: how to detect that a bad password has been entered?

五迷三道 提交于 2019-12-22 01:17:20

问题


A text s has been encrypted with:

s2 = iv + Crypto.Cipher.AES.new(Crypto.Hash.SHA256.new(pwd).digest(), 
                                    Crypto.Cipher.AES.MODE_CFB, 
                                    iv).encrypt(s.encode())

Then, later, a user inputs the password pwd2 and we decrypt it with:

iv, cipher = s2[:Crypto.Cipher.AES.block_size], s2[Crypto.Cipher.AES.block_size:]

s3 = Crypto.Cipher.AES.new(Crypto.Hash.SHA256.new(pwd2).digest(),
                           Crypto.Cipher.AES.MODE_CFB, 
                           iv).decrypt(cipher)

Problem: the last line works even if the entered password pw2 is wrong. Of course the decrypted text will be random chars, but no error is triggered.

Question: how to make Crypto.Cipher.AES.new(...).decrypt(cipher) fail if the password pw2 is incorrect? Or at least how to detect a wrong password?


Here is a linked question: Making AES decryption fail if invalid password and here a discussion about the cryptographic part (less programming) of the question: AES, is this method to say “The password you entered is wrong” secure? .


回答1:


AES provides confidentiality but not integrity out of the box - to get integrity too, you have a few options. The easiest and arguably least prone to "shooting yourself in the foot" is to just use AES-GCM - see this Python example or this one.

You could also use an HMAC, but this generally requires managing two distinct keys and has a few more moving parts. I would recommend the first option if it is available to you.

A side note, SHA-256 isn't a very good KDF to use when converting a user created password to an encryption key. Popular password hashing algorithms are better at this - have a look at Argon2, bcrypt or PBKDF2.

Edit: The reason SHA-256 is a bad KDF is the same reason it makes a bad password hash function - it's just too fast. A user created password of, say, 128 bits will usually contain far less entropy than a random sequence of 128 bits - people like to pick words, meaningful sequences etc. Hashing this once with SHA-256 doesn't really alleviate this issue. But hashing it with a construct like Argon2 that is designed to be slow makes a brute-force attack far less viable.




回答2:


Doesn't use the Crypto package, but this should suit your needs:

import base64
import os

from cryptography.fernet import Fernet
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt


def derive_password(password: bytes, salt: bytes):
    """
    Adjust the N parameter depending on how long you want the derivation to take.
    The scrypt paper suggests a minimum value of n=2**14 for interactive logins (t < 100ms),
    or n=2**20 for more sensitive files (t < 5s).
    """
    kdf = Scrypt(salt=salt, length=32, n=2**16, r=8, p=1, backend=default_backend())
    key = kdf.derive(password)
    return base64.urlsafe_b64encode(key)


salt = os.urandom(16)
password = b'legorooj'
bad_password = b'legorooj2'

# Derive the password
key = derive_password(password, salt)
key2 = derive_password(bad_password, salt)  # Shouldn't re-use salt but this is only for example purposes

# Create the Fernet Object
f = Fernet(key)

msg = b'This is a test message'

ciphertext = f.encrypt(msg)

print(msg, flush=True)  # Flushing pushes it strait to stdout, so the error that will come
print(ciphertext, flush=True)

# Fernet can only be used once, so we need to reinitialize
f = Fernet(key)

plaintext = f.decrypt(ciphertext)

print(plaintext, flush=True)

# Bad Key
f = Fernet(key2)
f.decrypt(ciphertext)
"""
This will raise InvalidToken and InvalidSignature, which means it wasn't decrypted properly.
"""

See my comment for links to the documentation.




回答3:


For future reference, here is a working solution following the AES GCM mode (recommended by @LukeJoshuaPark in his answer):

from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes

# Encryption
data = b"secret"
key = get_random_bytes(16)
cipher = AES.new(key, AES.MODE_GCM)
ciphertext, tag = cipher.encrypt_and_digest(data)
nonce = cipher.nonce

# Decryption
key2 = get_random_bytes(16)  # wrong key
#key2 = key  # correct key
try:
    cipher = AES.new(key2, AES.MODE_GCM, nonce=nonce)
    plaintext = cipher.decrypt_and_verify(ciphertext, tag)
    print("The message was: " + plaintext.decode())
except ValueError:
    print("Wrong key")

It does fail with an exception when the password is wrong indeed, as desired.


The following code uses a real password derivation function:

import Crypto.Random, Crypto.Protocol.KDF, Crypto.Cipher.AES

def cipherAES(pwd, nonce):
    return Crypto.Cipher.AES.new(Crypto.Protocol.KDF.PBKDF2(pwd, nonce, count=100000), Crypto.Cipher.AES.MODE_GCM, nonce=nonce)

# encryption
nonce = Crypto.Random.new().read(16)
cipher = cipherAES(b'pwd1', nonce)
ciphertext, tag = cipher.encrypt_and_digest(b'bonjour')

# decryption
try:
    cipher = cipherAES(b'pwd1', nonce=nonce)
    plaintext = cipher.decrypt_and_verify(ciphertext, tag)
    print("The message was: " + plaintext.decode())
except ValueError:
    print("Wrong password")

@fgrieu's answer is probably better because it uses scrypt as KDF.




回答4:


The best way is to use authenticated encryption, and a modern memory-hard entropy-stretching key derivation function such a scrypt to turn the password into a key. The cipher's nounce can be used as salt for the key derivation. With PyCryptodome that could be:

from Crypto.Random       import get_random_bytes
from Crypto.Cipher       import AES
from Crypto.Protocol.KDF import scrypt

# initialize an AES-128-GCM cipher from password (derived using scrypt) and nonce
def cipherAES(pwd, nonce):
    # note: the p parameter should allow use of several processors, but did not for me
    # note: changing 16 to 24 or 32 should select AES-192 or AES-256 (not tested)
    return AES.new(scrypt(pwd, nonce, 16, N=2**21, r=8, p=1), AES.MODE_GCM, nonce=nonce)

# encryption
nonce = get_random_bytes(16)
print("deriving key from password and nonce, then encrypting..")
ciphertext, tag = cipherAES(b'pwdHklot2',nonce).encrypt_and_digest(b'bonjour')
print("done")

# decryption of nonce, ciphertext, tag
print("deriving key from password and nonce, then decrypting..")
try:
    plaintext = cipherAES(b'pwdHklot2', nonce).decrypt_and_verify(ciphertext, tag)
    print("The message was: " + plaintext.decode())
except ValueError:
    print("Wrong password or altered nonce, ciphertext, tag")
print("done")

Note: Code is here to illustrate the principle. In particular, the scrypt parameters should not be fixed, but rather be included in a header before nonce, ciphertext, and tag; and that must be somewhat grouped for sending, and parsed for decryption.

Caveat: nothing in this post should be construed as an endorsement of PyCryptodome's security.


Addition (per request):

We need scrypt or some other form of entropy stretching only because we use a password. We could use a random 128-bit key directly.

PBKDF2-HMAC-SHAn with 100000 iterations (as in the OP's second code fragment there) is only barely passable to resist Hashcat with a few GPUs. It would would be almost negligible compared to other hurdles for an ASIC-assisted attack: a state of the art Bitcoin mining ASIC does more than 2*1010 SHA-256 per Joule, 1 kWh of electricity costing less than $0.15 is 36*105 J. Crunching these numbers, testing the (62(8+1)-1)/(62-1) = 221919451578091 passwords of up to 8 characters restricted to letters and digits cost less than $47 for energy dedicated to the hashing part.

scrypt is much more secure for equal time spent by legitimate users because it requires a lot of memory and accesses thereof, slowing down the attacker, and most importantly making the investment cost for massively parallel attack skyrocket.



来源:https://stackoverflow.com/questions/59145627/aes-how-to-detect-that-a-bad-password-has-been-entered

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!