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:
+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