用 Python 写的端到端加密通信工具。TLS 1.3 + external PSK 认证,在认证通道内再叠一层应用层 AES-256-GCM(X25519 交换 + 每条消息独立密钥 + 双向自动轮换)。
定位是端到端加密的临时通信通道,用于在互相信任的少数几方之间传输敏感数据。
项目目前仍处于开发阶段,如果你想参与开发请看下文工作清单
本项目适合想了解密码工程实践、或需要一条「开箱即用的加密信道」的开发者。全部加密逻辑集中在
tools/CryptoUtils.py,TLS 与 PSK 认证集中在Server/Client/ClientHandler,可以按下面的 API 自行二次开发。
- PSK 就是唯一凭据:双方预共享一个 PSK,认证在 TLS 握手内部完成(TLS 1.3 external PSK)。
- 认证失败发生在握手阶段:PSK 不对,TLS 握手直接失败,攻击者拿不到任何已认证的连接。
- 应用层再加密:TLS 之上叠一层独立密钥来源的 AES-256-GCM(X25519 + HKDF),两层密钥完全独立。
- 每条消息独立密钥:由「会话根密钥 + 序列号」通过 HKDF-SHA256 派生。
- 双向自动密钥轮换:任意一方发满 5 条消息即触发一次重新交换临时密钥,双方同时切换。
- 双重重放防护:每帧携带 8 字节时间戳 + 4 字节序列号(均在密文内,受 GCM 认证),接收端两者都校验。
- 统一帧格式:业务帧与控制帧共用同一种信封,
帧格式位于加密区内,外部既看不到消息类型也改不了它。 - 数据包大小伪造:可选的固定长度填充 / 随机长度填充,掩盖真实报文长度。
- 失败即断开:任一条消息解密或校验失败,立即终止该连接。
- 内存清理:敏感字节尽力清零并主动触发 GC。
请在采用前读完这一节。
- 不能真正从内存中清除敏感数据。Python 的
bytes不可变,tools/secure_memory.py对bytes入参只能清零一个临时副本;只有bytearray才能真正被覆盖。代码里已尽量用bytearray承载密钥,但无法覆盖全部路径。 - 不支持乱序与丢包恢复。序列号必须严格连续,收到断裂的序列号会按重放攻击处理并终止连接。底层是 TCP/TLS,正常不会乱序。
- 没有断线重连。
Client断开后需要重新connect()。 - 没有心跳机制。空闲连接不会自动探测存活。
- 双端同时发起刷新会失败。两端同时达到阈值时,双方各自提交新根密钥,而对端用旧根密钥加密的控制帧仍在路上,随后解密失败。目前应避免两端同时触发轮换。
| 环节 | 算法 |
|---|---|
| 准入认证 | TLS 1.3 external PSK(身份 + 预共享密钥,binder 校验) |
| 传输层 | TLS 1.3(psk_dhe_ke,带 ECDHE,具备前向安全) |
| 密钥交换 | X25519(应用层,256 位临时密钥) |
| 密钥派生 | HKDF-SHA256 |
| 数据加密 | AES-256-GCM |
| 完整性 | TLS 记录层 + AES-GCM 认证标签 + 时间戳/序列号校验 |
关于两层的关系:TLS 1.3 + PSK 已经提供了经过认证的加密通道;应用层的 X25519 + AES-GCM 是额外的一层(密钥来源与 TLS 完全独立),主要用于防止 TLS 被中途终结。如果你只信任一条链路,可以把应用层看作冗余。
PSK (双方带外共享, 1..64 字节)
↓ TLS 1.3 external PSK 握手(binder 校验)
经过认证的 TLS 通道
↓ 通道内交换 X25519 临时公钥
临时密钥对 (X25519, 每次握手/轮换重新生成)
↓ DH(自己的新私钥, 对方的新公钥)
共享密钥 (Shared Secret)
↓ HKDF-SHA256(salt="session_root")
会话根密钥 (Root Key) ← 每 5 条消息双方重新交换临时密钥
↓ HKDF-SHA256(salt="message_salt", info=seq)
消息密钥₁ 消息密钥₂ 消息密钥₃ ...
↓ ↓ ↓
AES-GCM AES-GCM AES-GCM ← 每条消息一个密钥 + 独立随机 nonce
两端的收发各自一条独立序列号链:send_seq 只在自己发送时递增,recv_seq 只在收到消息时递增。因此收发交替、任意方向连发都不会错位。
pip install cryptography没有任何证书要生成。PSK 由你自己生成(或由服务端生成后带外交给客户端):
import secrets
psk = secrets.token_bytes(32) # TLS 1.3 要求 1..64 字节import asyncio
from server import Server
PSK = b"\x01\x02..." # 与服务端共享的那个 PSK,务必带外安全传递
async def main():
server = Server(
host="127.0.0.1",
port=5555,
psk_key=PSK,
padding=1, # 数据包填充级别:0 不填充 / 1 固定 / 2 随机
)
conn = await server.accept() # 阻塞直到有客户端通过 PSK 认证
print("客户端已连接")
data = await conn.receive() # 收到字符串;None 表示断开
print(f"收到: {data}")
await conn.send("Hello From Server")
await conn.close()
await server.stop()
if __name__ == "__main__":
asyncio.run(main())import asyncio
from client import Client
PSK = b"\x01\x02..." # 与服务端一致
async def main():
client = Client(
host="127.0.0.1",
port=5555,
psk_key=PSK,
)
await client.connect() # PSK 不对会在这里直接失败
await client.send("Hello From Client")
print(await client.receive())
await client.close()
if __name__ == "__main__":
asyncio.run(main())每一帧由 NetworkBase._send_raw() 补上 4 字节总长,之后只有 nonce 是明文:
┌────────┬───────────┬──────────────────────────────────────────────────────┐
│ 总长 4 │ nonce 12 │ AES-GCM( 时间戳8 + 序列号4 + 帧格式4 + 数据 ) + tag │
└────────┴───────────┴──────────────────────────────────────────────────────┘
明文 明文 密文
| 字段 | 说明 |
|---|---|
| 总长 | 由 NetworkBase 使用,保证按帧读取 |
| nonce | 每条消息独立随机;解密前必须拿到,因此是全帧唯一留在明文里的字段 |
| 密文 | 时间戳、序列号、帧格式、数据全部在 AES-GCM 之内 |
时间戳和序列号也在密文里,外部观察者既读不到消息序号,也改不了它们——任何篡改都会导致认证失败。所有帧(含控制帧)都占一个序号。
┌────────────┬────────────┬──────────┬────────────────────────┐
│ 时间戳 8 │ 序列号 4 │ 帧格式 4 │ 业务数据 / 控制载荷 │
└────────────┴────────────┴──────────┴────────────────────────┘
接收端用自己的计数器(recv_seq,收发各一条、初始为 1、每帧加 1)派生本条消息密钥,解密后再校验密文内的时间戳(300 秒窗口)与序列号是否一致。
| 值 | 名称 | payload |
|---|---|---|
0x01 |
业务帧 | 业务数据(padding != 0 时前面多 4 字节原文长度,尾部是填充) |
0x11 |
REFRESH_KEY |
发起密钥刷新,本方新临时公钥(32 字节,同样参与填充) |
0x12 |
REFRESH_ACK |
响应密钥刷新,本方新临时公钥(32 字节,同样参与填充) |
0x13 / 0x14 |
心跳 | 预留 |
0x21–0x24 |
文件传输 | 预留(开始 / 数据块 / 结束 / 中止) |
0:不填充
1:固定大小填充,补到 128 字节的整数倍
2:随机大小填充,增加 1 ~ min(256, 剩余可用空间) 字节
填充在 _send_frame() 里对所有帧统一执行:先写 4 字节原文长度,再追加填充,整段一起被加密;接收端按这个长度截断,填充内容直接丢弃。
因为业务帧与控制帧(0x11 / 0x12)走同一套填充规则,两者在 padding = 1 下的线上长度完全相同,无法通过报文大小区分出"这是一次密钥轮换"。
客户端 服务端
│ │
│ ── TCP 连接 ──────────────────────────────> │
│ │
│ ── 阶段 1:协商(明文)───────────────────> │
│ <─ 编码格式 / 填充级别 ──────────────────── │
│ ── "OK" ──────────────────────────────────> │
│ │
│ ── 阶段 2:STARTTLS ───────────────────────> │
│ <─ "READY" ──────────────────────────────── │
│ │
│ ══ 阶段 3:TLS 1.3 + PSK 认证 ═════════════ │
│ writer.start_tls(带 PSK 回调的 context) │
│ ← PSK 不对就在这里失败,后面的阶段不会发生 │
│ │
│ ── 阶段 4:应用层 X25519 握手(TLS 通道内)─> │
│ <─ 交换临时公钥,双方各自 init_session ──── │
│ ── "Client Hello" ────────────────────────> │
│ <─ "Server Hello" ───────────────────────── │
│ │
│ ══ 阶段 5:加密通信 ═══════════════════════ │
│ ── 每条消息独立密钥的 AES-GCM ────────────> │
│ <─ 双方各自发满 5 条即自动轮换密钥 ─────── │
│ │
│ ── 关闭 ──────────────────────────────────> │
密钥轮换复用同一套加密帧,只是把帧格式换成 0x11 / 0x12:
0x11 REFRESH_KEY payload = 我方新临时公钥(32) # 发起轮换
0x12 REFRESH_ACK payload = 我方新临时公钥(32) # 响应轮换
控制帧与业务帧共用一条序列号,因此也会消耗序号。读循环解密后按帧格式分派,控制帧内联处理(不经过额外队列),保证"提交新根密钥"发生在读取下一帧之前。
任一方都可发起;但两端同时发起会让双方各自提交新根密钥,而对端用旧根密钥加密的控制帧仍在路上,导致随后的认证失败(见已知局限)。
底层定长收发与填充处理。
| 方法 | 说明 |
|---|---|
_recv_exact(n) |
精确接收 n 字节 |
_send_raw(data) |
发送一个带长度头的原始帧 |
_recv_raw() |
接收一个原始帧 |
_add_padding(data) / _remove_padding(data) |
填充与去填充 |
close() |
关闭底层连接 |
| 方法 | 说明 |
|---|---|
generate_keypair() |
生成 X25519 临时密钥对 |
init_session(private_key, peer_public_key) |
用已交换的临时私钥建立会话,返回自己的公钥 |
refresh_session(own_private_key, own_public_key, peer_public_key) |
双方新公钥到齐后提交一次会话轮换 |
derive_message_key(seq) |
由根密钥派生某序列号的消息密钥 |
derive_shared_key(private_key, peer_public_bytes) |
X25519 交换出共享密钥 |
shared_key_derive_aes_key(shared_key, salt) |
由共享密钥派生 AES 密钥 |
aes_encrypt(data, seq, frame_type) |
加密 时间戳 + 序列号 + 帧格式 + data,返回 nonce + 密文 |
aes_decrypt(ciphertext, seq, nonce) |
解密并校验密文内的时间戳与序列号,返回 帧格式(4) + 明文 |
clear_session() |
清除会话密钥 |
Client 与 ClientHandler 共用的刷新组件(tools/Refresher.py)。刷新相关的全部状态(是否正在刷新、待处理标记、等待 ACK 的 future、临时密钥对)都集中在它这里;发帧动作交给宿主,由宿主注入的 send_frame 负责分配序号、加密和写入。
Refresher(logger, crypto, send_frame)| 参数 | 说明 |
|---|---|
send_frame |
宿主注入的发送方法 async (frame_type: int, payload: bytes) -> None,负责填充、分配序号、加密与写入 |
刷新只改
CryptoUtils内部的根密钥,宿主持有的密钥副本已不再需要,因此没有"提交后同步回宿主"的回调。
| 方法 / 属性 | 说明 |
|---|---|
await refresh_keypair() |
主动发起:发 0x11,等对方 0x12 后提交 |
await respond_key_refresh(pub) |
响应请求:回 0x12(本方新公钥)后提交 |
handle_refresh_ack(payload) |
宿主收到 0x12 时调用,把 payload 交给等待者 |
request_pending_refresh() |
宿主发现 send_seq 超阈值时调用,登记一次待处理刷新 |
await wait_until_idle() |
等待当前刷新结束 |
is_refreshing / pending_refresh |
当前是否正在刷新 / 是否有待处理的刷新 |
wipe() |
关闭连接时清理一次性密钥 |
之所以把发帧交给宿主:序号分配、加密、写入必须在同一把锁内完成,否则并发的控制帧会与业务帧拿到同一个序号。
Refresher只负责何时发、发什么,不关心线路格式。
Client(host: str, port: int, psk_key: bytes)| 方法 | 说明 |
|---|---|
await connect() |
连接并完成协商、TLS-PSK 认证与应用层握手 |
await send(data) |
加密并发送(发满 5 条自动触发一次轮换) |
await receive() |
接收并解密;返回 None 表示对端已断开 |
await close() |
关闭连接并清理密钥 |
连接只有一个读协程:收帧 → 解密 → 推进序号 → 按帧格式分派。控制帧在这一步内联处理,业务帧进入队列由
receive()取出。没有独立的控制帧循环,因此不存在"ACK 还没提交、下一帧已经开始解密"的窗口。
Server(host, port, psk_key: bytes, padding=0, encoding="utf-8")| 方法 | 说明 |
|---|---|
await accept() |
启动监听并等待一个通过 PSK 认证的连接,返回 ClientHandler |
await stop() |
停止监听(不关闭已建立的连接) |
由 Server.accept() 返回,接口与 Client 对称:
| 方法 | 说明 |
|---|---|
await send(data) |
加密并发送 |
await receive() |
接收并解密;None 表示对端断开 |
await close() |
关闭连接 |
PyEndCrypt/
├── README.md
├── LICENSE
├── server.py # 服务端:监听、PSK 认证、接受连接
├── client.py # 客户端:连接、STARTTLS、PSK 认证、收发
└── tools/
├── CryptoUtils.py # 应用层加解密与密钥派生
├── NetworkBase.py # 定长收发基类 + 填充
├── ClientHandler.py # 服务端单连接处理器
├── Refresher.py # 会话根密钥刷新器(客户端/服务端共用)
├── secure_memory.py # 敏感内存清理
├── Logger.py # 日志(文件 + 控制台,同名复用)
├── exceptions.py # 异常体系
└── __init__.py
已完成:
- ⚫
添加客户端身份验证(已改为 TLS 1.3 external PSK) - ⚫
将print()替换为日志系统 - ⚫
服务端自动生成证书和密钥返回给客户端(证书体系已整体移除) - ⚫
优化异常处理 - ⚫
从内存中销毁密钥(受 Python 限制,见已知局限) - ⚫
实现异步 - ⚫
实现多客户端并发处理 - ⚫
每条消息独立密钥 + 双向自动密钥轮换 - ⚫
移除 mTLS,改用 PSK 作为唯一凭据 - ⚫
把密钥刷新逻辑抽成tools/Refresher.py,客户端与服务端共用 - ⚫
统一帧格式:帧格式放进加密区,业务帧与控制帧共用一种信封 - ⚫
去掉独立控制帧循环,改为读循环内按帧格式分派
待办:
- 🔴 修复"双端同时发起刷新"导致的认证失败(上一把根密钥回退 / 单端发起 / 三次握手)
- 🟡
Server.stop()关闭已建立的客户端连接 - 🟡 主动关闭指定客户端(
Server.close_client()) - 🟡 断线重连
- 🟡 心跳机制
- 🟡 中转服务器
本项目仅用于学习和技术研究,严禁用于任何违法犯罪活动。 使用者必须遵守当地法律法规,并承担使用责任。 本项目开发者不承担因该项目引起的任何法律责任。