Files
moxiaonian 415cb472b1 v3.6.1 安全修复同步发版
- 盘面图 XSS 防护:装配层全面转义用户可控字段 + 模板严格 CSP(default-src 'none',无 script-src)+ 6 项注入向量回归测试
- 自更新签名信任锚:--apply 双源 manifest 交叉验证 → ed25519 验签(纯 Python 实现,缺 sig 即拒)→ 逐文件 SHA256,全 fail-closed
- sync 工具同步后自动重建 manifest + 签名 + 独立验签,私钥缺失 fail-closed
- 测试 170 项全绿
2026-10-10 16:21:30 +08:00

153 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")