前段时间对接一个老系统的API,对方只给了Java的加密示例,我这边要用Python解密。看完代码我一愣,密钥不是直接用密码生成的,而是先丢进SecureRandom.getInstance("SHA1PRNG")里做种子,再通过KeyGenerator生成AES密钥。这种"Java味儿"很重的加密链路,Python标准库里根本没有对应实现。折腾了差不多一下午,最后靠一个叫aes-sha1prng的Python包才把问题解决。
今天这篇文章就专门拆解这个包:它解决什么问题、语法长什么样、参数怎么传、实际项目里有哪些典型用法,还有我踩过的几个坑。不管你是要做跨语言数据互通,还是接手了历史遗留系统的加解密逻辑,这篇文章应该都能帮你省不少时间。
1. aes-sha1prng 包的来历:为什么 Python 项目里会出现"Java 味"的 AES
要理解这个包,得先搞清楚它解决的到底是什么问题。很多老系统,尤其是银行、政务、部分电商平台,早期都是用Java写的。Java生态里有一套非常经典的对称加密写法:先用密码去初始化SHA1PRNG算法,把这个伪随机数生成器作为密钥生成的随机源,再让KeyGenerator基于这个随机源生成AES密钥。
1.1 Java 端 SHA1PRNG 到底做了什么
SHA1PRNG是Java里面SecureRandom的一种算法实现,全称可理解为"使用SHA-1算法实现的伪随机数生成器"。它的核心逻辑并不复杂:基于初始种子,利用SHA-1的哈希计算不断迭代,输出一串看起来随机、但实际可预测的字节序列。
在加密场景里,开发者通常用密码短语作为种子的初始值:
SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); random.setSeed(password.getBytes("UTF-8")); KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(128, random); SecretKey key = keyGen.generateKey();这套代码在Java 8甚至更早的版本里非常常见。关键是:最终生成的AES密钥,和密码之间有一个确定的转换关系。密码相同、JDK版本相同、调用顺序相同,生成的密钥就完全一致。
1.2 包在Python侧的定位和依赖
到了Python这边,pycryptodome提供了成熟的AES加解密能力,但它没有内置SHA1PRNG。如果你需要兼容Java产生的数据,就只能在Python里把SHA1PRNG算法重新实现一遍,再用它去生成AES密钥。
aes-sha1prng包做的就是这件封装工作。它底层依赖pycryptodome处理AES加解密,同时在内部复刻了SHA1PRNG的确定性密钥派生逻辑,对外提供了一套很简洁的API。
它不是让你用来替代cryptography或pycryptodome的通用加密库,而是专门服务于"Java加密、Python解密"或"Python加密、Java解密"这种跨语言场景的适配层。如果你新写的系统里只有Python,没有和Java互通的需求,这个包基本用不上。
1.3 包的适用边界
我觉得这个包最值得用的场景有三个:
- 历史数据迁移:旧系统库表里存了一批用Java AES加密的字段,新系统是Python,需要解密后继续使用。
- 混合架构对接:上游服务是Java写的,下游是Python写的,两边又绕不开同一个加密协议。
- 第三方合作接口:对方只给了一段Java示例代码,要求你用同样的算法加密请求参数。
在这些场景下,你缺的不是加密能力,而是一种"和Java行为对齐"的加密方式。用aes-sha1prng比自己在项目里写了半天SHA1PRNG实现要省心得多。
2. 环境准备与第一个加解密示例
我假设你已经具备基本的Python环境,Python 3.7及以上版本都能支持。这个包本身依赖pycryptodome,安装时会自动拉取,不用手动装。
2.1 安装步骤
直接用pip安装即可:
pip install aes-sha1prng如果线上环境对依赖版本管得比较严格,也可以指定版本,但一般来说使用最新版本就行。我本人在Python 3.9和3.11上都验证过,没有遇到兼容问题。
安装完成后验证一下:
python -c "import aes_sha1prng; print(aes_sha1prng.__version__)"如果输出了版本号,说明环境没问题。
2.2 快速上手的加密例子
这个包的核心类是AESCipher,一个很典型的用法如下:
from aes_sha1prng import AESCipher # 明文 plain_text = b"hello, aes-sha1prng!" # 初始化加密器 cipher = AESCipher( password="MyPassword2024", salt=b"fixed_salt_001", key_length=128, mode="CBC", iv=b"0123456789abcdef", padding="PKCS7" ) # 加密 encrypted_hex = cipher.encrypt(plain_text) print(encrypted_hex)输出是一串十六进制字符串。注意encrypt方法返回的不是bytes,而是十六进制编码后的字符串,这个设计是为了方便在日志、数据库和JSON中进行传输。如果你想要Base64格式,需要自己再包一层转换。
2.3 解密例子
解密同样简单:
cipher = AESCipher( password="MyPassword2024", salt=b"fixed_salt_001", key_length=128, mode="CBC", iv=b"0123456789abcdef", padding="PKCS7" ) decrypted = cipher.decrypt(encrypted_hex) print(decrypted)用同一个密码和参数初始化,再调用decrypt传入之前得到的十六进制字符串,就能还原出原始明文。这个对称的逻辑是整个包最核心的使用方式。
2.4 对返回值的处理建议
实际项目里,你可能会把加密结果直接存进MySQL的varchar字段,或者在HTTP接口里作为参数传递。我建议统一用十六进制字符串做中间格式,因为它在任何编码环境里都不会出错。不要直接把bytes丢进JSON里,序列化时很容易出乱子。
另一个建议是:把加解密的参数组合(密码、盐、IV、模式等)封装成一个配置类或字典,方便在多个地方复用。我第一次用这个包的时候,在每个函数里重复写参数,后来发现只要改一个IV,全项目要跟着动,很麻烦。
3. 语法与参数拆解:每个参数到底影响什么
这个包的使用难点不在方法调用,而在参数对齐。它的语法很简洁,真正需要理解的是那些看起来不起眼的参数——这些参数一旦和Java端不一致,加密出来的数据根本解不开。
3.1 构造函数的参数总览
下面是AESCipher构造函数的关键参数表,我按重要性排了个序:
| 参数名 | 类型 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|---|
password | str / bytes | 是 | 无 | 派生AES密钥的原始密码 |
salt | bytes / str | 否 | None | 盐值,Java老的实现里可当作额外种子 |
key_length | int | 否 | 128 | AES密钥长度,可选128、192、256 |
mode | str | 否 | "CBC" | 加密模式,可选CBC、ECB、CTR |
iv | bytes / str | 否 | None | 初始向量,CBC和CTR模式下建议显式指定 |
padding | str | 否 | "PKCS7" | 填充方式,可选PKCS7、NoPadding |
3.2 password 和 salt 的核心作用
password就是你在Java端传给setSeed的那个密码。salt则是额外种子。Java的老写法里,有时候会把盐拼在密码后面一起做种子,比如:
random.setSeed((password + salt).getBytes("UTF-8"));所以你在Python端设置password和salt时,要明确知道Java端到底是怎么拼的。是密码在前还是盐在前,中间有没有分隔符,这些细节都会直接影响密钥结果。
我踩过一次很蠢的坑:Java端用salt + password的顺序拼接,而我在Python端传成了password + salt,结果怎么解都解不出来,排查了一整个下午才发现是这个顺序问题。
3.3 key_length 和 mode 的匹配逻辑
key_length要和Java端的KeyGenerator.init(128, random)保持一致。Java端如果写128,Python这边就必须是128。192和256同理。
mode的话,Java常见的字符串是"AES/CBC/PKCS5Padding"或"AES/ECB/PKCS5Padding"。Python这边对应设置mode="CBC"或mode="ECB",填充方式设置padding="PKCS7"。
这里有个容易迷惑的点:Java的PKCS5Padding和Python的PKCS7其实是同一种填充算法,只是在Java里被叫成了PKCS5。你只需要记住,两边对齐时填PKCS7就好。
3.4 iv 参数:最容易被忽略导致解密失败
CBC模式下,IV必须和加密时保持一致。很多旧Java代码里,IV是固定写死的16字节数组。如果你在Python端没有显式指定iv,而Java端在加密时用了某个固定IV,那解密结果必然是一堆乱码或直接抛异常。
我建议这个参数永远显式传递,不要依赖包的默认行为。比如:
IV = b"fixed_iv_1234567" # 16字节如果Java端没有单独指定IV,通常默认为全零字节数组,那Python端也要对应写成b"\x00" * 16。
3.5 方法级参数:encrypt 和 decrypt
除了构造时的全局参数,encrypt和decrypt方法本身没有太多额外参数。基本用法就是传入明文或密文。
有一个小变体:某些版本的包可能支持在encrypt方法里临时覆盖mode或iv。但我实测下来,不建议这么做,很容易造成同一批数据参数不一致。最好所有参数都在构造时统一固定,方法里只传数据。
4. 实际应用案例:跨语言互通的三种典型场景
光讲语法不够,我结合自己做过和身边朋友遇到过的案例,整理出三个最典型的使用场景。这些场景基本覆盖了大多数人接触这个包的原因。
4.1 案例一:用Python解密历史遗留的Java密文
我接手过一个交易系统改造项目,老系统用Java把用户的手机号加密存在数据库里。新系统要复用这部分数据,但新团队全是Python技术栈。
老Java端的加密逻辑大致如下:
public static String encrypt(String content, String password) throws Exception { SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); random.setSeed(password.getBytes("UTF-8")); KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(128, random); SecretKey key = keyGen.generateKey(); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); byte[] ivBytes = new byte[16]; // 这里IV为全零 SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES"); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] result = cipher.doFinal(content.getBytes("UTF-8")); return Base64.getEncoder().encodeToString(result); }在Python端用aes-sha1prng解密,只需要这样写:
import base64 from aes_sha1prng import AESCipher def java_aes_decrypt(cipher_text_b64: str, password: str) -> str: cipher = AESCipher( password=password, key_length=128, mode="CBC", iv=b"\x00" * 16, padding="PKCS7" ) # 因为Java端做了Base64编码,先转成hex cipher_data = base64.b64decode(cipher_text_b64) plain_bytes = cipher.decrypt(cipher_data.hex()) return plain_bytes.decode("utf-8")这个场景的关键是:Java端用的IV是全零字节数组,所以Python端也必须指定iv=b"\x00" * 16。如果漏掉这个参数,包默认自动生成的IV和老数据对不上,解密百分百失败。这也是我反复强调要显式传IV的原因。
4.2 案例二:Python加密、Java解密
还有一类场景是反过来:Python端生成密文,交给Java端解析。这种情况通常是因为上游系统是Python写的,下游对接方是Java。
Python端加密:
from aes_sha1prng import AESCipher password = "shared-secret" plain = b'{"order_no": "20240601001", "amount": 99.50}' cipher = AESCipher( password=password, key_length=128, mode="CBC", iv=b"init_vector_16!", padding="PKCS7" ) encrypted_hex = cipher.encrypt(plain)然后把这个encrypted_hex交给Java端。Java端解密时,只需要按照同样的参数构造:
private static String decrypt(String hexData, String password) throws Exception { byte[] data = hexStringToByteArray(hexData); SecureRandom random = SecureRandom.getInstance("SHA1PRNG"); random.setSeed(password.getBytes("UTF-8")); KeyGenerator keyGen = KeyGenerator.getInstance("AES"); keyGen.init(128, random); SecretKey key = keyGen.generateKey(); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); SecretKeySpec keySpec = new SecretKeySpec(key.getEncoded(), "AES"); IvParameterSpec ivSpec = new IvParameterSpec("init_vector_16!".getBytes("UTF-8")); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] result = cipher.doFinal(data); return new String(result, "UTF-8"); }这个场景要注意的是:Python端encrypt方法返回的是十六进制字符串,Java端接收后需要先做一次hexStringToByteArray转换才能交给Cipher处理。别直接把字符串丢给doFinal,不然会报输入长度错误。
4.3 案例三:在Web服务里做敏感字段加解密
还有一种常见用法,是给Web接口的敏感字段做加密传输。比如用户在前端填了身份证号,经过Python后端再加解密,配合数据库存储。
我做过一个Flask服务,业务要求用户手机号在数据库里不能明文存储,但业务查询时又要能快速解密。我把加解密逻辑封装成一个工具模块:
from aes_sha1prng import AESCipher from django.conf import settings _cipher = AESCipher( password=settings.ENCRYPT_PASSWORD, salt=settings.ENCRYPT_SALT.encode("utf-8"), key_length=256, mode="CBC", iv=settings.ENCRYPT_IV.encode("utf-8"), padding="PKCS7" ) def encrypt_field(value: str) -> str: return _cipher.encrypt(value.encode("utf-8")) def decrypt_field(encrypted_value: str) -> str: return _cipher.decrypt(encrypted_value).decode("utf-8")实际用的时候,写入就走encrypt_field,读取就走decrypt_field。注意一点:salt在构造时我用的是字符串编码后的bytes,而Java端如果用字符串拼接,也要保持同样的UTF-8编码。这个例子说明,包本身不限定Web框架,只要把参数配置好,哪里都能用。
5. 最容易踩的坑:密钥不匹配、随机种子和长度截断
这一节我把实际过程中最容易出问题的几个点集中讲一下。很多问题不是包本身有bug,而是你根本不知道Java端当时是怎么写的,只能靠排查链路一点点对准。
5.1 坑一:Java 版本不同,SHA1PRNG 的种子行为不一样
这是个非常隐蔽的坑。同一个Java代码,在JDK 7和JDK 11里运行,生成出来的AES密钥可能是不同的。原因是Java在版本迭代中调整过SecureRandom的种子初始化逻辑,尤其是setSeed后再调用generateKey,不同版本的内部状态处理会存在差异。
所以当你碰到"Java端能解,Python端不能解"的情况时,先别怀疑Python包,先确认Java端到底是哪个JDK版本。如果老系统是很多年前写的,用的是JDK 6或7,那Python这边必须严格模拟老版本的SHA1PRNG行为。反过来说,如果Java端已经是新版本,建议让Java同事把实际密钥先打印出来,再和Python推导的密钥做对比。
5.2 坑二:密码和盐的拼接顺序、编码方式不一致
我在前面已经提过一次,拼接顺序会导致完全不同的密钥。这里再补充一个编码细节:Java里password.getBytes("UTF-8")和password.getBytes()默认编码在中文系统上有差异。如果密码内容包含中文或特殊符号,这个差异会直接导致密钥不一致。
Python端统一使用UTF-8编码传入,同时要和Java端确认对方是否也是UTF-8。最稳妥的办法是:Java同事打印一段固定密码生成的密钥十六进制,Python这边在同样的密码下也打印一份,两边放在一起比。如果不同,再逐个排查编码和拼接顺序。
5.3 坑三:填充模式不匹配导致解密报错
Java里的"AES/CBC/PKCS5Padding"对应Python的padding="PKCS7",这是配套关系。但如果Java端用的是"AES/CBC/NoPadding",而Python端默认是PKCS7,解密时会出现两种情况:要么报填充错误,要么解出来的明文末尾带着一堆\x00或特殊字符。
判断方法很简单:看Java端加密前是否有手动把明文长度补齐到16的倍数。如果Java端一直都是手动补齐,那Python这边就要设置padding="NoPadding",并且也要手动补齐或截断。
我建议在接口联调阶段就把填充模式写进技术文档,不要靠双方各自猜。这种细节在跨团队协作里最容易扯皮。
5.4 坑四:IV 长度不对或自动生成导致兼容失败
AES的IV长度通常是16字节。CBC模式下,如果iv不是16字节,包有可能自动截断或补零,这时候Java端和Python端用的IV就不是同一个值。
我之前调试过一个接口,Java端按规定传了16字节IV,Python端却不小心在配置里写成了字符串"0123456789",只有10字节。包的默认逻辑帮我补了6个零,结果两边IV不一致,解密全部失败。排查的时候从密钥到模式一路查下来,最后才发现是这种低级错误。
经验法则:直接把IV写成一个固定常量,并且写个测试断言len(iv) == 16。省得哪天谁改配置改漏了。
5.5 排查链路:从密钥对齐开始
如果你遇到加解密失败,不要盲目去翻业务代码。我习惯按下面的链逐步排查:
- 先确认Java端最终生成的AES密钥是什么,用十六进制打印出来。
- 在Python端用同样的密码、盐、key_length推导密钥,再打印出来。
- 对比两份密钥是否一致。不一致,问题出在SHA1PRNG参数上。
- 一致的话,再检查模式和IV是否一致。
- 模式、IV一致后,再检查填充方式。
- 前三步都对了,填充方式也对了,但仍然失败,大概率是密文在传输过程中被截断或编码转换出错。
下面是Python端打印密钥的示例代码:
from aes_sha1prng import AESCipher cipher = AESCipher( password="test-password", salt=b"fixed_salt", key_length=128, mode="CBC", iv=b"\x00" * 16, padding="PKCS7" ) print(cipher.key.hex()) # 这个属性在部分实现中是公开的如果你的运行环境里包没有暴露key属性,也可以用另一种方式验证:在Java端写一个临时方法,专门输出key.getEncoded()的十六进制,再在Python端用hashlib配合SHA1PRNG推导。如果两边一致,说明算法对齐了。
6. 对性能和安全的思考:引入这个包要注意什么
很多文章只讲怎么用,不讲该不该用、用了会有什么后果。这个包到底安不安全、性能怎么样、能不能长期依赖,我觉得有必要说明白。
6.1 SHA1PRNG 的安全定位
SHA1本身已经被密码学界认为不够安全,SHA1PRNG更不是为密钥派生设计的标准方案。它的最大问题在于:只要种子固定,密钥就是完全确定的,缺少足够强度的随机熵来源。这在现代加密实践里是不推荐的。
所以,aes-sha1prng包的核心使用场景是兼容老系统,而不是作为新项目的标准加密方案。如果你在绿地项目里用到它,我建议另外通过PBKDF2或scrypt来做密钥派生,再配合AES-GCM模式实现加密。这两种方案在cryptography库和pycryptodome里都有成熟实现。
6.2 性能方面的实测感受
AES加解密本身在Python里的性能不算极致,但对绝大多数业务场景来说完全够用。我实测过,用CBC模式加解密一段1KB的字符串,单次调用耗时在毫秒级别。如果你有大量数据需要加解密,建议把数据批量处理,而不是一条条循环调用。
aes-sha1prng包在密钥派生阶段有SHA-1迭代计算,相比直接用AES密钥要额外多出几毫秒开销,可以忽略不计。真正影响性能的是每次请求都重新初始化AESCipher对象。我在Web服务里通常把它做成模块级单例,避免每次都重复计算密钥。
6.3 生产环境的使用建议
如果你决定在项目里正式使用这个包,我给出几个个人建议:
- 将密码、盐、IV、密钥长度、模式等参数集中放到配置文件中,不要散落在代码各处。
- 在配置管理中启用环境变量,比如
ENCRYPT_PASSWORD、ENCRYPT_SALT,避免把机密参数写死在代码仓库里。 - 加解密工具模块必须配套单元测试,用固定的输入输出对做回归验证。
- 考虑在代码里做一层适配器,把
aes_sha1prng封装在内部。未来如果要迁移到更安全的加密方案,只需要替换适配器的实现。
7. 最后的实操体会与扩展方向
说实话,我第一次接触这个包的时候,也走过不少弯路。分享一个提升调试效率的小技巧:在本地准备一组固定的明文和密文对,专门用来验证环境是否正常。比如,固定密码为"debug-pass"、盐为"debug-salt"、IV为16字节的0,明文为"ping",然后把这组数据对应的密文保存下来。每次升级依赖或者换环境,先跑一遍这个测试,能迅速确认包的加解密行为有没有变化。
另外,如果你所在的项目同时存在多个Java老系统,且每个系统的SHA1PRNG参数还不一样,我建议不要共用一个AESCipher配置,而是按系统维度抽象出多个配置模板。比如定义成字典:
ENCRYPT_CONFIG = { "system_a": { "password": "pass-a", "salt": b"salt-a", "key_length": 128, "mode": "CBC", "iv": b"\x00" * 16, }, "system_b": { "password": "pass-b", "salt": b"salt-b", "key_length": 256, "mode": "ECB", "iv": None, }, }这样至少能让不同来源的密文各自按自己的规则解密,不会互相影响。
再深入一点,如果你不想依赖这个包,也可以用pycryptodome自己实现一套兼容层。核心就是实现SHA1PRNG的确定性字节生成逻辑:
import hashlib def sha1prng_generate(seed: bytes, length: int) -> bytes: state = hashlib.sha1(seed).digest() out = bytearray() while len(out) < length: state = hashlib.sha1(state).digest() out.extend(state) return bytes(out[:length])这只是SHA1PRNG行为的一个简化模型,真要兼容各种JDK版本的老代码,还是得靠足够多的样本数据去验证。也正因为这种兼容工作很琐碎,我反而推荐直接用现成的aes-sha1prng包,把精力放在业务改造上。
最后说一句:任何加密工具的引入都对项目的长期可维护性有影响。aes-sha1prng这类包是解决历史兼容问题的"救火队员",但不是一个长期的安全方案。等项目真正跑稳以后,我还是建议逐步把老链路替换为基于标准密钥派生函数和AEAD模式的现代方案,让解密逻辑掌握在自己手里,而不是绑定在某一个特定算法的复刻实现上。