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:
+12
-9
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/hVDbf9RW2/oWP5z1nhGyc6/Ip76Inw4mLKaRtsojbuDS/tAaY9km8phEBGgC4T8qunh/FD8qc4dcVHk+iJfCA==
|
||||
+1
-1
@@ -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
@@ -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 文件不变)。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)>',
|
||||
)
|
||||
|
||||
# 活跃标签(真实 < 起首、未被 < 转义)携带事件属性或 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
@@ -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 命盘契约不受影响。"""
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user