简介:这是一份基于Rijndael算法实现AES(128)加密/解密功能的Python源码工具包,面向学习对称加密原理的初学者,以及需要在项目中快速集成加密能力的开发者。工具严格遵循AES-128标准规范,采用128位密钥,在合理使用下具备较高的安全性;支持对任意字节类型文件进行加解密,不限于文本,且密钥长度限制在16个符号以内。代码提供两种调用方式:可将aes128.py作为模块导入到自己的项目中使用,也可直接运行main.py进入命令行交互界面完成加解密操作,输入输出类型均在文档字符串中有清晰说明。包内共4个文件,包含2个Python脚本、1个说明文档及1个gitignore配置,压缩包仅6KB,结构轻量,便于逐行阅读核心加密逻辑并进行二次封装。目前已有218人学习浏览,适合作为算法入门参考或小型工具直接使用。
1. 从 Rijndael 到 AES-128:这个 Python 实现解决什么问题
拿 Rijndael 密码算法练手,很多人第一反应是去查 OpenSSL 命令,其实一个 Python 文件也能把 AES-128 的加解密完整跑通。这个项目把 Rijndael 按 128 位密钥实现成aes128.py,再配一个main.py做交互式 CLI。
它能解决两类需求:在 Python 里加密配置或临时文件,不想为一个功能引入 pycryptodome;或者你想找一份足够短的代码,逐行读懂 S 盒、行移位、列混合这些教材概念。128 位密钥对应 16 字节,所以密钥里的符号必须能用单字节表示,ord()返回值小于 255,中文或 Emoji 直接当密钥会出问题。
2. 先看懂 Rijndael:密钥扩展与轮函数的字节级运算
AES-128 的输入输出都是一个 4 乘 4 的字节矩阵,密钥也是 16 字节。加密过程可以概括为「轮函数循环 10 次」,最后输出 16 字节密文。这一章只讲和代码直接相关的三件事:密钥怎么扩展、轮函数里四个操作分别做什么、以及如何用已知测试向量验证你的实现没有写歪。
2.1 为什么是 128 位密钥与 10 轮
Rijndael 算法支持 128/192/256 三种密钥长度,对应的轮数分别是 10/12/14。这个项目明确只支持 128 位,意味着你要传入 16 字节密钥,数据分组也是 16 字节。密钥长度决定轮数,轮数越多扩散越充分,但也越慢。对大多数业务数据来说,128 位与 192 位之间的差距在实际攻击成本上几乎可以忽略,选择哪版主要是看兼容性要求。
| 密钥长度 | 密钥字节数 | 轮数 | 扩展后密钥长度 |
|---|---|---|---|
| 128 位 | 16 | 10 | 176 字节 |
| 192 位 | 24 | 12 | 208 字节 |
| 256 位 | 32 | 14 | 240 字节 |
选型理由:128 位是性价比最高的档位,不追求超高安全等级时足够用。该实现把长度限制在 16 字节,反而省掉了检查三种长度的分支,适合教学和内部工具。
2.2 密钥扩展:从 16 字节到 176 字节的编排
AES 每一轮需要一个 16 字节轮密钥,10 轮加初始密钥一共 11 组,也就是 176 字节。常见做法是把 16 字节密钥先切分成 4 个 4 字节字,再迭代 40 轮生成 44 个 4 字节字。每轮的关键动作是 RotWord、SubWord 和轮常数 RCON。
# 密钥扩展:输入 16 字节,输出长度为 11 的子密钥列表 def expand_key(key: bytes, sbox: list[int]) -> list[bytes]: assert len(key) == 16, "AES-128 key must be 16 bytes" rcon = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36) # 初始 4 个字,每字 4 字节 w = [bytearray(key[i:i + 4]) for i in range(0, 16, 4)] for i in range(4, 44): temp = bytearray(w[i - 1]) if i % 4 == 0: # RotWord: 循环左移一个字节 temp = temp[1:] + temp[:1] # SubWord: 逐个查 S 盒 temp = bytearray(sbox[b] for b in temp) # 与轮常数异或,只影响第一个字节 temp[0] ^= rcon[i // 4 - 1] # 新的字 = 前 4 轮之前的字异或 temp w.append(bytearray(a ^ b for a, b in zip(w[i - 4], temp))) return [bytes(w[i]) for i in range(0, 44, 4)]sbox 是 256 字节查找表,这里假设已经生成好。rcon 只用了 10 个值,索引i // 4 - 1在第 4 轮开始使用。注意zip(w[i - 4], temp)是逐字节异或,不要直接对 bytes 做加法。输出是 11 份 16 字节的轮密钥,后续每一轮加密取用一份。
2.3 加密轮函数四大步骤的 Python 对照
16 字节明文按列填充成状态矩阵,然后进行 10 轮变换。这里给出常规实现的轮函数骨架:
def encrypt_block(state: bytearray, round_keys: list[bytes]) -> bytes: # 初始轮密钥加 state = bytearray(a ^ b for a, b in zip(state, round_keys[0])) # 中间 9 轮:完整的四步 for r in range(1, 10): state = sub_bytes(state) # 字节代换 state = shift_rows(state) # 行移位 state = mix_columns(state) # 列混合 state = bytearray(a ^ b for a, b in zip(state, round_keys[r])) # 最后一轮不做列混合 state = sub_bytes(state) state = shift_rows(state) state = bytearray(a ^ b for a, b in zip(state, round_keys[10])) return bytes(state)round_keys是上一节expand_key的返回值,长度 11,每个元素 16 字节。sub_bytes和shift_rows操作的是同一个 16 字节状态,行移位本质是索引重排,所以源码里经常用查表实现。最后一轮省去列混合不是偷懒,而是 Rijndael 设计文档里的标准结构,解密时对应逆过程同样要遵循这一结构。
验证方法:AES 标准文档 FIPS-197 附录 B 有一个经典测试向量,密钥为000102...0f,明文为001122...ff,密文应为69c4e0d86a7b0430d8cdb78070b4c55a。如果你的aes128.py暴露了内部函数,可以这样自检:
from aes128 import encrypt_block, expand_key key = bytes.fromhex("000102030405060708090a0b0c0d0e0f") pt = bytes.fromhex("00112233445566778899aabbccddeeff") ct = encrypt_block(pt, expand_key(key)) assert ct.hex() == "69c4e0d86a7b0430d8cdb78070b4c55a" print("ok")注意:不同实现里expand_key的返回类型不一致,有的项目把所有轮密钥拼成一个 176 字节的 bytes,有的返回 11 个子密钥列表。当你自检测试向量失败时,先确认这里,再查sub_bytes和mix_columns的顺序。轮函数只要差一步,密文就会全盘错位,而这种错位在断言里不会有任何中间提示。
3. 导入 aes128.py:按字节输入输出才是正路
这个库的使用文档很短,核心就一句:把aes128.py放到项目目录或 PYTHONPATH,然后导入。真正容易踩坑的是密钥长度和编码,下面把三种调用方式写清楚。
3.1 先决定文件放哪:项目目录还是 PYTHONPATH
最简单的方式是把文件放在当前项目根目录,然后直接导入。检查导入是否成功:
ls -l aes128.py python -c "import aes128; print(aes128.__file__)"如果文件放在其他路径,可以通过 PYTHONPATH 临时加入:
export PYTHONPATH="/your/path/to/aes:$PYTHONPATH" python -c "from aes128 import encrypt; print(encrypt)"PYTHONPATH 环境变量只在当前 shell 生效,写进脚本更稳妥的办法是在文件顶部用sys.path.insert(0, "/your/path/to/aes")临时追加路径,但不建议污染项目路径。放在项目目录是唯一不需要额外配置的方式,也是这个压缩包的默认使用姿势。
3.2 密钥长度与字符集的硬约束
项目说明里写了「密钥应少于 16 个符号」,严格说应该是 16 字节。如果你传入少于 16 字节,常见处理是用0x00在尾部补齐,达到 16 字节;如果多于 16 字节,大概率触发 AssertionError。这里有一个容易踩的点:ord()必须小于 255,也就是说密钥只能由单字节的 Latin-1 字符组成。中文的ord()会大于 255,存成 bytes 时是多个字节,不等价于单符号。
因此密钥建议直接用bytes:
key = b"0123456789abcdef" # 16 字节,合法 key2 = "short-key".encode("latin-1") # 9 字节,缺了会补 0如果你确实需要输入一个中文口令,先做哈希再截断到 16 字节,例如:
import hashlib key = hashlib.sha256("你的口令".encode("utf-8")).digest()[:16]这样得到的密钥是 16 字节,且与你输入的中文口令一一对应。注意哈希后密钥长度固定,解密时用同样的口令就能恢复。
3.3 一段可直接抄的加解密代码
假设aes128.py暴露的是函数式接口,常见命名是encrypt(plaintext, key)和decrypt(ciphertext, key)。拿到代码后先看一眼def行,如果里面是类,就改成cipher = AES128(key)的调用方式。
函数式版本:
from aes128 import encrypt, decrypt key = b"0123456789abcdef" plaintext = b"hello Rijndael" ciphertext = encrypt(plaintext, key) recovered = decrypt(ciphertext, key) assert recovered == plaintext所有输入输出都是bytes,不是字符串。encrypt返回的密文长度一般等于明文长度经分组填充后的长度,如果明文长度正好是 16 的整数倍,输出长度和输入一致。decrypt会做逆操作,最后返回去除填充后的原始字节流。
如果源码是类封装,则:
from aes128 import AES128 cipher = AES128(key) ciphertext = cipher.encrypt(plaintext) recovered = cipher.decrypt(ciphertext)类的构造函数通常接收密钥,之后复用同一个密钥做多次加解密;函数式接口则每次都要把 key 作为参数传进去,两者各有适用场景。类封装更接近面向对象习惯,函数式写起来更直接。
3.4 报错信息与排查方向
下表是常见问题,按出现频率排序:
| 报错或现象 | 原因 | 处理方式 |
|---|---|---|
AssertionError | 密钥长度不是 16 字节 | 补足或截断到 16 字节 |
TypeError | 传入 str 而不是 bytes | 用.encode("latin-1")转换 |
UnicodeEncodeError | 密钥含中文或 Emoji | 改用 ASCII 密钥,或先哈希再截断 |
| 解密后得到乱码 | 密钥不一致或未去掉 PKCS7 填充 | 核对密钥,检查解密函数是否自动 unpad |
| 密文长度变成 0 | 输入为空 bytes,填充逻辑没处理 | 手动补一整个分组再调用 |
空 bytes 是很多 AES 实现会忽略的边界。如果库里没有做填充,空输入可能会在分组循环里直接返回空内容。对文件加密来说,空文件也应该加密成一整个分组,否则无法区分空文件和未加密的失败;在正式使用前先用一个空文件跑一次加解密,确认输出长度是 16 的倍数。这一点在跑main.py时尤其明显,下一章会看到 CLI 的表现。
4. main.py 的 CLI:交互式加密文件的操作示范
虽然多数人用这个项目是 import,但main.py提供了一个不用写 Python 也能加密文件的入口。README 说它是个「shy CLI」,意思就是没有 argparse 参数,全靠input()一步一步问。这在大批量操作时不方便,但配合管道重定向依然能脚本化。
4.1 启动与交互流程
进入项目目录后直接运行:
python main.py启动后会依次询问操作类型、文件路径、密钥。交互过程中会问你三件事,含义如下:其中操作类型只有e和d两个合法值,传其他字符会走 else 分支导致流程重来。
[e]ncrypt or [d]ecrypt? e Input file: ./demo.txt Key: 0123456789abcdef Saved to ./demo.txt.aes| 输入项 | 典型值 | 说明 |
|---|---|---|
| 操作类型 | e或d | e加密,d解密 |
| 输入文件 | ./demo.txt | 路径可以是相对路径或绝对路径 |
| 密钥 | 0123456789abcdef | 程序内部同样受 16 字节限制 |
如果你第一次运行时报模块找不到,多半是先运行了main.py但aes128.py不在同一个目录。把两个文件放在同一层再试。另外,如果你在虚拟环境里运行,确认当前目录在sys.path中,Python 3 默认不会把脚本所在目录以外的地方加进来。
4.2 用 heredoc 自动应答完成文件加密
交互式 CLI 的优点是直观,缺点是每次都要敲三行。想批处理时,可以用 heredoc 把输入一次喂给程序:
printf 'hello AES-128\n' > demo.txt python main.py <<'EOF' e demo.txt 0123456789abcdef EOF命令逻辑:printf生成一个测试文件;heredoc 的三行严格按照main.py的input()顺序排列,分别是操作类型、文件名、密钥。如果程序还要求确认覆盖输出之类的额外输入,根据实际提示再补一行。
运行结束后检查输出:
ls -l demo.txt demo.txt.aes xxd demo.txt.aes | headdemo.txt.aes就是加密产物,内容已经是二进制乱码。解密时把操作类型改成d,输入文件改成demo.txt.aes,密钥保持一致即可。
4.3 CLI 在二进制、空文件和大文件上的表现
main.py读取文件时用的是字节流而非文本模式,所以对图片、压缩包、SQLite 数据库都一样处理。需要注意三个边界:
| 文件类型 | 预期表现 | 注意事项 |
|---|---|---|
| 文本文件 | 正常加密/解密 | 用xxd或od查看密文 |
| 二进制文件 | 正常处理 | 不要用文本编辑器打开密文 |
| 空文件 | 可能输出 0 字节或 16 字节 | 取决于是否做了填充,先确认再用于正式流程 |
| 超大文件 | 一次性读入内存 | 几百 MB 文件可能内存紧张,应改流式读取 |
如果你要把这个 CLI 接入自动化流程,建议不要直接用input()交互,而是写成命令行参数版本。一个常见做法是复制main.py再加argparse,把交互逻辑换成:
import argparse parser = argparse.ArgumentParser() parser.add_argument("action", choices=["e", "d"]) parser.add_argument("input", type=str) parser.add_argument("key", type=str) args = parser.parse_args()这段代码替换掉原来的input(),就能在 CI 或 shell 脚本里直接传参。因为aes128.py本身不依赖第三方库,改造后部署到服务器很轻。
5. 进一步加固:给 ECB 补上 CBC 与完整性校验
到这里你已经能加解密了,但要注意:如果aes128.py只实现了最基本的 ECB 模式,同一明文块永远得到同一密文块,这在文件头部有固定结构时可能泄露信息。一个低成本改进是手动包装 CBC 模式,把 IV 和密文放一起保存。
最小 CBC 加密核心:
import os from aes128 import encrypt, decrypt iv = os.urandom(16) # 每个文件生成一次 prev = iv output = bytearray(iv) # 密文文件 = IV + 加密后的分组 for i in range(0, len(plaintext), 16): block = plaintext[i:i+16] if len(block) < 16: block = block + b"\x00" * (16 - len(block)) # 简单补零 block = bytes(a ^ b for a, b in zip(block, prev)) enc = encrypt(block, key) output += enc prev = enc解密时先读前 16 字节作为 IV,再对每个密文分组解密,然后与前一密文分组异或。plaintext必须是bytes,encrypt函数返回 16 字节定长密文;prev在加密侧是当前分组的密文,在解密侧就是前一个密文分组,iv不需要保密,但要保持随机。
补零填充比 PKCS7 简单,但解密后无法判断原始长度。更好的做法是记录原始长度或使用 PKCS7。验证时也要防止「解密成功但内容是脏数据」:可以把hmac结果附在密文尾部:
import hmac, hashlib mac = hmac.new(key, output, hashlib.sha256).hexdigest() with open("demo.bin", "wb") as f: f.write(output + mac.encode("ascii"))运行时再算一次,比较两个 mac 是否相等,不等就说明文件被改动或密钥不对。这个技巧特别适合替代简单 CRC 校验,因为 HMAC 带密钥,伪造不了。
本文还有配套的精品资源,点击获取