ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python加密实战:Fernet、RSA与AES-GCM

Python加密实战:Fernet、RSA与AES-GCM 密码这玩意平时写业务代码的时候最容易被忽略但一旦出问题就是大事故。我早期做过一个内部工具处理用户上传的敏感文件当时图省事直接用了 base64 编码当“加密”存结果被同事一眼看穿后来才老老实实补课入了 Pythoncryptography这个库的坑。用下来两年多不管是给配置文件里的密码加密、给接口做签名还是给文件做对称加密它基本都能一把梭。今天就把这个库从入门到进阶的用法、以及我在实际项目里踩过的坑一次性捋清楚。这篇内容适合谁看如果你正在用 Python 做后端、爬虫、自动化脚本或者手里有敏感数据需要保护却不知道从哪下手那这篇文章能帮你少走不少弯路。我会从库的选型思路讲起到 Fernet 高层封装、RSA 非对称加密、AES-GCM 底层原语再到最后的问题排查一步步带你上手保证你看完就能直接抄作业。1. 为什么选 cryptography 而不是其他加密库Python 生态里做加密的库不少最常见的有hashlib、pycryptodome、cryptography这几个。很多新手一上来就调hashlib.sha256()觉得“我用了哈希就是加密了”。打住哈希是摘要算法它是单向的不能解密。如果你需要把密文还原成明文那必须用真正的加密算法比如 AES、RSA。而hashlib本身不提供这些加密原语所以它能做的只是验证完整性、存密码指纹不是加密解密。pycryptodome算是老牌的密码学库API 偏底层也支持 AES、RSA、ChaCha20 等但它的 API 风格比较老而且维护节奏相对慢文档对新手也不算友好。相比之下cryptography库最大的优势是它有“双层结构”一层是recipes也就是“开箱即用、几乎不会用错”的高层封装比如Fernet另一层是hazmat也就是“危险材料”提供底层密码学原语给专业人士用。这种设计我在实际使用中感觉非常舒服因为普通业务里 80% 的场景用 Fernet 就够了根本不需要我手动去拼初始化向量、认证标签这些东西省心太多。还有一个关键点是cryptography库背后是 Python Cryptographic Authority 在维护而且大量依赖它比如pip本身、requests的某些安全功能、paramiko等长期活跃社区里遇到问题基本都能搜到答案。选库看维护活跃度这比多看几个功能列表重要得多。我之前用过一个冷门加密库后来作者弃坑Python 3.10 上直接编译失败被迫迁移代码那叫一个难受。当然如果你只是需要对用户密码做哈希存储那bcrypt或者argon2-cffi是更好的选择。但如果你要做的是“加密解密”也就是数据可逆的变换那cryptography几乎是 Python 里的最优解。它把复杂的密码学原理封装成相对友好的 API同时没把底层能力阉割掉这样的平衡很难得。注意自己写加密算法是绝对的大忌。哪怕你觉得“我这个异或算法没人能破解”也千万别用。密码学需要的是久经考验的标准方案而不是你脑子里的奇思妙想。cryptography库的作用就是让你在正确算法里选一个合适的然后安全地使用它。2. 核心概念与设计思路对称加密、非对称加密、哈希、Fernet在动手写代码前有几个概念必须掰扯清楚不然很容易用错场景。对称加密就是加密和解密用同一个密钥好比一把钥匙开一把锁。优点是速度快适合加密大文件、数据库字段缺点是密钥要安全地共享给对方如果密钥泄露密文等于裸奔。常见的对称加密算法有 AES、ChaCha20、Blowfish 等。cryptography里的Fernet底层用的就是 AES-128-CBC同时还会做 HMAC-SHA256 认证既保密又防篡改。非对称加密则用一对密钥公钥和私钥。公钥可以公开分发私钥自己保存。用公钥加密的数据只有私钥能解用私钥签名的数据公钥能验签。它解决了密钥分发问题但速度慢通常只用来加密小块数据比如对称密钥本身或者做数字签名。典型算法就是 RSA、ECC。哈希跟加密完全是两码事。哈希是单向的不可逆主要用于完整性校验、密码存储。在cryptography库中哈希函数也提供了但一般我更推荐直接用hashlib因为hashlib是标准库零依赖性能也足够。那Fernet到底是什么它是cryptography库在recipes层提供的一个高层封装本质是一个“对称加密认证”的完整方案。它接收一个 32 字节的 URL-safe Base64 编码密钥也就是经过 urlsafe_b64encode 后的 32 字节随机值加密时自动生成随机 salt 和初始化向量并用 AES-128-CBC 加密然后用 HMAC-SHA256 对密文做签名解密时先验证签名再解密。整个流程对调用者完全透明你只需要encrypt()和decrypt()两个方法即可。它的设计思路非常聪明把那些容易出错的地方随机数生成、IV 管理、认证标签处理全封装好减少用户的犯错空间。如果你用过其他库手撸 AES-CBC就知道要处理 padding、IV、MAC一不小心就出漏洞。Fernet 把这堆麻烦全部包装起来让“安全”变得易用。所以如果你的业务场景是“我需要把一个字符串或文件加密之后再解回来”首选就是 Fernet。cryptography库的设计哲学其实和标准库里的ssl类似默认给你安全配置让你难以用错。而在hazmat层它才把算法原语暴露出来允许你拼装出自己的加密协议。这个分层的设计非常适合不同水平的开发者新手用高层老手用低层。3. 实操用 Fernet 实现文件加密与解密先来一个最直白的入门示例用 Fernet 加密字符串。3.1 生成密钥并保存密钥是 Fernet 的核心丢失密钥等于密文永远无法解密。生成密钥的代码如下from cryptography.fernet import Fernet key Fernet.generate_key() # 返回 bytes形如 b... print(key.decode())运行后你会得到一串类似M2IzOWYyMzU2ZWM3YmY3OWI3ZmQ5MWYxM2ZlY2Y0MjY的字符串。这串东西是 32 字节随机数据的 Base64 编码所以看起来只有字母数字和-、_、。一定要用安全的方式保存它比如放进环境变量、密钥管理服务KMS或者权限严格的文件里。千万别把它写进代码仓库哪怕仓库是私有的也不建议。注意generate_key()每次生成的密钥都不同即使同一时刻连续调用两次结果也不同。所以生成以后要立刻妥善保存没有备份就相当于自杀式加密。3.2 加密字符串拿到密钥后实例化Fernet对象然后调用encrypt()from cryptography.fernet import Fernet key bM2IzOWYyMzU2ZWM3YmY3OWI3ZmQ5MWYxM2ZlY2Y0MjY # 换成你自己的密钥 fernet Fernet(key) plaintext bhello, cryptography ciphertext fernet.encrypt(plaintext) print(ciphertext)输出是一长串 bytes比如gAAAAABm...。注意这里输入的plaintext必须是 bytes。如果你手上是字符串记得先用.encode(utf-8)转一下。解密时再用.decode()转回字符串。3.3 解密字符串decrypted fernet.decrypt(ciphertext) print(decrypted.decode())就这么简单。但有几个细节值得注意。第一decrypt()如果发现密文被篡改HMAC 校验失败会抛出cryptography.fernet.InvalidToken。这是好事说明你的数据完整性被保护了。在实际业务里你应该捕获这个异常而不是让它直接崩溃。第二Fernet 加密后的数据是带有时间戳的默认 token 包含时间戳所以你可以用fernet.decrypt(token, ttl300)设置令牌有效期。比如你生成一个重置密码的链接令牌五分钟后失效就可以通过 ttl 参数控制。这个特性我特别喜欢省得再单独存过期时间。第三如果你需要加密的不是短字符串而是大文件比如几 GB 的视频那直接把整个文件读进内存再加密就不太现实了。Fernet 对这种场景不太合适因为它的 API 是一次性处理一个完整的字节串。不过我们可以分块处理或者改用cryptography底层加密原语后面会讲。实际上cryptography官方并不建议用 Fernet 加密大文件因为一旦中间出问题整个 token 会失效。3.4 加密文件的简易方案分块读取虽然 Fernet 不适合超大文件但对几百 MB 以内的文件你可以用分块读取 自己管理块加密。不过这样要手动处理每一块的认证信息比较复杂。更实用的方式是用cryptography.hazmat.primitives.ciphers里的 AES-GCM 做流式加密。这个我们放到第 5 节再详细说。在这里先演示一下用 Fernet 加密整个小文件的最简单方式比如配置文件、文本文件几 MB 以内from cryptography.fernet import Fernet def encrypt_file(file_path, key): fernet Fernet(key) with open(file_path, rb) as f: data f.read() encrypted fernet.encrypt(data) with open(file_path .enc, wb) as f: f.write(encrypted) def decrypt_file(enc_path, key, output_path): fernet Fernet(key) with open(enc_path, rb) as f: data f.read() decrypted fernet.decrypt(data) with open(output_path, wb) as f: f.write(decrypted)这种方式简单粗暴但注意如果文件很大f.read()会把整个文件加载进内存容易 OOM。所以只建议小文件。文件加密后原始文件最好用安全方式删除比如shred或多次覆写否则等于白加密。4. 进阶使用 RSA 非对称加密实现密钥交换与数字签名Fernet 解决的是“同一个密钥加解密”的问题。但现实里你经常需要和第三方系统对接不可能把对称密钥直接塞给对方。这时候非对称加密就派上用场了。4.1 生成 RSA 密钥对用cryptography生成 RSA 密钥对非常简单from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization private_key rsa.generate_private_key( public_exponent65537, key_size2048, ) public_key private_key.public_key() # 序列化私钥PEM 格式PKCS8 private_pem private_key.private_bytes( encodingserialization.Encoding.PEM, formatserialization.PrivateFormat.PKCS8, encryption_algorithmserialization.BestAvailableEncryption(bmypassword) ) # 序列化公钥PEM 格式SubjectPublicKeyInfo public_pem public_key.public_bytes( encodingserialization.Encoding.PEM, formatserialization.PublicFormat.SubjectPublicKeyInfo )注意几个关键参数public_exponent65537是标准选择它能兼顾性能和安全不建议改成别的。key_size至少 2048 位再低就有安全风险。我见过一些老系统用 1024 位的密钥现在基本属于随时可被攻破的状态。私钥序列化时我建议一定要加上BestAvailableEncryption(bpassword)这样即使私钥文件泄露没有密码也解不开。BestAvailableEncryption默认使用 AES-256-CBC 加上 PBKDF2HMAC 派生密钥密码强度由你决定但别用太简单的。4.2 公钥加密、私钥解密RSA 加密有长度限制用 2048 位密钥最多只能加密 245 字节的数据2048/8 - 42 214? 实际上 PKCS1 v1.5 padding 最长为 11 字节需要具体计算但不展开太多。所以一般不会用 RSA 直接加密长文本。常见做法是“混合加密”用 RSA 加密一个随机生成的 AES 密钥再用 AES 密钥加密实际数据。下面演示一个最简单的公钥加密短文本私钥解密from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes message bsecret key: xxxxx ciphertext public_key.encrypt( message, padding.OAEP( mgfpadding.MGF1(algorithmhashes.SHA256()), algorithmhashes.SHA256(), labelNone ) ) plaintext private_key.decrypt( ciphertext, padding.OAEP( mgfpadding.MGF1(algorithmhashes.SHA256()), algorithmhashes.SHA256(), labelNone ) )这里用的填充方式OAEP是现在推荐的标准填充比旧版的 PKCS1v15 更安全。注意加密和解密时使用的 padding 参数必须保持一致包括 MGF1 里的哈希算法。我踩过坑本地用 SHA256服务端用 SHA1结果互相解不开。4.3 数字签名与验签非对称加密的另一大用途是数字签名。你用自己的私钥给一段数据签名接收方用你的公钥验证签名从而确认数据确实来自你且未被篡改。这在 API 对接中非常常用。from cryptography.hazmat.primitives.asymmetric import padding as asym_padding from cryptography.hazmat.primitives import hashes # 签名 signature private_key.sign( message, asym_padding.PSS( mgfasym_padding.MGF1(hashes.SHA256()), salt_lengthasym_padding.PSS.MAX_LENGTH ), hashes.SHA256() ) # 验签 try: public_key.verify( signature, message, asym_padding.PSS( mgfasym_padding.MGF1(hashes.SHA256()), salt_lengthasym_padding.PSS.MAX_LENGTH ), hashes.SHA256() ) print(签名有效) except Exception: print(签名无效)verify()如果失败会抛出异常。签名方案建议用 PSS概率签名比 PKCS1v15 更安全。salt_length设置为MAX_LENGTH是较为安全的选择但在跨系统对接时需要确认对方使用的salt_length是否一致否则验签会失败。4.4 混合加密实战用 RSA AES 加密传输数据实际业务里最常用的是混合加密。举个例子你向服务端上传一份敏感配置可以先在客户端生成一个随机的 AES 会话密钥用服务端的公钥加密这个 AES 密钥再用 AES 密钥加密配置文件。服务端收到后先用私钥解出 AES 密钥再用 AES 密钥解出配置。这样既解决了密钥分发问题又能高效加密大块数据。这个模式在cryptography里实现并不难但要小心编码问题。一般流程如下生成 32 字节随机数os.urandom(32)作为 AES 密钥。用 RSA 公钥加密这个 AES 密钥得到encrypted_key。用 AES-GCM 加密数据得到ciphertext tag nonce。把encrypted_key、ciphertext、tag、nonce打包比如用一个带版本号的 JSON 结构发给对方。对方解包后用 RSA 私钥解出 AES 密钥再用 AES-GCM 解密。由于cryptography的hazmat层提供了完整的 AES-GCM 支持这样实现起来非常直接。我把具体的 AES-GCM 用法放在下一节你可以组合起来用。5. 实操使用 AES-GCM 进行高效对称加密如果 Fernet 是“傻瓜相机”那么hazmat层的 AES-GCM 就是“专业单反”。它能给你更高的灵活性也能让你犯更多的错误。所以使用它时一定要深刻理解每个参数的含义。5.1 AES-GCM 是什么AES-GCMGalois/Counter Mode是 AES 的一种工作模式它同时提供加密和完整性认证是目前最推荐的对称加密模式之一。和 CBC 模式不同GCM 不需要额外的 HMAC因为认证标签已经内嵌在算法中。它的核心参数有三个key、nonce、associated data可选。key16/24/32 字节的对称密钥对应 AES-128/192/256。推荐使用 32 字节AES-256。nonce一个唯一的随机数通常 12 字节96 位。GCM 的安全性强烈依赖于 nonce 的唯一性。绝对不能使用相同的 key 和 nonce 组合加密两份不同的数据否则攻击者可以恢复出密钥变化。nonce 不需要保密但必须唯一。associated data可选指不加密但需要认证的数据。比如协议版本号、消息头等可以在解密时验证这些数据有没有被篡改。5.2 实现 AES-GCM 加解密下面是一个直接可用的加解密函数import os from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes def aes_gcm_encrypt(key: bytes, plaintext: bytes, aad: bytes b) - tuple[bytes, bytes, bytes]: nonce os.urandom(12) encryptor Cipher( algorithms.AES(key), modes.GCM(nonce), ).encryptor() encryptor.authenticate_additional_data(aad) ciphertext encryptor.update(plaintext) encryptor.finalize() return nonce, ciphertext, encryptor.tag def aes_gcm_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, tag: bytes, aad: bytes b) - bytes: decryptor Cipher( algorithms.AES(key), modes.GCM(nonce, tag), ).decryptor() decryptor.authenticate_additional_data(aad) try: plaintext decryptor.update(ciphertext) decryptor.finalize() except Exception: raise ValueError(认证失败密文或 associated data 被篡改) return plaintext用法示例key os.urandom(32) # 实际环境里 key 要妥善保存 nonce, ct, tag aes_gcm_encrypt(key, bimportant data, aadbheader-v1) pt aes_gcm_decrypt(key, nonce, ct, tag, aadbheader-v1) print(pt) # bimportant data注意解密时如果aad不一致finalize()会抛出异常。也就是说关联数据如果被改动认证就会失败。5.3 为什么要用 os.urandom 生成 nonceos.urandom是基于系统熵源的密码学安全随机数生成器。不要用random.random()生成 nonce因为普通伪随机数可以被预测。一旦 nonce 可预测AES-GCM 的安全性就会崩溃。另外nonce 的 12 字节推荐值是 GCM 的最优长度太短容易重复太长会有额外的 hash 开销。5.4 大文件流式加密AES-GCM 可以用来加密大文件。方法是分块读取文件对每个块调用encryptor.update()最后finalize()得到 tag。注意不要对每个块单独生成 nonce而是同一个加密操作中连续 update 多个块这样整份数据由同一个 nonce 和 key 保护。def encrypt_file_aes_gcm(key: bytes, input_path: str, output_path: str): nonce os.urandom(12) with open(input_path, rb) as fin, open(output_path, wb) as fout: # 先写 nonce后续解密时使用 fout.write(nonce) encryptor Cipher(algorithms.AES(key), modes.GCM(nonce)).encryptor() while True: chunk fin.read(64 * 1024) if not chunk: break encrypted_chunk encryptor.update(chunk) fout.write(encrypted_chunk) encryptor.authenticate_additional_data(b) # 如果有需要可传文件路径等元数据 encryptor.finalize() fout.write(encryptor.tag) # 最后写 tag解密同理先读 nonce 和 tag再分块解密。这种方式内存占用很小适合 GB 级别的文件。提示如果文件非常大而且需要断点续传加密会变得复杂。通常视频文件加密后会破坏原来的容器格式MP4/MKV播放器无法直接播放。这时要么设计自定义播放器要么使用支持加密的容器标准比如 HLS 的 AES-128 加密。那是另一个话题在这里先不展开。5.5 密钥派生从密码得到密钥很多业务场景里用户只愿意记一个密码不想管理 32 字节的随机 key。这时就需要把“密码”转成“密钥”也就是 KDFKey Derivation Function。cryptography提供了一个标准实现PBKDF2HMACfrom cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes import os password buser_password salt os.urandom(16) kdf PBKDF2HMAC( algorithmhashes.SHA256(), length32, saltsalt, iterations600000, ) key kdf.derive(password)iterations是迭代次数越慢越安全。OpenSSL 现在的默认迭代次数已经很高大约 600k所以 60 万次是合理选择。解密时需要重新用同一个 salt 和同样的迭代次数派生密钥所以 salt 必须和密文一起保存不需要保密。另外cryptography也支持更现代的 Argon2但那个算法在cryptography中不是内置的需要额外的库。如果你只是想从密码派生 AES 密钥PBKDF2HMAC 已经足够好而且兼容性最好。6. 常见问题与排查技巧实录封装层再多使用中还是会遇到各种问题。下面整理几个高频问题都是我实际项目中踩过的。6.1 InvalidToken解密失败现象调用fernet.decrypt()时抛InvalidToken。原因密钥不对、密文被篡改、密文不是合法的 Fernet token或者 ttl 超时。排查步骤确认密钥是否和加密时完全一致注意 bytes 类型别弄错编码。检查密文是否被完整复制有没有截断或中间有换行符。如果设置了ttl检查是不是超时了。检查密文是否来自同一个 Fernet 实例生成的密钥有些人把多个密钥混着用。6.2 ValueError: Fernet key must be 32 url-safe base64-encoded bytes现象实例化Fernet(key)时报错。原因key 不是合法的 32 字节 Base64 编码。常见于手动从别处复制密钥时把b后缀或多余字符也带进来了。还有可能是用了字符串而不是 bytes。解决确保 key 通过Fernet.generate_key()生成且类型为 bytes。如果你从环境变量读取记得os.environ[KEY].encode()。6.3 RSA 加密报错Data too long for key size现象public_key.encrypt()报 “Data too long for key size”。原因RSA 能加密的明文长度受密钥大小和填充方式限制。2048 位密钥 OAEP-SHA256 最多只能加密 190 字节因为 OAEP 至少占用 2*32266 字节。想加密更长的数据必须用混合加密。解决改用 AES-GCM 加密数据再用 RSA 加密 AES 密钥。6.4 非对称加密后返回的内容含特殊字符存不进 JSON现象直接把ciphertext、signature、tag这些 bytes 塞进 JSON报错无法序列化。解决用 Base64 编码。推荐base64.urlsafe_b64encode(data).decode()。解析时反向解码。注意不要直接用str(bytes)那只是 repr还原不回去。6.5 密钥泄露事故现象把私钥文件传到了 GitHub或者把 Fernet key 写在代码里。处理没有魔法只能立刻更换密钥并重新加密所有受影响数据。如果是私钥泄露还要吊销公钥信任。所以从一开始就要养成密钥管理的好习惯使用环境变量、密钥文件权限设为 600、或者接入 KMS。6.6 自定义错误解密时需要验签失败但不想抛异常Fernet 没有“尝试解密失败但不抛异常”的接口所以你要用 try-except 包住。但是注意InvalidToken在cryptography1.9 之前是继承自Exception的推荐直接捕获InvalidToken而不是捕获Exception避免掩盖其他逻辑 bug。6.7 跨版本兼容问题不同小版本的cryptography之间API 一般向后兼容但如果你序列化密钥时使用了非默认算法可能在新版本中弃用。比如serialization.BestAvailableEncryption在不同版本可能默认的 KDF 迭代次数不同。如果你需要长期保存密文建议在数据里存一个版本号并记录当时用的算法参数。否则几年后即使密钥还在也可能因为算法参数不匹配无法解密。场景推荐方案是否推荐字符串/小数据可逆加密Fernet推荐大文件加密AES-GCM 分块推荐密码哈希存储bcrypt / argon2推荐客户端-服务端密钥分发RSA AES 混合加密推荐数据完整性校验HMAC / 数字签名推荐做个简单的选型速查帮助你根据实际场景快速确定用哪个模块。7. 一些我踩过的坑和养成的习惯最后额外分享几个不在官方文档里却非常实用的经验。习惯一加密和解密的版本化设计。我会在密文里加一个魔数前缀比如ENC1:或者版本号 0x01来标识加密算法和参数。这样将来升级算法时还能读取老密文。Fernet 其实内部已经有版本号了但如果你使用 AES-GCM 自己封装非常建议加版本。习惯二永远不要用str存密钥。我会把密钥用bytes存储读取时也全程保持 bytes。一旦 str 和 bytes 混用编码和解码容易出乱子尤其在 Windows 下容易出现 Unicode 编码错误。习惯三定期轮换密钥。即使你的密钥目前没有泄露也应该设置轮换周期。如果你的业务允许每隔三到六个月轮换一次。轮换时保留旧密钥用于解密历史数据新密钥用于加密新数据这是业界标准的做法。习惯四加密后立刻测试解密。写完加密代码后我会在同一段代码里立刻写一个解密测试保证加密再解密后明文一致。这能第一时间发现编码、填充、密钥不匹配等问题而不用等系统上线后再排查。习惯五日志里绝不能打印明文密钥或明文密码。这点怎么强调都不过分。我见过有人为了调试直接把 Fernet key 打印到日志流里整个日志文件泄露等于明文泄露。如果确实需要调试用掩码处理比如只显示前四个字符。习惯六不要把内网和生产环境共用一把密钥。即使都是自己人环境隔离在密码学上同样重要。一旦内网密钥泄露攻击者可以直接解生产数据。习惯七测试时不要用真实密钥。单元测试里用固定的测试密钥是合理的但要用明显区分开的测试值比如btest-32-bytes-key-1234567890abcdef虽然长度可能不对但至少让人一眼知道这不是生产密钥。不要把生产密钥硬编码在测试代码里。8. 结尾关于密码学永远保持敬畏玩这个库几年下来我最大的感受是密码学的“安全”不是靠算法而是靠使用方式。cryptography库已经帮我们省去了大部分底层复杂度但密钥管理、随机数来源、算法组合这些事还是得自己把关。在最近的一个项目里我用 Fernet 加密数据库中的敏感字段用 RSA 做 API 的签名再配合 KMS 自动轮换密钥整体跑了一年多基本没出过问题。你可能会觉得加密和解密是小事一桩但真到出事那天后悔都来不及。希望这篇分享能帮你少踩一些坑在需要保护数据的时候能自信地写出安全的代码。如果后续你有更具体的加密场景欢迎在评论区聊我会结合自己的经验给出建议。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进