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