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
+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")