v3.6.1 安全修复同步发版

- 盘面图 XSS 防护:装配层全面转义用户可控字段 + 模板严格 CSP(default-src 'none',无 script-src)+ 6 项注入向量回归测试
- 自更新签名信任锚:--apply 双源 manifest 交叉验证 → ed25519 验签(纯 Python 实现,缺 sig 即拒)→ 逐文件 SHA256,全 fail-closed
- sync 工具同步后自动重建 manifest + 签名 + 独立验签,私钥缺失 fail-closed
- 测试 170 项全绿
This commit is contained in:
2026-10-10 16:21:30 +08:00
parent 433c9b4a41
commit 415cb472b1
13 changed files with 805 additions and 41 deletions
+1 -1
View File
@@ -1 +1 @@
3.6.0
3.6.1
+12 -9
View File
@@ -4,11 +4,11 @@
"LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315",
"README.md": "adb4481f20360c66e67fd89076c1535c577cdf85f34122d2dac1ff727dd81c38",
"SKILL.md": "720aa2de6e7e6f65c44f63421f0aece401146921e54a6c99f24db1af14241813",
"VERSION": "138203e3f604a08fad0c4d0a297efe2c486219820dcdd053e72b7d843504c7a1",
"VERSION": "de58d841bd709aa85a66ecc500dfbb48e7c207e84c399e30e751e8ccad6e8c9b",
"data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee",
"data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854",
"memory/README.md": "a979f985dd87e87bfca49676ebf10a537d299b1eeebac121c506c320c88a3afd",
"pyproject.toml": "d71541e952e8b1650a157bf17d2bed9946052df9f6647032218ca272973d93b4",
"pyproject.toml": "b202c2ca784d501959d3dc4d7024851de8b66b15a7e3368b81566b2e045601b4",
"requirements.txt": "46a4c971b192cbf2c4972dd0f420e7928b7e44cd3cb9f7dc5a36efb321ab6fd6",
"rules/00_总纲_取格.md": "829a35454f5c91966ecafe4dd2113b913588933efe08d0e1c127ae82ccec0da8",
"rules/11_正官格.md": "385193d93dfba89a935ca8907098f70548ad1fc12ab6a07a5280fd04224c83b6",
@@ -30,7 +30,7 @@
"rules/96_六亲.md": "96cf362bd180558352d0181401ac8e297ac89c09d91d1098972a9d1c40468198",
"rules/97_女命.md": "52962aa666faa3fa2a5175d352380df14f54fefb8f57cfe0761779ad51e18696",
"rules/99_附录_干支通例.md": "da906502391833eb1744ff0145a03c7d1e10781ac7156034632dc5994da0d2f4",
"rules/README.md": "27118f8338549ae4f92d1c9ed66e6bea82e028f2a5f68769bc43e08770857b51",
"rules/README.md": "e08256c5fe13683bab93076b19fde18c1c1455053dec83f37121e4718817d489",
"rules/sources/README.md": "183ba554b1d89c16b19fb65ac8a4d0614069c865676fb4c1bd20d95b5581c209",
"rules/sources/三命通会/00_卷一·原造化之始.md": "6329058dd08aed5f41d1111da99027cac38761aeea7120a84eff02683426294d",
"rules/sources/三命通会/01_卷一·论五行生成.md": "c9db87683a6d3f9b7eea665e3814e383e11a69b608114d9317beb2a2c56825f8",
@@ -566,6 +566,7 @@
"scripts/_license_gate.py": "4f3a24685c86abc6c66a158b441b89e2738bd14f5d63e8bdd41489654a29e837",
"scripts/_memory_store.py": "690e088a208ee5aeb9f66e6d4b5d4342f44bfc852bad51870cdfe397fbdf7660",
"scripts/_runtime_guard.py": "c4f9d00acbb5b2ba49680b23ed4f846c60211e67b9d58950079b3e8909dcfe81",
"scripts/_update_sig.py": "9bb3521b950ab834c770e313a98c9cc8f7e997997540258724505af774a4abe6",
"scripts/astro/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/astro/day_boundary.py": "b5a24b3937fbcd9c278331b5b19f8cf708acc987f6f6f97f346524786735af9a",
"scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e",
@@ -588,10 +589,10 @@
"scripts/render/assets/lingyao-logo.png": "3d63f71ff9af61a443708f2665d1331737bb567a4e731d049676dfeb4710e323",
"scripts/render/assets/logo-font.ttf": "0b3a28b91d30276a68501a837ce7c1874274276c21793957b83f1efbb4a9264a",
"scripts/render/assets/qun-qrcode.jpg": "437854ff4ea6d2b3452b16371afbba1b9bb97e1f2c7fdbc77580ae2cceb3cbaf",
"scripts/render/html.py": "3196b808d83b876ff1d4b7ea13bbb97f27154bfcd27b491cd10b2e4c0c254874",
"scripts/render/html.py": "f6054c3fddc9223e32061bbc2bb88c01b9888606baf118ab3ddfb1d11f11ec14",
"scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977",
"scripts/render/templates/paipan.html": "aecc6a7ec5d6dcb567e56a986dc803678838f12551828ff58ae313ae7a6499a5",
"scripts/self_update.py": "ca8cf55dffd805a3643e71167912e6468f33e1b7fe0a270ec263f6d9bf949bb8",
"scripts/render/templates/paipan.html": "62fc3fcf355089cbedfc9e7b39fae54bb93e91cab73be3c376145b2643853a28",
"scripts/self_update.py": "c7bbb58e750fdbbecad00936948e37d7ff7a47c57e22d116c537c104aa74e8de",
"scripts/share_export.py": "eab38048e3737de1eb8731e6f4860f4061d29dedb9e9f3a1c9d9ac44ec200c20",
"scripts/share_push.py": "307e83747b8d244f20632a198b7bfba51352a6b6bf0a8f98b73c904e719a6b89",
"share/README.md": "adccd0f7691bedd64441b11cdd8ce66e20adce55fe4d8de1333f531b689f82b8",
@@ -603,16 +604,18 @@
"tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04",
"tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa",
"tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790",
"tests/test_html.py": "29fbab116aaa48ef9b97f26b6eec6c22157cd8c6af9da61943e20dd93157e49d",
"tests/test_html.py": "d40f4558ddfda156706a0c3d4c52c794fbc1e59427acdc93bcdad7df34217638",
"tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e",
"tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea",
"tests/test_memory.py": "068241927df08301806451ff6e66206ceb9d439d95197b3506fe519d892c28ed",
"tests/test_runtime_guard.py": "7fd7e2f334433432452ea68fde30df1698def2126a1920f8da6d6aab236c1caf",
"tests/test_self_update.py": "183c2fb82d2d20285941b6c75a1890c4417d400c3c7e7d5f8a9680b4fa7bf76c",
"tests/test_self_update.py": "87a0fdaf28132e70fe407a552ff77f5911e13e67ca1d568581a273eaba875712",
"tests/test_share.py": "7938344415cbba95f683b2a09ecec9af28d00658f475ff0a56563dd13beff630",
"tests/test_shensha.py": "2aaa181279739356828f05a2175027b0ae711b322b4bd0e60d5c5e62538bcac8",
"tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4",
"tests/test_sync_tool.py": "67395f121cfa0092af7edb579dac54a07690b989eb82bb16c6ea815325e47ce3",
"tests/test_timezone.py": "3698878999d232bd431d6908c42fe8f4845c696da6085731abaf2f74017d722d",
"tests/test_update_sig.py": "3bfd6ce28a699afe233fe51d9d9c7aacc308f54a4dcb9f9e7ecd272bb0a9b5a2",
"tests/test_version_sentinel.py": "9b2bc603a2e86aa33d904f1381ecd8ab41eb0e759803ec47122d662c10a7df83",
"vendor/VENDOR.md": "dbf017616ac2fda4fef56ce95e3610e77457108b7fa426136fd25764d78fa3c8",
"vendor/tyme4py.LICENSE": "e017c12d3476152144886c06e12b67277bb64e922b88c902b76ac02a84ea1c9d",
@@ -1276,5 +1279,5 @@
"vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f",
"vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881"
},
"version": "3.6.0"
"version": "3.6.1"
}
+1
View File
@@ -0,0 +1 @@
/hVDbf9RW2/oWP5z1nhGyc6/Ip76Inw4mLKaRtsojbuDS/tAaY9km8phEBGgC4T8qunh/FD8qc4dcVHk+iJfCA==
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "ZiPingEightChar"
version = "3.6.0"
version = "3.6.1"
requires-python = ">=3.10" # zoneinfo 需 3.9+,dataclass 语法需 3.10
dependencies = [
"tyme4py==1.5.0", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性
+3 -1
View File
@@ -105,7 +105,9 @@ rules/
- **3.6**(2026-10-10)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。
### 3.6 相对 3.5:
- 询问契约改对话式(规则条数不变,542 条、20 文件)——SKILL.md Intake 节改「对话式询问(强制)」(禁用结构化问卷/点选工具与正文编号选项清单,一次最多 1~2 问、先铺垫再问),断运交互节同步;`90_行运.md` R-运-33 措辞随之修订——原「具备询问工具时必须用询问工具,否则以正文编号选项代替」「核对用结构化选项、多锚点年合成一次提问」改为对话式口径(普通对话消息、一次最多一两个锚点、先铺垫再问、答完再定下一个);核对前置的内部程序(场景分流、锚点选取、候选排序、证伪回查、收敛后一次性输出)不变。
- 询问契约改对话式(规则条数不变,542 条、20 文件)——SKILL.md Intake 节改「对话式询问(强制)」(禁用结构化问卷/点选工具与正文编号选项清单,一次最多 1~2 问、先铺垫再问),断运交互节同步;
- `90_行运.md` R-运-33 措辞随之修订——原「具备询问工具时必须用询问工具,否则以正文编号选项代替」「核对用结构化选项、多锚点年合成一次提问」改为对话式口径(普通对话消息、一次最多一两个锚点、先铺垫再问、答完再定下一个);
- 核对前置的内部程序(场景分流、锚点选取、候选排序、证伪回查、收敛后一次性输出)不变。
### 3.5 相对 3.4:
- description 改写为按问题域与使用场景触发;新增 R-禁-22 声明式偏离通道;R-运-31 事类映射表改开放清单措辞;规则计数相应更新(541 → 542 条,20 文件不变)。
+128
View File
@@ -0,0 +1,128 @@
"""纯 Python ed25519(RFC 8032)签名与验签,零依赖,仅用标准库。
self_update.py --apply 用其中的 verify() 校验 manifest.json 签名;
tools/sign_manifest.py 用 sign() 生成签名。验签性能不在关键路径上
(每次更新只验一次),实现取教科书式的扩展坐标点运算,求正确不求快。
密钥编码:私钥 = 32 字节种子(hex/base64 存储);公钥 = 32 字节 RFC 8032
标准压缩编码;签名 = 64 字节 R‖S。
"""
import hashlib
# 域参数:GF(2^255 - 19),曲线 -x² + y² = 1 + d·x²·y²
_P = 2 ** 255 - 19
_Q = 2 ** 252 + 27742317777372353535851937790883648493 # 群的阶
_D = (-121665 * pow(121666, _P - 2, _P)) % _P
_I = pow(2, (_P - 1) // 4, _P) # sqrt(-1)
def _xrecover(y: int) -> int:
xx = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
x = pow(xx, (_P + 3) // 8, _P)
if (x * x - xx) % _P != 0:
x = x * _I % _P
if x & 1:
x = _P - x
return x
_BY = (4 * pow(5, _P - 2, _P)) % _P
_BX = _xrecover(_BY)
# 基点(扩展坐标 x, y, z, t,x=X/Z, y=Y/Z, t=XY/Z)
_B = (_BX, _BY, 1, _BX * _BY % _P)
_IDENTITY = (0, 1, 1, 0)
def _point_add(P, Q):
x1, y1, z1, t1 = P
x2, y2, z2, t2 = Q
a = (y1 - x1) * (y2 - x2) % _P
b = (y1 + x1) * (y2 + x2) % _P
c = 2 * _D * t1 * t2 % _P
d = 2 * z1 * z2 % _P
e, f, g, h = b - a, d - c, d + c, b + a
return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
def _scalarmult(P, e: int):
Q = _IDENTITY
while e > 0:
if e & 1:
Q = _point_add(Q, P)
P = _point_add(P, P)
e >>= 1
return Q
def _encode_point(P) -> bytes:
x, y, z, _t = P
zi = pow(z, _P - 2, _P)
x, y = x * zi % _P, y * zi % _P
return (y | ((x & 1) << 255)).to_bytes(32, "little")
def _decode_point(s: bytes):
if len(s) != 32:
raise ValueError("点编码长度须为 32 字节")
y = int.from_bytes(s, "little") & ((1 << 255) - 1)
if y >= _P:
raise ValueError("点编码 y 坐标越界")
x = _xrecover(y)
if (x & 1) != (s[31] >> 7):
x = _P - x
if (-x * x + y * y - 1 - _D * x * x * y * y) % _P != 0:
raise ValueError("点不在曲线上")
return (x, y, 1, x * y % _P)
def _hint(m: bytes) -> int:
return int.from_bytes(hashlib.sha512(m).digest(), "little")
def _secret_expand(seed: bytes):
if len(seed) != 32:
raise ValueError("私钥种子须为 32 字节")
h = hashlib.sha512(seed).digest()
a = int.from_bytes(h[:32], "little")
a &= (1 << 254) - 8
a |= 1 << 254
return a, h[32:]
def publickey(seed: bytes) -> bytes:
"""32 字节种子 → 32 字节公钥。"""
a, _prefix = _secret_expand(seed)
return _encode_point(_scalarmult(_B, a))
def sign(seed: bytes, msg: bytes) -> bytes:
"""RFC 8032 确定性签名:64 字节 R‖S。仅发版工具(tools/)使用。"""
a, prefix = _secret_expand(seed)
A = publickey(seed)
r = _hint(prefix + msg) % _Q
R = _encode_point(_scalarmult(_B, r))
S = (r + _hint(R + A + msg) * a) % _Q
return R + S.to_bytes(32, "little")
def verify(pk: bytes, msg: bytes, sig: bytes) -> bool:
"""验签:通过返回 True;任何不符(含畸形输入)返回 False,不抛异常。"""
if len(pk) != 32 or len(sig) != 64:
return False
R, s = sig[:32], int.from_bytes(sig[32:], "little")
if s >= _Q:
return False
try:
A = _decode_point(pk)
Rpt = _decode_point(R)
except ValueError:
return False
h = _hint(R + pk + msg) % _Q
# [S]B == R + [h]A
return _encode_point(_scalarmult(_B, s)) == _encode_point(
_point_add(Rpt, _scalarmult(A, h)))
def fingerprint(pk: bytes) -> str:
"""公钥指纹:SHA256(公钥) 十六进制,用于注释与审计对照。"""
return hashlib.sha256(pk).hexdigest()
+13 -1
View File
@@ -13,6 +13,7 @@ logo 与品牌字体(render/assets/)以 base64 内嵌,保证 HTML 单文
import base64
import re
from datetime import datetime
from html import escape as _escape
from pathlib import Path
from chart.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain
@@ -32,6 +33,14 @@ _SEASON_LABEL = ("旺", "相", "休", "囚", "死")
_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
_SHA_NOTE_RE = re.compile(r"[((][^))]*[))]")
# 结构性占位符:值本身是装配层渲染出的 HTML 片段或 base64 资源,
# 填充时原样注入;其余占位符均为文本字段,一律 HTML 转义防注入。
_STRUCTURAL_KEYS = frozenset({
"LOGO_B64", "FONT_B64", "QRCODE_B64",
"WARNINGS", "MAIN_TABLE", "LUCK_STRIP", "CHILDHOOD_BLOCK",
"FLOW_YEARS", "FLOW_MONTHS",
})
def _strip_sha_note(entry: str) -> str:
"""神煞条目剔除查法括号注释:`将星(以年支子查,见于月支子)` → `将星`。"""
@@ -239,5 +248,8 @@ def build_html(chart) -> str:
}
out = tpl
for k, v in mapping.items():
out = out.replace("{{" + k + "}}", str(v))
v = str(v)
if k not in _STRUCTURAL_KEYS:
v = _escape(v, quote=True)
out = out.replace("{{" + k + "}}", v)
return out
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'">
<title>{{TITLE}}</title>
<style>
@font-face {
+111 -6
View File
@@ -4,20 +4,33 @@
用法:
python scripts/self_update.py --check 检查更新(24 小时缓存,重复检查不拉网络)
python scripts/self_update.py --notify 只读缓存打印提示,绝不碰网络(供 cast_chart 调用)
python scripts/self_update.py --apply 下载 zipball → 按 manifest 逐文件校验 → 同步本地
python scripts/self_update.py --apply 下载 zipball → 验签 → 按 manifest 逐文件校验 → 同步本地
退出码(--apply):0 成功;2 无需更新或配置缺失;3 失败(本地原样不动)。
信任模型(三道防线,各防一层):
1. 签名信任锚:manifest.json 附带 ed25519 签名 manifest.json.sig,
公钥内置于本文件(UPDATE_PUBLIC_KEY_HEX),私钥仅作者持有
(~/.zipec/signing.key,不进任何仓库)。仓库(GitHub/Gitea)或
下载链路被攻破时,攻击者改 manifest 即验签失败——防「源头投毒」。
2. 双源交叉验证:--apply 从 GitHub 与 Gitea 各拉一份 manifest.json,
与 zip 内字节逐一比对,任一不一致即中止——防「单一源被篡改/
劫持后单独口径」。
3. 逐文件 SHA256:manifest 通过前两关后,逐文件核对清单哈希——
防「zip 内文件与清单不符」(传输损坏、打包错误、部分篡改)。
manifest.json.sig 本身不进 manifest 清单(自指),随仓库提交分发。
远端双线路 failover:GitHub 优先,Gitea 兜底。HTTPS_PROXY/HTTP_PROXY 等
代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址,
支持 file:// 形式。
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS / ZIPEC_MANIFEST_URLS(逗号分隔)
整体覆盖远端地址,支持 file:// 形式。
抓取通道:urllib 优先;macOS 框架版 Python 常见的根证书缺失
(SSL CERTIFICATE_VERIFY_FAILED)时自动回退到系统 curl(curl 自带
系统信任库且自带代理环境变量支持)。
系统信任库且自带代理环境变量支持)。manifest 与 .sig 的抓取同走此通道。
"""
import argparse
import base64
import hashlib
import json
import os
@@ -34,12 +47,21 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _update_sig # 纯 Python ed25519(RFC 8032),零依赖验签
# ---- 远端配置(GitHub 优先,Gitea 兜底)----
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION"
GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main"
GITHUB_MANIFEST_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/manifest.json"
GITEA_VERSION_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/raw/branch/main/VERSION"
GITEA_ZIPBALL_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/archive/main.zip"
GITEA_MANIFEST_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/raw/branch/main/manifest.json"
# 更新签名信任锚:ed25519 公钥(32 字节,十六进制),作者 2026-10-10 生成。
# 指纹 SHA256(公钥) = 3089bc1cc9a453e5be66a36574635da6b2ad9797c57af11164111faca157411c
# 用途:--apply 校验 manifest.json.sig。私钥仅作者持有(~/.zipec/signing.key),
# 不进任何仓库;公钥更换 = 信任锚更换,须随版本说明明确公告。
UPDATE_PUBLIC_KEY_HEX = "49103c13cad0d9c67207945b4e4f6ff81d1261339eb0de4a60c2a1cf949d7852"
CACHE_REL = "data/.update_check.json"
CACHE_TTL_SECONDS = 24 * 3600
@@ -71,6 +93,14 @@ def _zipball_urls() -> list:
return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL]
def _manifest_urls() -> list:
"""双源交叉验证用:各源的 manifest.json 直链(.sig 直链 = 本地址 + '.sig')。"""
env = os.environ.get("ZIPEC_MANIFEST_URLS")
if env:
return [u.strip() for u in env.split(",") if u.strip()]
return [GITHUB_MANIFEST_URL, GITEA_MANIFEST_URL]
def _fetch_bytes(url: str, timeout: int) -> bytes:
"""urllib 优先;SSL 证书缺失等失败时回退系统 curl(macOS 框架版 Python 常见)。"""
try:
@@ -288,8 +318,58 @@ def _verify_manifest(src: Path, files: dict):
return errors
def _cross_check_manifest(manifest_bytes: bytes):
"""从全部配置源各拉一份 manifest.json,与 zip 内字节逐一比对。
返回 None = 各源与 zip 完全一致;否则返回错误信息(任一源不可达也
视为失败:无法交叉确认时宁可中止)。
"""
urls = _manifest_urls()
if not urls:
return "未配置任何 manifest 源"
for url in urls:
try:
data = _fetch_bytes(url, NETWORK_TIMEOUT)
except Exception as e:
return f"{url} 不可达({e}),无法完成双源交叉验证"
if data != manifest_bytes:
return f"{url} 的 manifest.json 与下载包内字节不一致,两源口径分歧"
return None
def _load_manifest_sig(src: Path):
"""优先取 zip 内 manifest.json.sig;缺失则按 manifest 源单独拉 .sig(curl 回退同路)。
返回签名文本(base64)或 None。
"""
p = src / "manifest.json.sig"
if p.is_file():
try:
return p.read_text(encoding="utf-8")
except OSError:
return None
for url in _manifest_urls():
try:
return _fetch_text(url + ".sig")
except Exception:
continue
return None
def _signature_valid(manifest_bytes: bytes, sig_text: str) -> bool:
try:
sig = base64.b64decode(sig_text.strip(), validate=True)
except Exception:
return False
try:
pk = bytes.fromhex(UPDATE_PUBLIC_KEY_HEX)
except ValueError:
return False
return _update_sig.verify(pk, manifest_bytes, sig)
def _sync(root: Path, src: Path, files: dict) -> None:
managed = set(files) | {"manifest.json", "VERSION"}
managed = set(files) | {"manifest.json", "manifest.json.sig", "VERSION"}
copied = deleted = 0
for rel in sorted(managed - {"VERSION"}): # VERSION 最后写
s = src / rel
@@ -364,7 +444,32 @@ def cmd_apply(root: Path) -> int:
print("失败:远端包内找不到 manifest.json(本地未改动)", file=sys.stderr)
return 3
try:
manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
manifest_bytes = (src / "manifest.json").read_bytes()
except OSError as e:
print(f"失败:manifest.json 读取异常({e})(本地未改动)", file=sys.stderr)
return 3
# 防线 1:双源交叉验证(各源 manifest 与 zip 内字节一致)
err = _cross_check_manifest(manifest_bytes)
if err:
print(f"失败:{err},已中止(本地未改动)", file=sys.stderr)
return 3
print(f"双源交叉验证通过({len(_manifest_urls())} 个源口径一致)")
# 防线 2:内置公钥验签 manifest(缺 sig 视同验签失败)
sig_text = _load_manifest_sig(src)
if sig_text is None:
print("失败:包未带 manifest.json.sig 签名,可能为旧版或被篡改,"
"已中止(本地未改动)", file=sys.stderr)
return 3
if not _signature_valid(manifest_bytes, sig_text):
print("失败:manifest.json 签名校验不符,包可能被篡改,"
"已中止(本地未改动)", file=sys.stderr)
return 3
print("manifest 签名校验通过")
try:
manifest = json.loads(manifest_bytes.decode("utf-8"))
files = manifest["files"]
except Exception as e:
print(f"失败:manifest.json 无法解析({e})(本地未改动)", file=sys.stderr)
+76 -2
View File
@@ -10,7 +10,7 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
def _chart():
def _chart(name="张三", place="四川省成都市"):
"""用真实排盘管线造一份 ChartData(成都 1990 男盘)。"""
from datetime import datetime
from astro.solar_time import to_true_solar_time
@@ -39,7 +39,7 @@ def _chart():
years = [(y, gz, luck_label_for_year(y, decade))
for y, gz in flow_years(cast_dt.year, this_year, 10)]
return build_chart_data(
name="张三", gender="男", place="四川省成都市",
name=name, gender="男", place=place,
clock="1990-05-14 08:30", loc=loc, tz_audit=tz_audit,
st_audit=st_audit, bd_audit=bd_audit, raw=raw, luck=luck,
luck_pillars=decade, flow_years=years,
@@ -172,6 +172,80 @@ class HtmlRenderTest(unittest.TestCase):
self.assertEqual(NAYIN["丙午"], "天河水")
class HtmlXssTest(unittest.TestCase):
"""XSS 防护:用户可控字段(姓名/出生地)一律转义,模板带 CSP,无脚本。"""
PAYLOADS = (
"<script>alert(1)</script>",
"<img src=x onerror=alert(1)>",
'" autofocus onfocus=alert(1) x="',
"javascript:alert(1)",
'山西晋城 "><svg onload=alert(1)>',
)
# 活跃标签(真实 < 起首、未被 &lt; 转义)携带事件属性或 javascript: 协议
_LIVE_HANDLER = re.compile(r"<[a-zA-Z][^<>]*\son\w+\s*=")
_LIVE_JS_URL = re.compile(r"<[a-zA-Z][^<>]*javascript:")
def _render(self, name, place):
from render.html import build_html
return build_html(_chart(name=name, place=place))
def test_payloads_escaped(self):
"""注入向量在输出中只以转义形态存在,裸形态消失。"""
from html import escape
for payload in self.PAYLOADS:
for field in ("name", "place"):
kw = {"name": "张三", "place": "四川省成都市", field: payload}
html = self._render(**kw)
esc = escape(payload, quote=True)
if esc != payload: # 含 & < > " ' 的载荷:转义形态在、裸形态不在
self.assertIn(esc, html, f"{field}={payload!r} 未转义")
self.assertNotIn(payload, html, f"{field}={payload!r} 裸注入")
def test_no_live_markup_in_document(self):
"""注入打到 name/place 后检查整份文档(含结构性占位符区域):
无 <script> 标签、无活跃事件处理属性、无 javascript: 属性值。"""
html = self._render("<img src=x onerror=alert(1)>",
'" autofocus onfocus=alert(1) x="')
self.assertNotIn("<script", html.lower())
self.assertIsNone(self._LIVE_HANDLER.search(html),
"存在活跃 on* 事件处理属性")
self.assertIsNone(self._LIVE_JS_URL.search(html),
"存在 javascript: 属性值")
def test_javascript_scheme_inert(self):
"""javascript:alert(1) 无特殊字符、转义前后同形——只能作为纯文本出现。"""
html = self._render("javascript:alert(1)", "javascript:alert(1)")
self.assertIsNone(self._LIVE_JS_URL.search(html))
def test_csp_meta_present(self):
"""CSP meta 存在且按模板实际资源类型收紧:default-src 'none',
仅放行 data: 图片/字体与 inline 样式。"""
from render.html import build_html
html = build_html(_chart())
m = re.search(
r'<meta http-equiv="Content-Security-Policy" content="([^"]+)"', html)
self.assertIsNotNone(m, "缺少 CSP meta")
csp = m.group(1)
self.assertIn("default-src 'none'", csp)
self.assertIn("img-src data:", csp)
self.assertIn("style-src 'unsafe-inline'", csp)
self.assertIn("font-src data:", csp)
def test_no_script_tag(self):
"""盘面为纯静态页:输出不含任何 <script>。"""
from render.html import build_html
self.assertNotIn("<script", build_html(_chart()).lower())
def test_structural_placeholders_intact(self):
"""转义不误伤结构性占位符:表格/条带 HTML 片段原样保留。"""
html = self._render("<b>x</b>", '<i>y</i>')
self.assertIn('<table class="pan">', html)
self.assertIn('<div class="strip">', html)
self.assertIn('data:image/png;base64,', html)
class CastHtmlE2ETest(unittest.TestCase):
def test_html_flag_writes_file(self):
with tempfile.TemporaryDirectory() as td:
+160 -20
View File
@@ -1,8 +1,11 @@
"""自更新:版本比较、24h 缓存、manifest 校验拦截、apply 同步与保留区、退出码。
远端用本地临时目录伪造:VERSION / zipball 均以 file:// 地址经
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS 注入,不碰真实网络。
远端用本地临时目录伪造:VERSION / zipball / manifest 直链均以 file://
地址经 ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS / ZIPEC_MANIFEST_URLS
注入,不碰真实网络。manifest 一律用测试密钥签名,UPDATE_PUBLIC_KEY_HEX
由 autouse fixture 替换为测试公钥。
"""
import base64
import hashlib
import json
import subprocess
@@ -14,8 +17,19 @@ from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import _update_sig
import self_update as su
# 测试专用密钥(与正式私钥无关)
TEST_SEED = bytes(range(32))
TEST_PK_HEX = _update_sig.publickey(TEST_SEED).hex()
OTHER_SEED = bytes(range(32, 64))
@pytest.fixture(autouse=True)
def _test_public_key(monkeypatch):
monkeypatch.setattr(su, "UPDATE_PUBLIC_KEY_HEX", TEST_PK_HEX)
# ---- 造本地副本与伪造远端 ----
@@ -38,10 +52,19 @@ def make_local(tmp_path, version="3.5.0"):
return root
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
"""造远端 zipball(带一层顶层目录,模拟 GitHub codeload 结构)+ 远端 VERSION。
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None,
sign_seed=TEST_SEED, zip_sig=True, sig_message=None,
diverge_source=False):
"""造远端:zipball(带一层顶层目录,模拟 GitHub codeload)+ 远端 VERSION
+ 两个"源"目录(src_a/src_b,各存 manifest.json 与 manifest.json.sig
直链文件,供双源交叉验证)。
tamper=<相对路径>:先按原内容算清单哈希,再篡改该文件,用于触发校验拦截。
tamper=<相对路径>:先按原内容算清单哈希并签名,再篡改该文件 → 触发逐文件
SHA256 拦截。sign_seed 控制签名密钥(换 OTHER_SEED 模拟错误私钥)。
zip_sig=False:zip 内不放 manifest.json.sig(源目录仍放,可测单独拉取;
配合 source_sig=False 则彻底无签名)。sig_message:改签别的内容,
模拟 manifest 与签名不符。diverge_source=True:src_b 的 manifest 改一字节,
模拟两源不一致。
"""
src = tmp_path / "zipcontent" / "ZiPingEightChar-main"
src.mkdir(parents=True)
@@ -54,25 +77,43 @@ def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
rel: hashlib.sha256((src / rel).read_bytes()).hexdigest()
for rel in all_files
}
(src / "manifest.json").write_text(
json.dumps({"version": version, "files": hashes}, indent=2) + "\n",
encoding="utf-8",
)
manifest_bytes = (json.dumps({"version": version, "files": hashes}, indent=2)
+ "\n").encode("utf-8")
(src / "manifest.json").write_bytes(manifest_bytes)
sig_b64 = base64.b64encode(
_update_sig.sign(sign_seed, sig_message if sig_message is not None
else manifest_bytes)).decode("ascii") + "\n"
if tamper:
(src / tamper).write_text("# tampered\n", encoding="utf-8")
if zip_sig:
(src / "manifest.json.sig").write_text(sig_b64, encoding="utf-8")
zip_path = tmp_path / "remote.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
for f in sorted(src.parent.rglob("*")):
zf.write(f, f.relative_to(src.parent))
# 双源直链文件
source_dirs = []
for name in ("src_a", "src_b"):
d = tmp_path / name
d.mkdir()
m = manifest_bytes
if diverge_source and name == "src_b":
m = m.replace(b'"files"', b'"filez"', 1)
(d / "manifest.json").write_bytes(m)
(d / "manifest.json.sig").write_text(sig_b64, encoding="utf-8")
source_dirs.append(d)
version_file = tmp_path / "REMOTE_VERSION"
version_file.write_text(version + "\n", encoding="utf-8")
return version_file, zip_path
return version_file, zip_path, source_dirs
def point_at_remote(monkeypatch, version_file, zip_path=None):
def point_at_remote(monkeypatch, version_file, zip_path=None, source_dirs=None):
monkeypatch.setenv("ZIPEC_VERSION_URLS", version_file.as_uri())
if zip_path is not None:
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zip_path.as_uri())
if source_dirs is not None:
monkeypatch.setenv("ZIPEC_MANIFEST_URLS", ",".join(
(d / "manifest.json").as_uri() for d in source_dirs))
REMOTE_FILES = {
@@ -91,7 +132,7 @@ class TestVersionCompare:
def test_newer_remote_marks_update(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
vf, _ = make_remote(tmp_path)
vf, _, _ = make_remote(tmp_path)
point_at_remote(monkeypatch, vf)
assert su.cmd_check(root) == 0
cache = json.loads((root / "data" / ".update_check.json").read_text())
@@ -100,7 +141,7 @@ class TestVersionCompare:
def test_same_version_no_update(self, tmp_path, monkeypatch):
root = make_local(tmp_path)
vf, _ = make_remote(tmp_path, version="3.5.0")
vf, _, _ = make_remote(tmp_path, version="3.5.0")
point_at_remote(monkeypatch, vf)
assert su.cmd_check(root) == 0
cache = json.loads((root / "data" / ".update_check.json").read_text())
@@ -167,15 +208,17 @@ class TestCacheFreshness:
class TestApply:
def test_success_syncs_and_preserves(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp = make_remote(tmp_path, files=REMOTE_FILES)
point_at_remote(monkeypatch, vf, zp)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 0
assert "更新完成" in capsys.readouterr().out
out = capsys.readouterr().out
assert "交叉验证通过" in out and "签名校验通过" in out and "更新完成" in out
# 清单内文件被覆盖/新增
assert (root / "VERSION").read_text().strip() == "3.6.0"
assert (root / "scripts" / "cast_chart.py").read_text() == "# new\n"
assert (root / "scripts" / "new_feature.py").exists()
assert (root / "manifest.json").exists()
assert (root / "manifest.json.sig").exists() # 签名随包同步到本地
# 清单不含的本地多余文件被清理
assert not (root / "stale_legacy.py").exists()
assert not (root / "legacy").exists() # 清空后的空目录一并移除
@@ -187,9 +230,9 @@ class TestApply:
def test_tampered_file_aborts_untouched(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp = make_remote(tmp_path, files=REMOTE_FILES,
tamper="scripts/cast_chart.py")
point_at_remote(monkeypatch, vf, zp)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
tamper="scripts/cast_chart.py")
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 3
assert "不符" in capsys.readouterr().err
# 本地原样不动
@@ -200,7 +243,7 @@ class TestApply:
def test_already_latest_exit_2(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp = make_remote(tmp_path, version="3.5.0")
vf, zp, _ = make_remote(tmp_path, version="3.5.0")
point_at_remote(monkeypatch, vf, zp)
assert su.cmd_apply(root) == 2
assert "无需更新" in capsys.readouterr().out
@@ -218,6 +261,103 @@ class TestApply:
assert (root / "VERSION").read_text().strip() == "3.5.0"
class TestApplySigning:
"""--apply 的签名信任锚与双源交叉验证。"""
def _untouched(self, root):
assert (root / "VERSION").read_text().strip() == "3.5.0"
assert (root / "scripts" / "cast_chart.py").read_text() == "# old\n"
assert (root / "stale_legacy.py").exists()
assert not (root / "scripts" / "new_feature.py").exists()
assert not (root / "manifest.json.sig").exists()
def test_sig_fetched_separately_when_not_in_zip(self, tmp_path, monkeypatch):
"""zip 内无 sig、但源直链有:单独拉取并验签通过。"""
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES, zip_sig=False)
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 0
assert (root / "VERSION").read_text().strip() == "3.6.0"
def test_missing_sig_everywhere_aborts(self, tmp_path, monkeypatch, capsys):
"""zip 与源直链都没有 sig → 视同为旧版或被篡改,拒。"""
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES, zip_sig=False)
for d in dirs:
(d / "manifest.json.sig").unlink()
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 3
assert "未带" in capsys.readouterr().err
self._untouched(root)
def test_sig_message_mismatch_aborts(self, tmp_path, monkeypatch, capsys):
"""签名签的是别的内容(manifest 被换)→ 验签拒。"""
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
sig_message=b"forged manifest")
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 3
assert "签名校验不符" in capsys.readouterr().err
self._untouched(root)
def test_wrong_signing_key_aborts(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
sign_seed=OTHER_SEED)
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 3
assert "签名校验不符" in capsys.readouterr().err
self._untouched(root)
def test_manifest_one_byte_flip_aborts(self, tmp_path, monkeypatch, capsys):
"""manifest 改一字节(源直链同步改、签名照旧)→ 验签拒。"""
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
for d in dirs: # 两源与 zip 保持一致地改,隔离出"验签"这一道
m = d / "manifest.json"
data = bytearray(m.read_bytes())
data[20] ^= 0x01
m.write_bytes(bytes(data))
# zip 内 manifest 也同步改
import zipfile as zf_mod
extract = tmp_path / "repack"
with zf_mod.ZipFile(zp) as z:
z.extractall(extract)
m = extract / "ZiPingEightChar-main" / "manifest.json"
data = bytearray(m.read_bytes())
data[20] ^= 0x01
m.write_bytes(bytes(data))
with zf_mod.ZipFile(zp, "w") as z:
for f in sorted(extract.rglob("*")):
z.write(f, f.relative_to(extract))
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 3
assert "签名校验不符" in capsys.readouterr().err
self._untouched(root)
def test_divergent_sources_abort(self, tmp_path, monkeypatch, capsys):
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
diverge_source=True)
point_at_remote(monkeypatch, vf, zp, dirs)
assert su.cmd_apply(root) == 3
assert "不一致" in capsys.readouterr().err
self._untouched(root)
def test_source_unreachable_aborts(self, tmp_path, monkeypatch, capsys):
"""交叉验证时一个源不可达 → 宁可中止。"""
root = make_local(tmp_path)
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
monkeypatch.setenv("ZIPEC_VERSION_URLS", vf.as_uri())
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zp.as_uri())
monkeypatch.setenv("ZIPEC_MANIFEST_URLS",
(dirs[0] / "manifest.json").as_uri()
+ ",file:///nonexistent/manifest.json")
assert su.cmd_apply(root) == 3
assert "交叉验证" in capsys.readouterr().err
self._untouched(root)
class TestCastChartIntegration:
def test_notice_on_stderr_stdout_clean(self):
"""排盘成功后 stderr 出一行提示;stdout 命盘契约不受影响。"""
+146
View File
@@ -0,0 +1,146 @@
"""外层 tools/sync_license_copy.py 的集成测试。
sync/build/sign 工具留在授权方工作区外层 tools/,不随本仓库分发;
副本或 clone 环境下 tools/ 不存在,整个模块 skip。
测试用微型假母版(monkeypatch 替换模块 SRC)同步进临时授权副本,
不碰真实母版与真实副本;签名用测试私钥,UPDATE_PUBLIC_KEY_HEX 同步造假。
"""
import base64
import importlib.util
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
TOOLS = ROOT.parent / "tools"
SYNC_TOOL = TOOLS / "sync_license_copy.py"
pytestmark = pytest.mark.skipif(
not (SYNC_TOOL.is_file()
and (TOOLS / "build_manifest.py").is_file()
and (TOOLS / "sign_manifest.py").is_file()),
reason="外层 tools/ 不随仓库分发,本环境无同步工具",
)
sys.path.insert(0, str(ROOT / "scripts"))
import _update_sig # noqa: E402
TEST_SEED = bytes(range(32))
TEST_PK_HEX = _update_sig.publickey(TEST_SEED).hex()
OTHER_SEED = bytes(range(32, 64)) # 与内置公钥不匹配的错误私钥
STALE_SIG = base64.b64encode(b"\x00" * 64).decode("ascii") + "\n"
def _load_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
sync_tool = _load_module("sync_license_copy", SYNC_TOOL)
build_manifest = _load_module("build_manifest", TOOLS / "build_manifest.py")
def _make_master(src: Path, pk_hex: str) -> None:
"""微型母版:VERSION/SKILL.md/rules×2/cast_chart.py/self_update.py + 过期 manifest。"""
(src / "rules").mkdir(parents=True)
(src / "scripts").mkdir(parents=True)
(src / "VERSION").write_text("9.9.9-test\n", encoding="utf-8")
(src / "SKILL.md").write_text("---\nname: test\n---\n\n# 母版\n",
encoding="utf-8")
(src / "rules" / "r1.md").write_text("# 规则一\n", encoding="utf-8")
(src / "rules" / "r2.md").write_text("# 规则二\n", encoding="utf-8")
(src / "scripts" / "cast_chart.py").write_text(
"#!/usr/bin/env python3\nprint('chart')\n", encoding="utf-8")
(src / "scripts" / "self_update.py").write_text(
f'UPDATE_PUBLIC_KEY_HEX = "{pk_hex}"\n', encoding="utf-8")
# 母版随仓库分发的旧 manifest/sig:同步进副本后必须被重建/重签或移除
(src / "manifest.json").write_text('{"version": "9.9.9-test", "files": {}}\n',
encoding="utf-8")
(src / "manifest.json.sig").write_text(STALE_SIG, encoding="utf-8")
def _make_copy(dst: Path) -> None:
"""已存在的授权副本:授权状态文件 + 带副本编号水印的 SKILL.md。"""
(dst / "data").mkdir(parents=True)
(dst / "data" / ".license.json").write_text("{}", encoding="utf-8")
(dst / "SKILL.md").write_text(
"---\nname: test\n---\n\n<!-- 副本编号:LY-TEST0000-0000 · 授权对象:测试员 · "
"测试授权 · 禁止商业用途与个人传播 -->\n", encoding="utf-8")
@pytest.fixture
def pair(tmp_path, monkeypatch):
src, dst = tmp_path / "master", tmp_path / "copy"
_make_master(src, TEST_PK_HEX)
_make_copy(dst)
monkeypatch.setattr(sync_tool, "SRC", src)
return src, dst
def _key_file(tmp_path, seed: bytes) -> Path:
p = tmp_path / "signing.key"
p.write_text("# 测试私钥\n" + seed.hex() + "\n", encoding="utf-8")
return p
def _manifest_on_disk(dst: Path) -> dict:
return json.loads((dst / "manifest.json").read_text(encoding="utf-8"))
def test_sync_rebuilds_manifest_and_signs(pair, tmp_path, capsys):
"""同步后 manifest 与水印后文件逐哈希一致,sig 用内置公钥验签通过。"""
_src, dst = pair
key = _key_file(tmp_path, TEST_SEED)
assert sync_tool.main(["--key", str(key), str(dst)]) == 0
manifest = _manifest_on_disk(dst)
# 与 build_manifest 对水印后磁盘文件重新计算的结果完全一致
assert manifest == build_manifest.build_manifest(dst)
for rel in ("SKILL.md", "rules/r1.md", "rules/r2.md", "scripts/cast_chart.py"):
assert rel in manifest["files"]
# 水印确实落在文件里(哈希对应的是水印后内容)
assert "LY-TEST0000-0000" in (dst / "SKILL.md").read_text(encoding="utf-8")
sig_text = (dst / "manifest.json.sig").read_text(encoding="utf-8")
assert sig_text != STALE_SIG # 过期 sig 被新签名覆盖而非原样保留
sig = base64.b64decode(sig_text.strip(), validate=True)
assert _update_sig.verify(bytes.fromhex(TEST_PK_HEX),
(dst / "manifest.json").read_bytes(), sig)
out = capsys.readouterr().out
assert f"公钥指纹 {_update_sig.fingerprint(bytes.fromhex(TEST_PK_HEX))[:16]}" in out
assert f"{len(manifest['files'])} 个文件" in out
def test_sync_no_sign_removes_stale_sig(pair, capsys):
"""--no-sign:manifest 重建、过期 sig 移除、WARN、退出码 0。"""
_src, dst = pair
assert sync_tool.main(["--no-sign", str(dst)]) == 0
assert _manifest_on_disk(dst) == build_manifest.build_manifest(dst)
assert not (dst / "manifest.json.sig").exists()
assert "WARN" in capsys.readouterr().err
def test_sync_missing_key_fails_closed(pair, tmp_path, capsys):
"""私钥缺失:manifest 已重建、过期 sig 移除、报错、退出码非 0。"""
_src, dst = pair
missing = tmp_path / "no-such.key"
assert sync_tool.main(["--key", str(missing), str(dst)]) == 1
assert _manifest_on_disk(dst) == build_manifest.build_manifest(dst)
assert not (dst / "manifest.json.sig").exists()
err = capsys.readouterr().err
assert "私钥不存在" in err and "未签名" in err
def test_sync_wrong_key_fails_closed(pair, tmp_path, capsys):
"""签名私钥与副本内置公钥不匹配:独立验签拦截,sig 移除,退出码非 0。"""
_src, dst = pair
key = _key_file(tmp_path, OTHER_SEED)
assert sync_tool.main(["--key", str(key), str(dst)]) == 1
assert _manifest_on_disk(dst) == build_manifest.build_manifest(dst)
assert not (dst / "manifest.json.sig").exists()
assert "验签" in capsys.readouterr().err
+152
View File
@@ -0,0 +1,152 @@
"""ed25519(RFC 8032)纯 Python 实现与 manifest 签名工具的测试。
TEST 1/2/3 为 RFC 8032 §7.1 官方测试向量(空消息/1 字节/2 字节)。
"""
import base64
import json
import os
import stat
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import _update_sig as ed
# RFC 8032 §7.1:(SECRET KEY, PUBLIC KEY, MESSAGE, SIGNATURE)
RFC8032_VECTORS = [
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
"",
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155"
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
"72",
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da"
"085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
"af82",
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac"
"18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"),
]
class TestRFC8032Vectors:
@pytest.mark.parametrize("i", range(len(RFC8032_VECTORS)))
def test_vector(self, i):
seed, pk, msg, sig = (bytes.fromhex(x) for x in RFC8032_VECTORS[i])
assert ed.publickey(seed) == pk # 公钥推导
assert ed.sign(seed, msg) == sig # 确定性签名逐字节一致
assert ed.verify(pk, msg, sig) is True # 验签通过
class TestSignVerifyRoundtrip:
def test_roundtrip_random_key(self):
seed = os.urandom(32)
msg = '{"version": "9.9.9", "files": {}}'.encode()
assert ed.verify(ed.publickey(seed), msg, ed.sign(seed, msg)) is True
def test_message_one_byte_flip_fails(self):
seed = os.urandom(32)
pk = ed.publickey(seed)
msg = b'{"files": {"a.py": "abc"}}'
sig = ed.sign(seed, msg)
bad = bytearray(msg)
bad[3] ^= 0x01
assert ed.verify(pk, bytes(bad), sig) is False
def test_wrong_public_key_fails(self):
msg = b"manifest"
sig = ed.sign(os.urandom(32), msg)
assert ed.verify(ed.publickey(os.urandom(32)), msg, sig) is False
def test_forged_signature_fails(self):
seed = os.urandom(32)
pk, msg = ed.publickey(seed), b"manifest"
sig = bytearray(ed.sign(seed, msg))
sig[10] ^= 0x01 # 伪造:改 R 的一个字节
assert ed.verify(pk, msg, bytes(sig)) is False
assert ed.verify(pk, msg, os.urandom(64)) is False # 全随机签名
def test_malformed_inputs_return_false_not_raise(self):
pk = ed.publickey(os.urandom(32))
assert ed.verify(pk, b"m", b"") is False # 长度不足
assert ed.verify(pk, b"m", b"\xff" * 64) is False # S 越界
assert ed.verify(b"\xff" * 32, b"m", ed.sign(os.urandom(32), b"m")) is False
assert ed.verify(b"short", b"m", b"x" * 64) is False # 公钥长度错
sig = bytearray(ed.sign(os.urandom(32), b"m"))
sig[31] |= 0x80 # R 的 y 坐标 >= 2^255-19 界外/非法点
sig[0:32] = b"\xff" * 32
assert ed.verify(pk, b"m", bytes(sig)) is False
SIGN_TOOL = ROOT.parent / "tools" / "sign_manifest.py"
class TestSignManifestTool:
def _repo(self, tmp_path):
root = tmp_path / "repo"
root.mkdir()
(root / "manifest.json").write_text(
json.dumps({"version": "9.9.9", "files": {"a.py": "0" * 64}},
indent=2) + "\n", encoding="utf-8")
seed = os.urandom(32)
key = tmp_path / "signing.key"
key.write_text("# 测试私钥\n# 生成日期:2026-10-10\n" + seed.hex() + "\n",
encoding="utf-8")
return root, key, seed
def test_sign_and_verify(self, tmp_path):
root, key, seed = self._repo(tmp_path)
r = subprocess.run(
[sys.executable, str(SIGN_TOOL), "--repo-root", str(root),
"--key", str(key)],
capture_output=True, text=True, timeout=60)
assert r.returncode == 0, r.stderr
sig_text = (root / "manifest.json.sig").read_text(encoding="utf-8")
assert sig_text.strip() == sig_text.rstrip("\n") and "\n" not in sig_text.strip()
sig = base64.b64decode(sig_text.strip(), validate=True)
assert len(sig) == 64
manifest_bytes = (root / "manifest.json").read_bytes()
assert ed.verify(ed.publickey(seed), manifest_bytes, sig) is True
# manifest 改一字节 → 该签名不再有效
bad = bytearray(manifest_bytes)
bad[10] ^= 0x01
assert ed.verify(ed.publickey(seed), bytes(bad), sig) is False
def test_missing_manifest_exit_2(self, tmp_path):
root, key, _ = self._repo(tmp_path)
(root / "manifest.json").unlink()
r = subprocess.run(
[sys.executable, str(SIGN_TOOL), "--repo-root", str(root),
"--key", str(key)],
capture_output=True, text=True, timeout=60)
assert r.returncode == 2
assert "manifest.json" in r.stderr
class TestRealKeySetup:
"""作者侧私钥落盘状态:存在、权限 600、绝不进 git 仓库。"""
def test_key_file_perms_and_not_in_repo(self):
key = Path.home() / ".zipec" / "signing.key"
if not key.exists():
pytest.skip("作者机上才有私钥")
assert stat.S_IMODE(key.stat().st_mode) == 0o600
assert stat.S_IMODE(key.parent.stat().st_mode) == 0o700
r = subprocess.run(["git", "-C", str(ROOT), "status", "--porcelain"],
capture_output=True, text=True)
assert "signing.key" not in r.stdout
# 私钥对应的公钥 == self_update 内置信任锚
import self_update as su
seed = bytes.fromhex(
[ln for ln in key.read_text().splitlines()
if ln.strip() and not ln.startswith("#")][0])
assert ed.publickey(seed).hex() == su.UPDATE_PUBLIC_KEY_HEX
assert ed.fingerprint(ed.publickey(seed)) == (
"3089bc1cc9a453e5be66a36574635da6b2ad9797c57af11164111faca157411c")