如何在 Python 中解密 OpenSSL AES 加密的文件?

Posted

技术标签:

【中文标题】如何在 Python 中解密 OpenSSL AES 加密的文件?【英文标题】:How to decrypt OpenSSL AES-encrypted files in Python? 【发布时间】:2013-05-21 14:50:44 【问题描述】:

OpenSSL 为 AES 加密提供了一个流行的(但不安全的 - 见下文!)命令行界面:

openssl aes-256-cbc -salt -in filename -out filename.enc

Python 以 PyCrypto 包的形式支持 AES,但它只提供工具。如何使用 Python/PyCrypto 解密使用 OpenSSL 加密的文件?

通知

这个问题过去也涉及使用相同方案的 Python 中的加密。此后,我删除了该部分以阻止任何人使用它。不要以这种方式加密任何更多的数据,因为按照今天的标准它并不安全。您应该只使用解密,除了向后兼容性之外没有其他原因,即当您别无选择时。想要加密?如果可能,请使用 NaCl/libsodium。

【问题讨论】:

+1 用于跟进自己,但这不是一个好的标准,因为基于密码的密钥派生是基于 MD5 的单次迭代(尽管使用盐)。至少,PBKDF2/scrypt 应该与更多的迭代一起使用。 @SquareRootOfTwentyThree 谢谢,我对那个特定的主题进行了一点调查。 @SquareRootOfTwentyThree 提出了一个很好的观点,apps/enc.c 使用了迭代计数为 1 的EVP_BytesToKey。对于普通密码,这是完全不合适的,因为它可以被简单地暴力破解。手册页建议使用 PBKDF2,这是一个更合适的解决方案。看到这段代码是used in Ansible Vault,那么以一个明确的警告not开始使用它除了向后兼容怎么样? @Lekensteyn 感谢您指出我在 Ansible 中的回答有参考。写的时候没想到。 :) 它实际上似乎仅用于遗留目的,但我明白你的意思。我会发出更强烈的警告。 @Lekensteyn 我不断收到如何用其他语言解密的问题,建议人们无论如何都要使用加密代码。到今天为止,它只能在编辑历史记录中找到。 【参考方案1】:

鉴于 Python 的流行,起初我很失望,因为找不到这个问题的完整答案。我花了相当多的时间阅读了这个板上的不同答案以及其他资源,才能让它正确。我想我可能会分享结果以供将来参考和审查;我绝不是密码学专家!但是,下面的代码似乎可以无缝运行:

from hashlib import md5
from Crypto.Cipher import AES
from Crypto import Random

def derive_key_and_iv(password, salt, key_length, iv_length):
    d = d_i = ''
    while len(d) < key_length + iv_length:
        d_i = md5(d_i + password + salt).digest()
        d += d_i
    return d[:key_length], d[key_length:key_length+iv_length]

def decrypt(in_file, out_file, password, key_length=32):
    bs = AES.block_size
    salt = in_file.read(bs)[len('Salted__'):]
    key, iv = derive_key_and_iv(password, salt, key_length, bs)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    next_chunk = ''
    finished = False
    while not finished:
        chunk, next_chunk = next_chunk, cipher.decrypt(in_file.read(1024 * bs))
        if len(next_chunk) == 0:
            padding_length = ord(chunk[-1])
            chunk = chunk[:-padding_length]
            finished = True
        out_file.write(chunk)

用法:

with open(in_filename, 'rb') as in_file, open(out_filename, 'wb') as out_file:
    decrypt(in_file, out_file, password)

如果您发现有机会对此进行改进或将其扩展为更灵活(例如,使其在没有盐的情况下工作,或提供 Python 3 兼容性),请随时这样做。

通知

这个答案过去也涉及使用相同方案的 Python 中的加密。此后,我删除了该部分以阻止任何人使用它。不要以这种方式加密任何更多的数据,因为按照今天的标准它并不安全。您应该只使用解密,除了向后兼容性之外没有其他原因,即当您别无选择时。想要加密?如果可能,请使用 NaCl/libsodium。

【讨论】:

这个实现与this one相比如何?有没有相对的优势或劣势? @rattray 主要区别在于您的示例与其他许多关于在 Python 中一般使用 AES 的示例一样。我的是与 OpenSSL 实现的兼容性,因此您可以使用众所周知的命令行工具来解密使用上述 Python 代码加密的文件,反之亦然。 @KennyPowers 我认为你不能不破坏 OpenSSL 兼容性,这是这个问题的主要目标。如果您不需要,还有更好的方法来执行加密,这也将为您提供所需的灵活性。 @SteveWalsh 我的代码需要二进制,而您的 file.enc 是 base64 编码的(给定 -a 参数)。在解密之前删除该参数或解码文件。如需进一步支持,请提出您自己的问题。 @SaketKumarSingh 我不认为该命令正在做你认为它正在做的事情。看起来您正在使用密码“symmetric_keyfile.key”加密文件,而不是该文件中的内容。【参考方案2】:

我将重新发布您的代码并进行一些更正(我不想掩盖您的版本)。虽然您的代码有效,但它不会检测到填充周围的一些错误。特别是,如果提供的解密密钥不正确,您的填充逻辑可能会做一些奇怪的事情。如果您同意我的更改,您可以更新您的解决方案。

from hashlib import md5
from Crypto.Cipher import AES
from Crypto import Random

def derive_key_and_iv(password, salt, key_length, iv_length):
    d = d_i = ''
    while len(d) < key_length + iv_length:
        d_i = md5(d_i + password + salt).digest()
        d += d_i
    return d[:key_length], d[key_length:key_length+iv_length]

# This encryption mode is no longer secure by today's standards.
# See note in original question above.
def obsolete_encrypt(in_file, out_file, password, key_length=32):
    bs = AES.block_size
    salt = Random.new().read(bs - len('Salted__'))
    key, iv = derive_key_and_iv(password, salt, key_length, bs)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    out_file.write('Salted__' + salt)
    finished = False
    while not finished:
        chunk = in_file.read(1024 * bs)
        if len(chunk) == 0 or len(chunk) % bs != 0:
            padding_length = bs - (len(chunk) % bs)
            chunk += padding_length * chr(padding_length)
            finished = True
        out_file.write(cipher.encrypt(chunk))

def decrypt(in_file, out_file, password, key_length=32):
    bs = AES.block_size
    salt = in_file.read(bs)[len('Salted__'):]
    key, iv = derive_key_and_iv(password, salt, key_length, bs)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    next_chunk = ''
    finished = False
    while not finished:
        chunk, next_chunk = next_chunk, cipher.decrypt(in_file.read(1024 * bs))
        if len(next_chunk) == 0:
            padding_length = ord(chunk[-1])
            if padding_length < 1 or padding_length > bs:
               raise ValueError("bad decrypt pad (%d)" % padding_length)
            # all the pad-bytes must be the same
            if chunk[-padding_length:] != (padding_length * chr(padding_length)):
               # this is similar to the bad decrypt:evp_enc.c from openssl program
               raise ValueError("bad decrypt")
            chunk = chunk[:-padding_length]
            finished = True
        out_file.write(chunk)

【讨论】:

请编辑我的帖子。无论如何,它是经过同行评审的。一般来说,我同意一些错误检查是好的。虽然“缺失垫”实际上是一种误导,但实际上它太多了。这与 OpenSSL 给出的错误相同吗? 已更正以更紧密地匹配来自 evp_enc.c 的 openssl 输出,这两种情况都输出相同的“错误解密”消息。 太棒了!我也想在 .NET 中解密。谁能帮我转换成这种语言? 我已从我的回答中删除了 encrypt 函数,并鼓励您也这样做。【参考方案3】:

以下代码应与 Python 3 兼容,并与代码中记录的小改动兼容。还想使用 os.urandom 而不是 Crypto.Random。 'Salted__' 被替换为 salt_header,可以根据需要进行定制或留空。

from os import urandom
from hashlib import md5

from Crypto.Cipher import AES

def derive_key_and_iv(password, salt, key_length, iv_length):
    d = d_i = b''  # changed '' to b''
    while len(d) < key_length + iv_length:
        # changed password to str.encode(password)
        d_i = md5(d_i + str.encode(password) + salt).digest()
        d += d_i
    return d[:key_length], d[key_length:key_length+iv_length]

def encrypt(in_file, out_file, password, salt_header='', key_length=32):
    # added salt_header=''
    bs = AES.block_size
    # replaced Crypt.Random with os.urandom
    salt = urandom(bs - len(salt_header))
    key, iv = derive_key_and_iv(password, salt, key_length, bs)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    # changed 'Salted__' to str.encode(salt_header)
    out_file.write(str.encode(salt_header) + salt)
    finished = False
    while not finished:
        chunk = in_file.read(1024 * bs) 
        if len(chunk) == 0 or len(chunk) % bs != 0:
            padding_length = (bs - len(chunk) % bs) or bs
            # changed right side to str.encode(...)
            chunk += str.encode(
                padding_length * chr(padding_length))
            finished = True
        out_file.write(cipher.encrypt(chunk))

def decrypt(in_file, out_file, password, salt_header='', key_length=32):
    # added salt_header=''
    bs = AES.block_size
    # changed 'Salted__' to salt_header
    salt = in_file.read(bs)[len(salt_header):]
    key, iv = derive_key_and_iv(password, salt, key_length, bs)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    next_chunk = ''
    finished = False
    while not finished:
        chunk, next_chunk = next_chunk, cipher.decrypt(
            in_file.read(1024 * bs))
        if len(next_chunk) == 0:
            padding_length = chunk[-1]  # removed ord(...) as unnecessary
            chunk = chunk[:-padding_length]
            finished = True 
        out_file.write(bytes(x for x in chunk))  # changed chunk to bytes(...)

【讨论】:

这段代码显然未经测试,不能按原样工作。 @ChrisArndt 在 python 3 上对我来说很好。 对不起,我不记得了,什么对我不起作用。但是,我实现了自己的脚本来使用 AES 加密文件:gist.github.com/SpotlightKid/53e1eb408267315de620 @StephenFuhry 我意识到这是一篇旧帖子,但您可能想要修复代码中的一个微妙错误 - 行“out_file.write(字节(x for x in chunk))”应该移出一层,否则你只会解密最后一个块。 我已经从我的回答中删除了encrypt 函数,并鼓励你也这样做。【参考方案4】:

这个答案基于 openssl v1.1.1,它支持比以前版本的 openssl 更强大的 AES 加密密钥派生过程。

此答案基于以下命令:

echo -n 'Hello World!' | openssl aes-256-cbc -e -a -salt -pbkdf2 -iter 10000 

此命令加密明文“Hello World!”使用 aes-256-cbc。密钥是使用 pbkdf2 从密码和随机盐中派生的,具有 10,000 次 sha256 散列迭代。当提示输入密码时,我输入了密码“p4$$w0rd”。该命令产生的密文输出为:

U2FsdGVkX1/Kf8Yo6JjBh+qELWhirAXr78+bbPQjlxE=

上面用openssl产生的密文解密过程如下:

    base64 解码 openssl 的输出,utf-8 解码 密码,这样我们就有了这两者的底层字节。 salt 是 base64 解码的 openssl 输出的字节 8-15。 在给定密码字节和盐的情况下,使用 pbkdf2 派生一个 48 字节的密钥 10,000 次 sha256 散列迭代。 密钥是派生密钥的 0-31 字节,iv 是派生密钥的 32-47 字节。 密文是字节 16 到 base64 解码的 openssl 末尾 输出。 使用 aes-256-cbc 解密密文,给定密钥 iv 和 密文。 从明文中删除 PKCS#7 填充。的最后一个字节 明文表示追加到末尾的填充字节数 的明文。这是要删除的字节数。

下面是上述过程的python3实现:

import binascii
import base64
import hashlib
from Crypto.Cipher import AES       #requires pycrypto

#inputs
openssloutputb64='U2FsdGVkX1/Kf8Yo6JjBh+qELWhirAXr78+bbPQjlxE='
password='p4$$w0rd'
pbkdf2iterations=10000

#convert inputs to bytes
openssloutputbytes=base64.b64decode(openssloutputb64)
passwordbytes=password.encode('utf-8')

#salt is bytes 8 through 15 of openssloutputbytes
salt=openssloutputbytes[8:16]

#derive a 48-byte key using pbkdf2 given the password and salt with 10,000 iterations of sha256 hashing
derivedkey=hashlib.pbkdf2_hmac('sha256', passwordbytes, salt, pbkdf2iterations, 48)

#key is bytes 0-31 of derivedkey, iv is bytes 32-47 of derivedkey 
key=derivedkey[0:32]
iv=derivedkey[32:48]

#ciphertext is bytes 16-end of openssloutputbytes
ciphertext=openssloutputbytes[16:]

#decrypt ciphertext using aes-cbc, given key, iv, and ciphertext
decryptor=AES.new(key, AES.MODE_CBC, iv)
plaintext=decryptor.decrypt(ciphertext)

#remove PKCS#7 padding. 
#Last byte of plaintext indicates the number of padding bytes appended to end of plaintext.  This is the number of bytes to be removed.
plaintext = plaintext[:-plaintext[-1]]

#output results
print('openssloutputb64:', openssloutputb64)
print('password:', password)
print('salt:', salt.hex())
print('key: ', key.hex())
print('iv: ', iv.hex())
print('ciphertext: ', ciphertext.hex())
print('plaintext: ', plaintext.decode('utf-8'))

如预期的那样,上面的 python3 脚本产生以下内容:

openssloutputb64: U2FsdGVkX1/Kf8Yo6JjBh+qELWhirAXr78+bbPQjlxE=
password: p4$$w0rd
salt: ca7fc628e898c187
key:  444ab886d5721fc87e58f86f3e7734659007bea7fbe790541d9e73c481d9d983
iv:  7f4597a18096715d7f9830f0125be8fd
ciphertext:  ea842d6862ac05ebefcf9b6cf4239711
plaintext:  Hello World!

注意:可以在 https://github.com/meixler/web-browser-based-file-encryption-decryption 找到等效/兼容的 javascript 实现(使用 web crypto api)。

【讨论】:

有趣的补充!【参考方案5】:

我知道这有点晚了,但here 是我在 2013 年发表的关于如何使用 python pycrypto 包以兼容 openssl 的方式加密/解密的解决方案。已经在python2.7和python3.x上测试过了。源码和测试脚本可以在here找到。

此解决方案与上面介绍的优秀解决方案之间的主要区别之一是它区分了管道和文件 I/O,这可能会导致某些应用程序出现问题。

该博客的主要功能如下所示。

# ================================================================
# get_key_and_iv
# ================================================================
def get_key_and_iv(password, salt, klen=32, ilen=16, msgdgst='md5'):
    '''
    Derive the key and the IV from the given password and salt.

    This is a niftier implementation than my direct transliteration of
    the C++ code although I modified to support different digests.

    CITATION: http://***.com/questions/13907841/implement-openssl-aes-encryption-in-python

    @param password  The password to use as the seed.
    @param salt      The salt.
    @param klen      The key length.
    @param ilen      The initialization vector length.
    @param msgdgst   The message digest algorithm to use.
    '''
    # equivalent to:
    #   from hashlib import <mdi> as mdf
    #   from hashlib import md5 as mdf
    #   from hashlib import sha512 as mdf
    mdf = getattr(__import__('hashlib', fromlist=[msgdgst]), msgdgst)
    password = password.encode('ascii', 'ignore')  # convert to ASCII

    try:
        maxlen = klen + ilen
        keyiv = mdf(password + salt).digest()
        tmp = [keyiv]
        while len(tmp) < maxlen:
            tmp.append( mdf(tmp[-1] + password + salt).digest() )
            keyiv += tmp[-1]  # append the last byte
        key = keyiv[:klen]
        iv = keyiv[klen:klen+ilen]
        return key, iv
    except UnicodeDecodeError:
        return None, None


# ================================================================
# encrypt
# ================================================================
def encrypt(password, plaintext, chunkit=True, msgdgst='md5'):
    '''
    Encrypt the plaintext using the password using an openssl
    compatible encryption algorithm. It is the same as creating a file
    with plaintext contents and running openssl like this:

    $ cat plaintext
    <plaintext>
    $ openssl enc -e -aes-256-cbc -base64 -salt \\
        -pass pass:<password> -n plaintext

    @param password  The password.
    @param plaintext The plaintext to encrypt.
    @param chunkit   Flag that tells encrypt to split the ciphertext
                     into 64 character (MIME encoded) lines.
                     This does not affect the decrypt operation.
    @param msgdgst   The message digest algorithm.
    '''
    salt = os.urandom(8)
    key, iv = get_key_and_iv(password, salt, msgdgst=msgdgst)
    if key is None:
        return None

    # PKCS#7 padding
    padding_len = 16 - (len(plaintext) % 16)
    if isinstance(plaintext, str):
        padded_plaintext = plaintext + (chr(padding_len) * padding_len)
    else: # assume bytes
        padded_plaintext = plaintext + (bytearray([padding_len] * padding_len))

    # Encrypt
    cipher = AES.new(key, AES.MODE_CBC, iv)
    ciphertext = cipher.encrypt(padded_plaintext)

    # Make openssl compatible.
    # I first discovered this when I wrote the C++ Cipher class.
    # CITATION: http://projects.joelinoff.com/cipher-1.1/doxydocs/html/
    openssl_ciphertext = b'Salted__' + salt + ciphertext
    b64 = base64.b64encode(openssl_ciphertext)
    if not chunkit:
        return b64

    LINELEN = 64
    chunk = lambda s: b'\n'.join(s[i:min(i+LINELEN, len(s))]
                                for i in range(0, len(s), LINELEN))
    return chunk(b64)


# ================================================================
# decrypt
# ================================================================
def decrypt(password, ciphertext, msgdgst='md5'):
    '''
    Decrypt the ciphertext using the password using an openssl
    compatible decryption algorithm. It is the same as creating a file
    with ciphertext contents and running openssl like this:

    $ cat ciphertext
    # ENCRYPTED
    <ciphertext>
    $ egrep -v '^#|^$' | \\
        openssl enc -d -aes-256-cbc -base64 -salt -pass pass:<password> -in ciphertext
    @param password   The password.
    @param ciphertext The ciphertext to decrypt.
    @param msgdgst    The message digest algorithm.
    @returns the decrypted data.
    '''

    # unfilter -- ignore blank lines and comments
    if isinstance(ciphertext, str):
        filtered = ''
        nl = '\n'
        re1 = r'^\s*$'
        re2 = r'^\s*#'
    else:
        filtered = b''
        nl = b'\n'
        re1 = b'^\\s*$'
        re2 = b'^\\s*#'

    for line in ciphertext.split(nl):
        line = line.strip()
        if re.search(re1,line) or re.search(re2, line):
            continue
        filtered += line + nl

    # Base64 decode
    raw = base64.b64decode(filtered)
    assert(raw[:8] == b'Salted__' )
    salt = raw[8:16]  # get the salt

    # Now create the key and iv.
    key, iv = get_key_and_iv(password, salt, msgdgst=msgdgst)
    if key is None:
        return None

    # The original ciphertext
    ciphertext = raw[16:]

    # Decrypt
    cipher = AES.new(key, AES.MODE_CBC, iv)
    padded_plaintext = cipher.decrypt(ciphertext)

    if isinstance(padded_plaintext, str):
        padding_len = ord(padded_plaintext[-1])
    else:
        padding_len = padded_plaintext[-1]
    plaintext = padded_plaintext[:-padding_len]
    return plaintext

【讨论】:

我无法让这个解决方案在 Python 3.9 中工作。当我将这些函数放入我的代码中时,我得到了 TypeError: Object type cannot be pass to C code。博客链接已损坏。而且我无法让 github 链接的脚本工作。这似乎在大多数事情上停滞不前。当我使用 -V 选项时,它会输出“b'mcrypt.py' 1.2”。绝对有可能我做的不对。 哇,很抱歉你遇到问题了,我好久没看这个了,我去看看,同时,你可以试试github.com/jlinoff/lock_files它应该仍然有效。这是您失败的博客 URL:joelinoff.com/blog/?p=885 吗? 看起来 pycrypto 包中的某些内容发生了变化。我可以通过将安装包名称从crypto 更改为Crypto 来解决它,但这太老套了。我正在删除要点以避免混淆其他人。这可能会有所帮助:crypto.stackexchange.com/questions/3298/…. 我决定保留要点并对其进行更新以反映此对话,并详细说明使其工作所需的解决方法。感谢您报告此事。要点:gist.github.com/jlinoff/412752f1ecb6b27762539c0f6b6d667b 不用担心。我知道这是从 2017 年开始的,奇怪的是,我还遇到了很多其他问题,试图让 OpenSSL 兼容的解密在 Python 中工作。我最终让我的代码使用子进程运行 OpenSSL。顺便说一下,博客链接实际上并没有被破坏,但是除了“提供 openssl -aes-256-cbc 兼容加密/解密的简单 python 函数”(看起来像标题和侧边栏)之外没有任何东西。我读了一点关于你的 lock_files 项目,非常整洁。【参考方案6】:

注意:此方法与 OpenSSL 不兼容

但如果您只想加密和解密文件,则它是合适的。

我从here 复制的自我回答。我认为这也许是一个更简单、更安全的选择。虽然我对专家的一些关于它的安全性的意见感兴趣。

我使用 Python 3.6 和 SimpleCrypt 加密文件然后上传。

认为这是我用来加密文件的代码:

from simplecrypt import encrypt, decrypt
f = open('file.csv','r').read()
ciphertext = encrypt('USERPASSWORD',f.encode('utf8')) # I am not certain of whether I used the .encode('utf8')
e = open('file.enc','wb') # file.enc doesn't need to exist, python will create it
e.write(ciphertext)
e.close

这是我在运行时用来解密的代码,我将getpass("password: ") 作为参数运行,因此我不必在内存中存储password 变量

from simplecrypt import encrypt, decrypt
from getpass import getpass

# opens the file
f = open('file.enc','rb').read()

print('Please enter the password and press the enter key \n Decryption may take some time')

# Decrypts the data, requires a user-input password
plaintext = decrypt(getpass("password: "), f).decode('utf8')
print('Data have been Decrypted')

注意,UTF-8 编码行为在 python 2.7 中有所不同,因此代码会略有不同。

【讨论】:

请注意,这个问题专门针对实现与 OpenSSL 的兼容性;不是关于在 Python 中执行加密的好方法(OpenSSL 方法当然不是)。因此,您的回答不符合问题,因此我投反对票。 @ThijsvanDien 感谢您指出这一点。我没有意识到我的帖子Import encrypted csv into Python 3 被标记为该帖子的潜在副本。我已经编辑了帖子以澄清。

以上是关于如何在 Python 中解密 OpenSSL AES 加密的文件?的主要内容,如果未能解决你的问题,请参考以下文章

QT:AES-256-CBC 根据 PHP 代码在 C++ 中加密/解密

如何使用BASH命令解密PHP Openssl加密

如何使用 AES 解密用 openssl 命令加密的 Java 文件?

尝试使用 Openssl 解密 S/MIME 文件

如何使用之前使用 mcrypt 加密的 OpenSSL 解密字符串?

如何使用 OpenSSL 进行 AES 解密