feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、 元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py` (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2 (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验; `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码; `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因; 工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe, `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁), 新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与 `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。 - Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性) + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准, 而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。 - Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/ compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过 (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失; 真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0, 改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内), 20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms); 长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min, 队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、 跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、 平台锁定不是终态诊断。 - Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档); GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象); `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra `protected-assets`)。
This commit is contained in:
@@ -1,20 +1,39 @@
|
||||
"""Compile a legacy export into a runtime package (stage 2 of the 3D plan).
|
||||
"""Compile a legacy export into a runtime package (stages 2 and 5 of the 3D plan).
|
||||
|
||||
Offline only: this is the tool that bakes the coordinate transform, the unit
|
||||
scale, the UV sampling table and the identity hashes into one file, so the runtime
|
||||
never repeats that work.
|
||||
never repeats that work. Stage 5 adds the three release protections the design
|
||||
document asks for, and each is optional:
|
||||
|
||||
* ``--sign-key``: sign the package with an Ed25519 private key that stays on the
|
||||
build machine. The client carries only the matching public key.
|
||||
* ``--encrypt-key-id`` + ``--key-file``: additionally seal the content with
|
||||
AES-256-GCM. Encryption never replaces signing; it is added to it.
|
||||
* ``--lock-*`` (or ``--lock-from-probe``): record where the package may be
|
||||
displayed. A release compiled for a GPU must not silently render on llvmpipe.
|
||||
|
||||
Units are an argument, never a default. The legacy mesh is 15.8 x 15.0 x 7.5 source
|
||||
units; whether one of those is a millimetre is a physical claim about the sensor,
|
||||
and ``validate_scene.py`` prints the measured candidates so a person can decide.
|
||||
|
||||
Example::
|
||||
Examples::
|
||||
|
||||
# an engineering package: plain, unsigned, unlocked
|
||||
python tools/scene_assets/compile_scene.py \\
|
||||
--legacy-dir sensors/finger/assets/30fps-0703 \\
|
||||
--map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\
|
||||
--meters-per-source-unit 0.001 \\
|
||||
--output build/finger-display-1.0.0.oryscene
|
||||
|
||||
# a release package: signed, sealed, locked to the machine we probed
|
||||
python tools/scene_assets/compile_scene.py \\
|
||||
--legacy-dir sensors/finger/assets/30fps-0703 \\
|
||||
--map sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \\
|
||||
--meters-per-source-unit 0.001 \\
|
||||
--sign-key keys/build-ed25519.pem --sign-key-id build-2024 \\
|
||||
--encrypt-key-id finger-display --key-file keys/finger-display.key \\
|
||||
--lock-from-probe docs/architecture/gl-report-windows-dev.json \\
|
||||
--output build/finger-display-1.0.0.oryscene
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +52,77 @@ def _bootstrap_paths() -> None:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
|
||||
def _platform_lock_from_args(args):
|
||||
"""The lock the command line describes, from a probe report or from flags."""
|
||||
from orisys_app_shared.scene3d.contracts import PlatformLock
|
||||
|
||||
if args.lock_from_probe is not None:
|
||||
report = json.loads(Path(args.lock_from_probe).read_text(encoding="utf-8"))
|
||||
context = report.get("context") or {}
|
||||
host = report.get("host") or {}
|
||||
os_family = host.get("os_family") or host.get("system") or ""
|
||||
machine = host.get("machine") or ""
|
||||
return PlatformLock(
|
||||
os_family=(str(os_family).lower(),) if os_family else (),
|
||||
machine=(str(machine).lower(),) if machine else (),
|
||||
gl_profile=(
|
||||
str(context.get("profile_used")) if context.get("profile_used") else None
|
||||
),
|
||||
allowed_vendors=(str(context["vendor"]),) if context.get("vendor") else (),
|
||||
allowed_renderers=(str(context["renderer"]),) if context.get("renderer") else (),
|
||||
forbid_software_renderer=not args.allow_software_renderer,
|
||||
min_texture_size=int(context.get("max_texture_size") or 0),
|
||||
note=str(args.lock_note or f"qualified from {Path(args.lock_from_probe).name}"),
|
||||
)
|
||||
lock = PlatformLock(
|
||||
os_family=tuple(v.lower() for v in (args.lock_os or ())),
|
||||
machine=tuple(v.lower() for v in (args.lock_machine or ())),
|
||||
gl_profile=args.lock_gl_profile,
|
||||
allowed_vendors=tuple(args.lock_vendor or ()),
|
||||
allowed_renderers=tuple(args.lock_renderer or ()),
|
||||
forbid_software_renderer=not args.allow_software_renderer,
|
||||
min_texture_size=int(args.lock_min_texture or 0),
|
||||
note=str(args.lock_note or ""),
|
||||
)
|
||||
return None if lock.is_unrestricted() and not args.lock_note else lock
|
||||
|
||||
|
||||
def _sealing_from_args(args):
|
||||
"""The key material the command line points at, or ``None`` for a plain package."""
|
||||
from orisys_app_shared.scene3d.contracts import Sealing
|
||||
from orisys_app_shared.scene3d import protect
|
||||
|
||||
signing_pem = None
|
||||
if args.sign_key is not None:
|
||||
path = Path(args.sign_key)
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"the signing key is missing: {path}")
|
||||
signing_pem = path.read_bytes()
|
||||
# Prove the key is usable before spending a compile on it.
|
||||
protect.public_key_pem(signing_pem)
|
||||
provider = None
|
||||
if args.encrypt_key_id:
|
||||
if args.key_file is None:
|
||||
raise SystemExit("--encrypt-key-id also needs --key-file")
|
||||
provider = protect.load_key_file(Path(args.key_file), key_id=args.encrypt_key_id)
|
||||
if signing_pem is None and provider is None:
|
||||
return None
|
||||
verify = protect.public_key_pem(signing_pem) if signing_pem is not None else None
|
||||
if verify is not None and args.write_verify_key is not None:
|
||||
# The client's half of the pair. Publishing it is part of the release, and
|
||||
# writing it here keeps it from being re-derived differently somewhere else.
|
||||
target = Path(args.write_verify_key)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(verify)
|
||||
return Sealing(
|
||||
key_id=str(args.encrypt_key_id or ""),
|
||||
provider=provider,
|
||||
signing_key_pem=signing_pem,
|
||||
signing_key_reference=str(args.sign_key_id or "build-key"),
|
||||
verify_key_pem=verify,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--legacy-dir", type=Path, required=True, help="legacy NPZ export")
|
||||
@@ -54,6 +144,37 @@ def main(argv: list[str] | None = None) -> int:
|
||||
default=None,
|
||||
help="optional 4x4 rigid transform (npy) from source to sensor coordinates",
|
||||
)
|
||||
protection = parser.add_argument_group(
|
||||
"release protection (stage 5)", "all optional; a plain package is the default"
|
||||
)
|
||||
protection.add_argument("--sign-key", type=Path, default=None, help="Ed25519 private key (PEM)")
|
||||
protection.add_argument("--sign-key-id", default="", help="name recorded in the envelope")
|
||||
protection.add_argument(
|
||||
"--write-verify-key",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="write the public half of --sign-key here (this is what the client ships)",
|
||||
)
|
||||
protection.add_argument("--encrypt-key-id", default="", help="content key id to seal with")
|
||||
protection.add_argument("--key-file", type=Path, default=None, help="key for --encrypt-key-id")
|
||||
protection.add_argument(
|
||||
"--lock-from-probe",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="take the platform lock from a probe_gl.py report",
|
||||
)
|
||||
protection.add_argument("--lock-os", action="append", default=None, help="repeatable")
|
||||
protection.add_argument("--lock-machine", action="append", default=None, help="repeatable")
|
||||
protection.add_argument("--lock-gl-profile", default=None, choices=(None, "desktop33", "gles30"))
|
||||
protection.add_argument("--lock-vendor", action="append", default=None, help="repeatable")
|
||||
protection.add_argument("--lock-renderer", action="append", default=None, help="repeatable")
|
||||
protection.add_argument("--lock-min-texture", type=int, default=0)
|
||||
protection.add_argument("--lock-note", default="")
|
||||
protection.add_argument(
|
||||
"--allow-software-renderer",
|
||||
action="store_true",
|
||||
help="do not forbid a software rasteriser (the design document says do)",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@@ -62,13 +183,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import ThreeDError
|
||||
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter
|
||||
from orisys_app_shared.scene3d.package import write_package
|
||||
from orisys_app_shared.scene3d.package import describe_envelope, write_package
|
||||
|
||||
transform = (
|
||||
np.load(args.transform, allow_pickle=False)
|
||||
if args.transform is not None
|
||||
else np.eye(4)
|
||||
)
|
||||
try:
|
||||
sealing = _sealing_from_args(args)
|
||||
lock = _platform_lock_from_args(args)
|
||||
except ThreeDError as exc:
|
||||
print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
assets = LegacyFingerImporter().convert(
|
||||
args.legacy_dir,
|
||||
@@ -79,14 +206,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
asset_version=args.version,
|
||||
lod_id=args.lod_id,
|
||||
)
|
||||
if lock is not None:
|
||||
from dataclasses import replace
|
||||
|
||||
# Part of the asset, so it travels into the manifest and the signature.
|
||||
assets = replace(assets, platform_lock=lock)
|
||||
report = write_package(
|
||||
assets, args.output,
|
||||
sensor_model=args.sensor_model,
|
||||
hardware_revisions=("*",),
|
||||
sealing=sealing,
|
||||
)
|
||||
except ThreeDError as exc:
|
||||
print(f"rejected: {exc.diagnostic.code}: {exc.diagnostic.message}", file=sys.stderr)
|
||||
return 2
|
||||
envelope = describe_envelope(report.output)
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"output": str(report.output),
|
||||
@@ -95,6 +229,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"triangles": report.part_triangle_counts,
|
||||
"geometry_id": assets.geometry_id,
|
||||
"field_domain_id": assets.field_domain_id,
|
||||
"envelope": envelope,
|
||||
"platform_lock": (
|
||||
assets.platform_lock.to_dict() if assets.platform_lock is not None else None
|
||||
),
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
@@ -105,6 +243,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
print(f" geometry_id={assets.geometry_id}")
|
||||
print(f" asset_hash={report.asset_hash}")
|
||||
print(
|
||||
f" envelope=v{envelope['envelope_version']} {envelope['payload']}"
|
||||
f"{' signed(' + str(envelope['signing_key']) + ')' if envelope['signed'] else ' unsigned'}"
|
||||
+ (f" sealed for key {envelope['key_id']}" if envelope["sealed"] else "")
|
||||
)
|
||||
if summary["platform_lock"]:
|
||||
print(f" platform_lock={json.dumps(summary['platform_lock'], ensure_ascii=False)}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user