116 lines
6.3 KiB
Python
116 lines
6.3 KiB
Python
"""签名发行元数据与七类包验证;不执行包内代码。"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import re
|
|
import stat
|
|
import zipfile
|
|
from typing import Literal
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
|
|
class Release(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
schema_version: Literal[1] = 1
|
|
namespace: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{1,63}$")
|
|
package_id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{1,63}$")
|
|
type: Literal["theme", "skill", "plugin", "mcp", "persona", "template", "model"]
|
|
version: str = Field(pattern=r"^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$")
|
|
name: str = Field(min_length=1, max_length=120)
|
|
author_id: str = Field(min_length=1, max_length=80)
|
|
license: str = Field(min_length=1, max_length=80)
|
|
description: str = Field(max_length=10000)
|
|
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
size: int = Field(gt=0, le=10 * 1024 * 1024)
|
|
platforms: list[str] = Field(max_length=12)
|
|
architectures: list[str] = Field(max_length=12)
|
|
min_app_version: str
|
|
max_app_version: str | None = None
|
|
dependencies: dict[str, str] = Field(default_factory=dict, max_length=64)
|
|
permissions: list[str] = Field(default_factory=list, max_length=64)
|
|
changelog: str = Field(max_length=10000)
|
|
published_at: str = Field(max_length=40)
|
|
key_id: str = Field(pattern=r"^[a-zA-Z0-9-]{1,80}$")
|
|
signature: str = Field(max_length=128)
|
|
|
|
@field_validator("license")
|
|
@classmethod
|
|
def declared_license(cls, value):
|
|
if value.lower() in {"unknown", "none", "unlicensed", "tbd"}:
|
|
raise ValueError("公开目录要求明确许可证")
|
|
return value
|
|
|
|
|
|
def signed_payload(release: Release) -> bytes:
|
|
# 固定 canonical JSON,签名覆盖类型、权限、兼容版本与对象摘要,而非只签 ZIP。
|
|
return json.dumps(release.model_dump(exclude={"signature"}), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
|
|
|
|
def verify(release: Release, public_key: bytes):
|
|
Ed25519PublicKey.from_public_bytes(public_key).verify(base64.b64decode(release.signature, validate=True), signed_payload(release))
|
|
|
|
|
|
def inspect(release: Release, blob: bytes):
|
|
if len(blob) != release.size or hashlib.sha256(blob).hexdigest() != release.sha256:
|
|
raise ValueError("摘要或长度不匹配")
|
|
max_size, max_entries = ((10 * 1024 * 1024, 100) if release.type == "theme" else (50 * 1024 * 1024, 2048))
|
|
if release.type == "theme" and len(blob) > 5 * 1024 * 1024:
|
|
raise ValueError("主题包超过 5 MiB")
|
|
names, files, total = set(), {}, 0
|
|
with zipfile.ZipFile(io.BytesIO(blob)) as archive:
|
|
if len(archive.infolist()) > max_entries:
|
|
raise ValueError("条目过多")
|
|
for item in archive.infolist():
|
|
name = item.filename.rstrip("/")
|
|
parts = name.split("/")
|
|
mode = item.external_attr >> 16
|
|
if (not name or any(part in {"", ".", ".."} or part.endswith((".", " ")) for part in parts)
|
|
or re.search(r'[\\:\x00-\x1f<>|?*]', name)
|
|
or any(re.fullmatch(r"(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?", p) for p in parts)
|
|
or name.casefold() in names or item.flag_bits & 1
|
|
or stat.S_ISLNK(mode) or stat.S_IFMT(mode) not in {0, stat.S_IFREG, stat.S_IFDIR}
|
|
or item.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}):
|
|
raise ValueError("不安全 ZIP")
|
|
names.add(name.casefold())
|
|
total += item.file_size
|
|
if total > max_size:
|
|
raise ValueError("解压限制")
|
|
if not item.is_dir(): files[name] = archive.read(item)
|
|
required = {"theme": "theme.yaml", "skill": "skill.yaml", "plugin": "plugin.yaml",
|
|
"mcp": "mcp.json", "persona": "persona.json", "template": "template.json", "model": "model.json"}[release.type]
|
|
matches = [path for path in files if path == required or path.endswith("/" + required)]
|
|
if len(matches) != 1:
|
|
raise ValueError("类型清单缺失或不唯一")
|
|
if release.type in {"theme", "skill", "plugin"}:
|
|
import yaml
|
|
value = yaml.safe_load(files[matches[0]])
|
|
identity = {"theme": "theme_id", "skill": "skill_id", "plugin": "plugin_id"}[release.type]
|
|
if (not isinstance(value, dict) or value.get(identity, value.get("id") if release.type in {"plugin", "skill"} else None) != release.package_id
|
|
or (identity in value and "id" in value and value[identity] != value["id"])
|
|
or value.get("version") != release.version):
|
|
raise ValueError("发行身份与类型清单不一致")
|
|
if set(value.get("permissions", [])) != set(release.permissions):
|
|
raise ValueError("发行权限与类型清单不一致")
|
|
if release.type in {"mcp", "persona", "template", "model"}:
|
|
value = json.loads(files[matches[0]])
|
|
if not isinstance(value, dict): raise ValueError("清单必须为对象")
|
|
forbidden = {"api_key", "password", "token", "secret", "chat_history", "messages"}
|
|
def check(node):
|
|
if isinstance(node, dict):
|
|
if forbidden.intersection(str(k).lower() for k in node): raise ValueError("清单混入秘密或历史")
|
|
for child in node.values(): check(child)
|
|
elif isinstance(node, list):
|
|
for child in node: check(child)
|
|
check(value)
|
|
if release.type == "persona" and not isinstance(value.get("system_prompt"), str): raise ValueError("缺少人设提示")
|
|
if release.type == "template" and (not isinstance(value.get("markdown"), str) or value.get("executable")): raise ValueError("模板不能执行程序")
|
|
if release.type == "model" and not all(value.get(k) for k in ["source", "revision", "license", "resources", "verified_platforms"]): raise ValueError("模型方案不完整")
|
|
if release.type == "mcp":
|
|
if value.get("transport") not in {"stdio", "streamable_http", "sse"}: raise ValueError("不支持 transport")
|
|
if value["transport"] == "stdio" and not isinstance(value.get("args"), list): raise ValueError("参数必须为数组")
|
|
return {"files": len(files), "expanded_size": total, "manifest": matches[0]}
|