AES:如何检测输入了错误的密码?

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

文本 s 已加密:

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

然后,稍后,用户输入密码pwd2,我们用:

解密
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)

问题:即使输入的密码 pw2 错误,最后一行仍然有效 。当然解密的文本会是随机字符,但不会触发错误。

问:如果密码pw2不正确,如何使Crypto.Cipher.AES.new(...).decrypt(cipher)失败?或者至少如何检测错误的密码?


这是一个链接问题:Making AES decryption fail if invalid password 这里是关于问题的加密部分(较少编程)的讨论:AES,这种说“您输入的密码错误”的方法安全吗? .

AES 提供机密性但不提供开箱即用的完整性 - 要获得完整性,您有几个选择。 "shooting yourself in the foot" 最简单且可以说最不容易出现的方法是仅使用 AES-GCM - 请参阅 this Python example or this one.

您也可以使用 HMAC,但这通常需要管理两个不同的密钥并且有更多的活动部件。如果您可以使用第一个选项,我会推荐它。

旁注,在将用户创建的密码转换为加密密钥时,SHA-256 不是一个很好的 KDF。流行的密码哈希算法在这方面做得更好——看看 Argon2、bcrypt 或 PBKDF2。

编辑:SHA-256 是一个糟糕的 KDF 的原因与它生成一个糟糕的密码哈希函数的原因相同——只是太快了.例如,用户创建的 128 位密码通常包含的熵远低于 128 位随机序列 - 人们喜欢选择单词、有意义的序列等。使用 SHA-256 散列一次并不能真正缓解这个问题。但是使用像 Argon2 这样 设计缓慢 的构造对其进行哈希处理使得暴力攻击变得不那么可行。

为了将来参考,这里有一个遵循 AES GCM mode 的可行解决方案(@LukeJoshuaPark 在他的回答中推荐):

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")

当密码确实错误时,它确实会失败并出现异常。


以下代码使用了真正的密码推导函数:

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 的 可能更好,因为它使用 scrypt 作为 KDF。

最好的方法是使用经过身份验证的加密,以及现代的 memory-hard entropy-stretching 密钥派生函数,例如 scrypt,将密码转换为密钥。密码的 nounce 可以用作密钥推导的盐。 PyCryptodome 可以是:

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")

注:这里代码是为了说明原理。特别是scrypt参数不要固定,而是在nonce、密文、tag前加上一个header;并且必须对发送进行某种分组,并为解密进行解析。

警告:post 中的任何内容都不应被解释为对 PyCryptodome 安全性的认可。


加法(每request):

我们需要 scrypt 或其他形式的熵拉伸只是因为我们使用密码。我们可以直接使用随机的 128 位密钥。

具有 100000 次迭代的

PBKDF2-HMAC-SHAn(如在 OP 的第二个代码片段 there 中)仅勉强可以通过几个 GPU 来抵抗 Hashcat。与 ASIC-assisted 攻击的其他障碍相比,它几乎可以忽略不计:最先进的比特币挖矿 ASIC 每焦耳的 SHA-256 运算量超过 2*1010, 1 kWh 的电费低于 0.15 美元是 36*105 J. 计算这些数字,测试 (62(8+1)-1 )/(62-1) = 221919451578091 最多 8 个字符的密码仅限于字母和数字,用于散列部分的能源成本不到 47 美元。

scrypt 对于合法用户花费的相同时间来说更安全,因为它需要大量内存及其访问,从而减慢攻击者的速度,最重要的是使大规模并行攻击的投资成本猛增。

不使用 Crypto 包,但这应该符合您的需要:

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.
"""

查看我的 comment 以获取文档链接。