docs: add secspider design spec

This commit is contained in:
Harold
2026-04-25 14:43:20 +08:00
parent a33bbcda20
commit faccc8431a
@@ -0,0 +1,524 @@
# SecSpider 加密插件设计
## 目标
为当前 Spider 插件体系设计一套可落地的“源码封包”机制,用于隐藏 Spider 明文源码,避免普通用户直接打开插件文件即可看到实现细节。
本次设计面向的宿主前提如下:
- 宿主支持修改加载流程
- 插件既可能来自本地文件,也可能来自远程下载
- 宿主本身是开源可见、运行在用户设备上的 Python 客户端
- 现有 Spider 插件接口保持不变,仍以 `Spider` 类作为宿主入口
本次设计的核心目标是:
- 让发布物不再是直接可读的 Python 明文源码
- 支持宿主对插件做来源校验和完整性校验
- 在不改变 Spider 对外接口的前提下完成加载
- 保持明文插件与加密插件双栈兼容,便于渐进迁移
本次设计不追求:
- 防住能修改宿主、抓内存、下断点的逆向者
- 做在线授权平台
- 做设备绑定、过期控制或远程吊销
- 让用户设备上的插件“不可提取”
## 背景与边界
当前宿主 `SpiderPluginLoader` 使用 `importlib.util.spec_from_file_location(...)` 直接执行本地 Python 文件,并约定模块中导出 `Spider` 类。该机制适合明文源码插件,但不适合密文封包。
由于本次目标是隐藏源码而不是彻底防逆向,设计必须先承认以下边界:
- 只要插件最终要在本地运行,明文源码或等价字节码一定会在某个时刻进入解释器
- 解密端开源且跑在用户设备上,因此任何宿主内置密钥都可能被逆向提取
- 这意味着方案的安全收益是“提高获取源码的门槛”,而不是“保证源码永不泄露”
因此,本次设计定位为:
- 源码封包机制
- 完整性与签名校验机制
- 宿主侧双栈插件加载机制
而不是 DRM 或不可导出的执行环境。
## 方案选择
候选方案:
1. 纯混淆方案
2. 离线封包 + 本地验签 + 本地解密方案
3. 在线授权下发解密材料方案
选用方案 2。
原因:
- 用户当前目标只是避免普通用户直接看源码,不需要引入在线依赖
- 宿主已允许修改,适合增加自定义 loader
- 本地验签可以防止第三方伪造或篡改插件
- 双栈方案能平滑兼容现有明文插件生态
- 相比在线授权,工程复杂度和运维成本更可控
不选方案 1 的原因:
- 只能防止双击直读,无法提供可靠的篡改检测
- 无法建立统一的协议版本、密钥轮换和签名链路
不选方案 3 的原因:
- 超出当前目标
- 需要额外服务端、授权链路和可用性保障
## 总体架构
系统分成三个角色:
- `builder`
- 读取明文 Spider 源码
- 生成内容密钥
- 加密源码
- 生成签名
- 输出单文件加密包
- `loader`
- 读取插件文件
- 识别明文或密文格式
- 对密文包执行验签、解密、内存加载
- `runtime spider`
- 解密后在内存中编译执行
- 对宿主继续暴露 `Spider`
总体链路如下:
1. 构建阶段读取明文 Spider 源码
2. 生成随机内容密钥 `content_key`
3.`content_key` 对源码做对称加密
4. 使用宿主密钥派生链路包裹 `content_key`
5. 对包头和密文内容做签名
6. 产出单文件文本封包
7. 宿主加载时先识别格式
8. 若为明文插件,则继续按现有 `importlib` 逻辑执行
9. 若为密文插件,则先验签,再解包内容密钥,再解密源码
10. 解密后的源码通过 `compile/exec` 在内存中执行
11. 宿主统一从模块中读取 `Spider` 类并实例化
## 封包格式设计
建议定义文本协议 `secspider/1`,保持现有注释头风格,便于远程分发和人工排查。
推荐格式:
```text
// ignore
//@name:[直] omofun
//@webSite:http://101.42.227.94:11024
//@version:1
//@remark:
//@codeID:z5nxgZSWE8ipZYvGtImsRFu73TOB45fZ
//@env:
//@isAV:0
//@deprecated:0
//@order:B
//@format:secspider/1
//@alg:aes-256-gcm
//@wrap:hkdf-aes-keywrap
//@sign:ed25519
//@kid:k2026_04
//@nonce:base64:...
//@ek:base64:...
//@hash:sha256:...
//@sig:base64:...
// ignore
payload.base64:...
```
字段分组如下:
- 业务元数据
- `name`
- `webSite`
- `version`
- `remark`
- `codeID`
- `env`
- `isAV`
- `deprecated`
- `order`
- 协议元数据
- `format`
- `alg`
- `wrap`
- `sign`
- `kid`
- 加密与校验字段
- `nonce`
- `ek`
- `hash`
- `sig`
- 数据字段
- `payload.base64`
字段约束如下:
- `format` 固定为 `secspider/1`
- `alg` 首版固定为 `aes-256-gcm`
- `wrap` 首版固定为 `hkdf-aes-keywrap`
- `sign` 首版固定为 `ed25519`
- `kid` 表示当前包使用的密钥版本
- `nonce` 为内容加密随机数
- `ek` 为包裹后的内容密钥,不是主密钥本身
- `hash` 为解密前明文源码的 `sha256`
- `sig` 覆盖除 `sig` 自身外的所有头字段和 `payload`
`payload.base64` 存放最终密文载荷,推荐流程为:
- 明文源码转 `utf-8` 字节
- 可选压缩
- 使用内容密钥加密
- 对结果做 `base64` 编码
## 算法选择
首版固定采用如下组合:
- 内容加密:`AES-256-GCM`
- 内容密钥派生:`HKDF-SHA256`
- 发布签名:`Ed25519`
选择原因如下:
- `AES-256-GCM` 为成熟 AEAD 算法,适合同时提供保密性和密文完整性
- `HKDF-SHA256` 足够简单,适合从宿主主密钥按 `kid/codeID/version` 派生包密钥
- `Ed25519` 适合快速、稳定地完成离线签名和验签
本次设计明确不采用:
- 自定义异或或字符串切片混淆
- 自研加解密协议
- 只做加密不做签名
## 密钥分层设计
宿主开源且运行在用户设备上,因此密钥体系的设计目标不是“绝对保密”,而是避免所有插件共用一把固定明文密钥。
推荐分三层密钥:
### 1. 发布签名密钥对
- 构建环境持有 `Ed25519 private key`
- 宿主持有对应 `Ed25519 public key`
用途:
- 证明插件确实由发布方签发
- 拦截被篡改或伪造的插件包
约束:
- 私钥绝不进入客户端
### 2. 宿主主密钥
- 宿主内置 `master_secret`
- 不直接用于解密 `payload`
用途:
- 派生当前包使用的 `wrap_key`
说明:
- 这层密钥会被逆向到,因此安全作用有限
- 但仍优于“所有插件共用固定 AES key”的做法
### 3. 内容密钥
- 每个插件构建时随机生成一个 32 字节 `content_key`
用途:
- 只用于当前插件包的源码加密
优势:
- 每个插件包独立加密
- 不同包之间不共享实际内容密钥
### 密钥派生规则
建议:
```text
wrap_key = HKDF-SHA256(
ikm = master_secret,
salt = kid,
info = "secspider:" + codeID + ":" + version
)
```
用途:
- `wrap_key` 用于包裹或解包 `content_key`
收益:
- 同一 `master_secret` 下,不同 `codeID/version` 的包不会直接共钥
- 支持按 `kid` 做宿主侧密钥轮换
## 密钥轮换策略
建议宿主维护一个 `keyring`,按 `kid` 管理签名公钥和主密钥材料。
设计要求:
- 插件头部必须携带 `kid`
- loader 先按 `kid` 查找密钥材料
- 找不到 `kid` 直接拒绝执行
- 新版插件可切换到新 `kid`
-`kid` 可在兼容期内继续保留
首版只需要支持静态内置 `keyring`,不要求联网拉取。
## 宿主加载设计
### 现状
当前宿主 `SpiderPluginLoader` 的核心流程为:
1. 安装兼容 `base.spider`
2. 通过本地路径或远程下载得到插件文件
3. 通过 `importlib.util.spec_from_file_location(...)` 执行源码文件
4. 从模块中读取 `Spider` 类并实例化
这条链路仅适合明文 Python 文件。
### 目标形态
宿主改造为双栈加载:
- 明文插件:保持现有 `importlib` 路径
- 加密插件:走自定义 `secspider` runtime 路径
两条路径最终都返回 `types.ModuleType`,上层实例化逻辑保持不变。
### 推荐改造点
`SpiderPluginLoader` 中新增三个私有方法:
- `_detect_package_format(source_path) -> str`
- `_load_plain_module(module_name, source_path) -> types.ModuleType`
- `_load_secspider_module(module_name, source_path, config) -> types.ModuleType`
主流程调整为:
1. `_install_compat_modules()`
2. `_resolve_source_path()`
3. `_detect_package_format(source_path)`
4. 如果不是 `secspider/1`,则走 `_load_plain_module(...)`
5. 如果是 `secspider/1`,则走 `_load_secspider_module(...)`
6. 统一读取 `Spider` 类、实例化并执行 `init(config.config_text)`
### 为什么不继续沿用 `spec_from_file_location`
原因如下:
- 密文插件正文不再是合法 Python 源码
- 若继续沿用 `importlib` 文件执行,势必要把明文源码先落盘
- 这会直接削弱“避免用户直接读取源码”的目标
因此,对加密插件的正确路径是:
- 把缓存文件当“文本包”
- 在内存中验签和解密
-`compile/exec` 创建模块对象
## 宿主侧模块结构
建议新增如下模块:
- `atv_player/plugins/spider_crypto/package.py`
- 解析 `secspider/1`
- 校验头字段
- 输出规范化签名输入
- `atv_player/plugins/spider_crypto/keyring.py`
- 维护 `kid -> public_key/master_secret`
- `atv_player/plugins/spider_crypto/runtime.py`
- 验签
- 派生 `wrap_key`
- 解包 `content_key`
- 解密 `payload`
- `compile/exec` 生成内存模块
- `atv_player/plugins/spider_crypto/errors.py`
- 定义格式、签名、密钥、解密、运行期错误
`SpiderPluginLoader` 只负责调度,不直接承载加解密细节。
## 运行时序设计
密文插件的标准加载时序如下:
1. 读取插件文件文本
2. 解析注释头和 `payload.base64`
3. 检查 `format == secspider/1`
4.`kid` 获取签名公钥
5. 对头字段和 payload 执行 `Ed25519` 验签
6. 验签通过后,按 `kid/codeID/version` 派生 `wrap_key`
7. 解开 `ek` 得到 `content_key`
8. 使用 `content_key + nonce` 解密 `payload`
9. 对解密后的源码做 `sha256` 校验,必须与 `hash` 一致
10. 将源码编译为 `code object`
11. 在新的 `types.ModuleType` namespace 中执行
12. 返回 module 对象给 `SpiderPluginLoader`
执行时应满足:
- 明文源码不写回磁盘
- 模块命名保持宿主当前风格
- 解密失败和运行失败分开报错
## 推荐的运行时接口
建议形成如下边界:
```python
class SecSpiderPackage:
@classmethod
def parse(cls, text: str) -> "SecSpiderPackage": ...
def signing_bytes(self) -> bytes: ...
class SpiderKeyring:
def get_public_key(self, kid: str): ...
def get_master_secret(self, kid: str) -> bytes: ...
class SecSpiderRuntime:
def __init__(self, keyring: SpiderKeyring) -> None: ...
def load_module(self, pkg: SecSpiderPackage, module_name: str) -> types.ModuleType: ...
```
这样可以把:
- 包格式解析
- 密钥管理
- 运行时加载
三个责任分开,避免 `SpiderPluginLoader` 继续膨胀。
## 远程下载与缓存策略
当前宿主远程插件会下载后缓存到本地文件。此行为可以保留,但缓存内容应始终是“原始加密包文本”,而不是解密后的源码。
建议:
- `_resolve_source_path()` 继续负责下载并落地缓存
- 缓存文件只用于下次重新加载原始包
- 每次加载仍重新验签和解密
- 如需提速,只做内存级缓存,不做明文持久化缓存
缓存文件后缀可以考虑从固定 `.py` 调整为更中性的扩展名,例如:
- `.spkg`
- `.txt`
若短期不想改后缀,也应在代码语义上把其视为“插件文本缓存”,而不是默认假设为 Python 源码文件。
## 构建器设计
构建器职责如下:
1. 读取明文 Spider 源码
2. 计算源码 `sha256`
3. 生成随机 `content_key`
4. 生成随机 `nonce`
5. 可选压缩源码
6. 使用 `AES-256-GCM` 加密源码
7. 基于 `master_secret + kid + codeID + version` 派生 `wrap_key`
8.`wrap_key` 包裹 `content_key`
9. 生成规范化包头
10. 对头部和 payload 做 `Ed25519` 签名
11. 输出最终文本封包
构建器只在受控环境中使用,不进入宿主。
## 错误分类设计
宿主侧应把底层异常统一映射为明确的用户可见错误:
- `插件格式不支持`
- `插件签名校验失败`
- `插件密钥不可用`
- `插件解密失败`
- `插件源码校验失败`
- `插件缺少 Spider 类`
- `插件运行失败: ...`
- `缺少依赖: ...`
这样做的目的是:
- 便于日志区分是下载问题、协议问题、签名问题还是运行问题
- 便于 UI 或调用方给出稳定提示
## 兼容与迁移策略
推荐分三步迁移:
### 第一步:宿主支持双栈
- 明文插件继续可加载
- 新增 `secspider/1` 加密插件加载能力
### 第二步:单插件试点
- 选择一个 Spider 先完成从明文到加密包的发布验证
- 检查下载、缓存、验签、解密和错误处理链路
### 第三步:逐步扩大覆盖面
- 将更多 Spider 改为由构建器生成加密包
- 明文源码只保留在私有开发或构建环境
本次设计不要求一次性淘汰全部明文插件。
## 测试策略
至少覆盖以下测试:
1. 包解析测试
- 缺少字段
- 重复字段
- 非法 `format`
- 缺失 `payload`
2. 验签测试
- 改动头字段任一字符后验签失败
- 改动 payload 任一字符后验签失败
3. 解密测试
- 错误 `kid`
- 错误 `ek`
- 错误 `nonce`
- 错误 `hash`
4. 模块加载测试
- 成功加载后模块含 `Spider`
- `init(config_text)` 能按现有方式调用
5. 兼容测试
- 明文插件加载逻辑不回退
6. 缓存测试
- 远程下载失败时,若已有有效缓存,仍能回退到缓存包
所有测试应使用本地 fixture,不依赖真实网络和真实私钥环境。
## 风险与约束
- 宿主主密钥可被逆向提取,因此无法防住高级对手
- 攻击者可以自行修改宿主,去掉验签和解密保护
- 若未来需要做授权控制,必须引入新的密钥分发或服务端参与机制
- 若协议字段顺序、空白或换行规则不固定,签名实现很容易出错,因此必须定义严格的 canonicalization 规则
## 验收标准
- 明文插件加载行为保持兼容
- `secspider/1` 插件可从本地和远程加载
- 被篡改的插件会在验签阶段拒绝执行
- 密钥错误或密文损坏会在解密阶段明确失败
- 加载成功后,宿主仍像处理普通插件一样处理 `Spider` 实例
- 解密后的源码不落盘
- 宿主错误提示可区分格式、签名、密钥、解密和运行期问题