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:
ykcheungab
2026-09-14 16:44:33 +08:00
parent ac4dcd643f
commit 5cd92254e1
24 changed files with 5448 additions and 188 deletions
+9
View File
@@ -17,6 +17,15 @@ dependencies = [
"orisys>=0.5",
]
[project.optional-dependencies]
# Reading or writing a *sealed* `.oryscene` (stage 5): Ed25519 signatures and
# AES-256-GCM. Optional on purpose — a plain package needs only stdlib zipfile plus
# numpy, and the loader refuses a sealed package (naming this extra) rather than
# pretending the ciphertext is data.
protected-assets = [
"cryptography>=42",
]
[tool.setuptools]
package-dir = { "" = "src" }
@@ -21,12 +21,21 @@ from orisys_app_shared.record.playback import (
RecordingReader,
list_recordings,
)
from orisys_app_shared.record.visualization3d import (
BLOCK_VERSION as VISUALIZATION3D_BLOCK_VERSION,
VISUALIZATION3D_KEY,
Visualization3DRecord,
build_block as build_visualization3d_block,
read_block as read_visualization3d_block,
)
__all__ = [
"EVENTS_HEADER",
"FORCES_HEADER",
"FRAMES_HEADER",
"RECORD_FORMAT_VERSION",
"VISUALIZATION3D_BLOCK_VERSION",
"VISUALIZATION3D_KEY",
"ExperimentRecorder",
"RecordConfig",
"RecordReport",
@@ -34,8 +43,11 @@ __all__ = [
"RecordingFrame",
"RecordingInfo",
"RecordingReader",
"Visualization3DRecord",
"build_visualization3d_block",
"format_value",
"list_calibration_snapshots",
"list_recordings",
"read_visualization3d_block",
"store_calibration_snapshot",
]
@@ -0,0 +1,160 @@
"""The ``visualization3d`` block in a recording's ``session.json``.
Stage 5 in ``docs/architecture/studio-3d-stages.md``.
A recording that shows a three-dimensional surface has to say so, because the
picture is part of what was observed: an operator watching an exaggerated
deformation while a colleague reads the CSV is looking at a *different* claim than
the numbers, and six months later nobody can reconstruct which claim was on screen.
So the block records what the pane was, what it displayed it with, and how the
field was labelled — and nothing about the measurement, which the CSV already owns.
Two rules, both about records that outlive the build that wrote them:
* **the writer never fails a recording.** The block is assembled from a live widget,
so every read is guarded: a missing attribute becomes an explicit null rather than
an exception on the way into ``session.json``.
* **the reader treats a missing block as "not supported", not as an error.** A
recording made before this block existed, or by a build without 3D at all, is a
perfectly good recording. ``read_block`` says ``supported=False`` and why, so a
consumer can decide whether it cares.
The counters (frames shown, display frames dropped) are *not* in the block: they are
only known when the recording stops, and a block written at start cannot know them.
They travel as one ``visualization3d`` event instead, which the recorder already
timestamps and which keeps "what was configured" and "what happened" apart.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
#: The key under which the block lives in ``session.json``.
VISUALIZATION3D_KEY = "visualization3d"
#: Bumped when the block's *meaning* changes. A reader that does not know a version
#: reports ``supported=False`` rather than guessing at fields.
BLOCK_VERSION = 1
def build_block(
*,
enabled: bool,
available: bool,
reason: str = "",
scene: Optional[Mapping[str, Any]] = None,
gl: Optional[Mapping[str, Any]] = None,
display: Optional[Mapping[str, Any]] = None,
stream: Optional[Mapping[str, Any]] = None,
) -> dict[str, Any]:
"""One block for ``session.json``. Plain JSON types only, no failures."""
return {
"block_version": BLOCK_VERSION,
"enabled": bool(enabled),
"available": bool(available),
# Why not, in one line. Empty when the pane was up: a reason next to a working
# view only invites the reader to wonder which of the two is true.
"reason": "" if (enabled and available) else str(reason or ""),
"scene": _clean(scene),
"gl": _clean(gl),
"display": _clean(display),
"stream": _clean(stream),
}
def _clean(value: Optional[Mapping[str, Any]]) -> Optional[dict[str, Any]]:
"""Drop nothing silently: nulls stay nulls, and unknown types become text."""
if value is None:
return None
out: dict[str, Any] = {}
for key, item in value.items():
if item is None or isinstance(item, (bool, int, float, str)):
out[str(key)] = item
elif isinstance(item, (list, tuple)):
out[str(key)] = [str(v) if isinstance(v, (bytes, bytearray)) else v for v in item]
else:
out[str(key)] = str(item)
return out
@dataclass(frozen=True)
class Visualization3DRecord:
"""What a recording says about its three-dimensional view."""
supported: bool
enabled: bool = False
available: bool = False
reason: str = ""
scene: Mapping[str, Any] = field(default_factory=dict)
gl: Mapping[str, Any] = field(default_factory=dict)
display: Mapping[str, Any] = field(default_factory=dict)
stream: Mapping[str, Any] = field(default_factory=dict)
block_version: int = 0
def asset_hash(self) -> str:
return str(self.scene.get("asset_hash") or "")
def geometry_id(self) -> str:
return str(self.scene.get("geometry_id") or "")
def was_showing_measurements(self) -> bool:
"""Whether a 3D *measurement* was on screen, as opposed to a reference model.
``quality`` is the field's own claim: ``display_only`` means the pane was
showing an unmeasured surface, which is exactly the case a later reader must
not treat as observed data.
"""
return bool(self.enabled and self.available) and str(
self.stream.get("quality") or ""
) not in ("", "display_only")
def read_block(session: Optional[Mapping[str, Any]]) -> Visualization3DRecord:
"""Read the block out of a ``session.json`` mapping, tolerantly.
Never raises: a broken recording is still a recording, and a consumer asking
"was there a 3D view?" must get an answer for every file, including the ones
written before the question existed.
"""
if not isinstance(session, Mapping):
return Visualization3DRecord(supported=False, reason="no session metadata")
raw = session.get(VISUALIZATION3D_KEY)
if raw is None:
return Visualization3DRecord(
supported=False,
reason=(
"this recording has no visualization3d block: it was made before the "
"3D view was recorded, or by a build without one"
),
)
if not isinstance(raw, Mapping):
return Visualization3DRecord(
supported=False, reason="the visualization3d block is not an object"
)
try:
version = int(raw.get("block_version") or 0)
except (TypeError, ValueError):
return Visualization3DRecord(
supported=False, reason="the visualization3d block has no usable version"
)
if version != BLOCK_VERSION:
return Visualization3DRecord(
supported=False,
block_version=version,
reason=f"visualization3d block version {version} is not {BLOCK_VERSION}",
)
return Visualization3DRecord(
supported=True,
enabled=bool(raw.get("enabled")),
available=bool(raw.get("available")),
reason=str(raw.get("reason") or ""),
scene=_mapping(raw.get("scene")),
gl=_mapping(raw.get("gl")),
display=_mapping(raw.get("display")),
stream=_mapping(raw.get("stream")),
block_version=version,
)
def _mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
+37 -6
View File
@@ -18,6 +18,7 @@ customer sees the wrong part move:
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from typing import Any, Optional
@@ -66,9 +67,12 @@ __all__ = [
class SceneCompiler:
"""Package a scene (and an optional housing GLB) for release. Offline only.
The signing and encryption that :class:`CompileOptions` describes are stage 5:
until then a request for an encrypted package is *refused*, rather than
quietly producing a plain one that a release script would ship as protected.
Signing and encryption are stage 5 and they are *optional*: with no
:class:`~orisys_app_shared.scene3d.contracts.Sealing` the output is the plain
container an engineering build has always produced. What this class will not do is
quietly substitute one for the other — a request for an encrypted package with no
key material, or with a crypto backend that is not installed, is refused rather
than satisfied with a plain file a release script would then ship as protected.
"""
def compile(
@@ -78,13 +82,35 @@ class SceneCompiler:
output: Path,
options: CompileOptions,
) -> CompileReport:
if options.encryption_key_id:
sealing = options.sealing
if options.encryption_key_id and not (sealing and sealing.encrypts()):
raise error(
"asset_auth_failed",
"encrypted packages are stage 5; refusing rather than writing a "
"plain package that claims to be protected",
"an encrypted package was requested but no content key is available "
"(the encryption key id is a name; the key has to come from somewhere)",
key_id=str(options.encryption_key_id),
)
if sealing is not None and (sealing.encrypts() or sealing.signs()):
from orisys_app_shared.scene3d.package import backend_available
if not backend_available():
raise error(
"asset_auth_failed",
"signing or encrypting a package needs the 'cryptography' package; "
"refusing rather than writing a plain package that claims to be "
"protected",
)
if options.encryption_key_id and sealing.key_id and (
str(options.encryption_key_id) != str(sealing.key_id)
):
# Two names for the same key is how a release ends up sealed for a key
# id nobody distributes.
raise error(
"asset_auth_failed",
"the compile options and the key material name different key ids",
options=str(options.encryption_key_id),
sealing=str(sealing.key_id),
)
scene = surface
if housing_glb is not None:
from orisys_app_shared.scene3d.glb import HousingLimits
@@ -106,12 +132,17 @@ class SceneCompiler:
f"the surface has {count} vertices (limit "
f"{int(options.max_surface_vertices)}); export a lower LOD",
)
if options.platform_lock is not None:
# The lock is part of the asset, so it travels into the manifest and hence
# into the signature.
scene = replace(scene, platform_lock=options.platform_lock)
validate_assets(scene)
report = write_package(
scene,
Path(output),
sensor_model=options.expected.sensor_model,
hardware_revisions=(options.expected.hardware_revision,),
sealing=options.sealing,
)
return report
@@ -60,6 +60,9 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
"gpu_budget_exceeded": "error",
"asset_rejected": "error",
"no_data": "info",
#: The package is not released for this machine. An error, but only for the 3D
#: view: the two-dimensional application carries on.
"platform_mismatch": "error",
#: An illustrative display deformation was applied. Informational by design:
#: the picture is legitimate, and the point is that it is *labelled*.
"display_illustrative": "info",
@@ -222,6 +225,211 @@ class ExpectedGeometry:
shape_hw: tuple[int, int]
@dataclass(frozen=True)
class PlatformLock:
"""Where a released package is allowed to be displayed.
The design document's stage 5 asks for a platform lock, and the reason is
commercial rather than technical: a package that ships a compiled surface should
not silently render on a machine nobody qualified. So the lock is *part of the
signed manifest* and the loader enforces it — but the enforcement is deliberately
one-sided: a locked-out machine loses the three-dimensional view and keeps
everything else (acquisition, recording, charts, calibration).
Every field is a *set of allowed values* and an empty set means "no restriction",
so an un-locked package (the default) is not the same as a package locked to
nothing. ``forbid_software_renderer`` defaults to ``True`` because a package that
was qualified on a GPU must not be signed off on llvmpipe.
"""
os_family: tuple[str, ...] = ()
machine: tuple[str, ...] = ()
gl_profile: Optional[Profile] = None
allowed_vendors: tuple[str, ...] = ()
allowed_renderers: tuple[str, ...] = ()
forbid_software_renderer: bool = True
min_texture_size: int = 0
#: Free text for the report ("qualified on the dev box, 2024-06").
note: str = ""
def is_unrestricted(self) -> bool:
return not (
self.os_family
or self.machine
or self.gl_profile
or self.allowed_vendors
or self.allowed_renderers
or self.min_texture_size
)
def to_dict(self) -> dict[str, Any]:
"""Canonical JSON form. Stored in the manifest, hence inside the signature."""
out: dict[str, Any] = {
"os_family": [str(v) for v in self.os_family],
"machine": [str(v) for v in self.machine],
"forbid_software_renderer": bool(self.forbid_software_renderer),
"min_texture_size": int(self.min_texture_size),
}
if self.gl_profile:
out["gl_profile"] = str(self.gl_profile)
if self.allowed_vendors:
out["allowed_vendors"] = [str(v) for v in self.allowed_vendors]
if self.allowed_renderers:
out["allowed_renderers"] = [str(v) for v in self.allowed_renderers]
if self.note:
out["note"] = str(self.note)
return out
@classmethod
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> Optional["PlatformLock"]:
"""Read one back. A malformed lock is an error, not a silently open one."""
if data is None:
return None
if not isinstance(data, Mapping):
raise error("asset_rejected", "the platform lock is not an object")
profile = data.get("gl_profile")
if profile is not None and str(profile) not in ("desktop33", "gles30"):
raise error("asset_rejected", f"unknown gl_profile in the lock: {profile!r}")
def _strings(key: str) -> tuple[str, ...]:
value = data.get(key) or ()
if isinstance(value, str) or not isinstance(value, Iterable):
raise error("asset_rejected", f"the lock's {key} must be a list")
return tuple(str(item) for item in value)
try:
minimum = int(data.get("min_texture_size") or 0)
except (TypeError, ValueError) as exc:
raise error("asset_rejected", "the lock's min_texture_size is not a number") from exc
if minimum < 0:
raise error("asset_rejected", "the lock's min_texture_size is negative")
return cls(
os_family=_strings("os_family"),
machine=_strings("machine"),
gl_profile=str(profile) if profile else None, # type: ignore[arg-type]
allowed_vendors=_strings("allowed_vendors"),
allowed_renderers=_strings("allowed_renderers"),
forbid_software_renderer=bool(data.get("forbid_software_renderer", True)),
min_texture_size=minimum,
note=str(data.get("note") or ""),
)
@dataclass(frozen=True)
class PlatformInfo:
"""What this machine is, for the lock check.
Split in two on purpose: ``os_family``/``machine`` are known before any window
exists, while the graphics fields only exist once Qt has made a context. So a
package locked to another operating system is refused *before* a context is
created, and a package locked to a GPU is refused as soon as the capabilities are
known.
"""
os_family: str
machine: str
gl_profile: Optional[Profile] = None
vendor: str = ""
renderer: str = ""
software_renderer: bool = False
max_texture_size: int = 0
@classmethod
def here(cls, capabilities: Optional[GLCapabilities] = None) -> "PlatformInfo":
import platform as _platform
family = _platform.system().lower()
return cls(
os_family=family or "unknown",
machine=(_platform.machine() or "unknown").lower(),
gl_profile=(capabilities.profile if capabilities is not None else None),
vendor=(capabilities.vendor if capabilities is not None else ""),
renderer=(capabilities.renderer if capabilities is not None else ""),
software_renderer=bool(
capabilities.software_renderer if capabilities is not None else False
),
max_texture_size=int(
getattr(capabilities, "max_texture_size", 0) or 0
),
)
def check_platform_lock(
lock: Optional[PlatformLock], info: PlatformInfo
) -> tuple[Diagnostic, ...]:
"""Every way this machine violates the lock. Empty means "allowed".
The graphics half is only checked when ``info`` carries it: a pre-context check
(``PlatformInfo.here()`` with no capabilities) must not report a GPU problem it
cannot see yet, or every locked package would be refused before Qt starts.
"""
if lock is None or lock.is_unrestricted():
return ()
out: list[Diagnostic] = []
if lock.os_family and info.os_family not in {v.lower() for v in lock.os_family}:
out.append(
diagnostic(
"platform_mismatch",
"this package is not released for this operating system",
this=info.os_family,
allowed=",".join(lock.os_family),
)
)
if lock.machine and info.machine not in {v.lower() for v in lock.machine}:
out.append(
diagnostic(
"platform_mismatch",
"this package is not released for this processor architecture",
this=info.machine,
allowed=",".join(lock.machine),
)
)
graphics_known = info.gl_profile is not None or bool(info.vendor or info.renderer)
if not graphics_known:
return tuple(out)
if lock.gl_profile and info.gl_profile != lock.gl_profile:
out.append(
diagnostic(
"platform_mismatch",
"this package was compiled for another OpenGL profile",
this=str(info.gl_profile),
required=str(lock.gl_profile),
)
)
if lock.forbid_software_renderer and info.software_renderer:
out.append(
diagnostic(
"platform_mismatch",
"this package requires a GPU and this context is a software rasteriser",
renderer=info.renderer or "unknown",
)
)
haystack = f"{info.vendor} {info.renderer}".lower()
for label, allowed in (
("vendor", lock.allowed_vendors),
("renderer", lock.allowed_renderers),
):
if allowed and not any(item.lower() in haystack for item in allowed):
out.append(
diagnostic(
"platform_mismatch",
f"this package is not released for this graphics {label}",
this=haystack.strip() or "unknown",
allowed=",".join(allowed),
)
)
if lock.min_texture_size and info.max_texture_size < int(lock.min_texture_size):
out.append(
diagnostic(
"platform_mismatch",
"the GPU's maximum texture size is below what this package needs",
this=str(info.max_texture_size),
required=str(int(lock.min_texture_size)),
)
)
return tuple(out)
@dataclass(frozen=True)
class SceneAssets:
"""A validated scene: one sensing part, zero or more housing parts."""
@@ -242,6 +450,9 @@ class SceneAssets:
#: triangle is this?" is a question an operator will ask, and re-deriving it
#: from UVs alone is a guess.
source_vertex_of_render: Optional[I32] = None
#: Where this package may be displayed (stage 5). ``None`` means "not locked",
#: which is what every package written before the lock existed carries.
platform_lock: Optional[PlatformLock] = None
def part(self, part_id: str) -> Optional[MeshPart]:
for item in self.parts:
@@ -331,6 +542,34 @@ class RenderStats:
estimated_gpu_bytes: int = 0
@dataclass(frozen=True)
class Sealing:
"""How a package is protected while it is written.
The *material*, not the policy: the policy is ``CompileOptions.signing_key_reference``
and ``CompileOptions.encryption_key_id`` (names a person wrote down), and this is
the resolved key bytes and provider a build machine actually holds. Splitting the
two is what keeps an offline tool from needing the crypto backend while the
runtime, which only ever *reads* sealed packages, needs nothing but a public key.
"""
#: Which content key to use, and where the key comes from. ``None`` = plain.
key_id: str = ""
provider: Optional["KeyProvider"] = None
#: Build-time only. The private key never reaches the runtime path.
signing_key_pem: Optional[bytes] = None
signing_key_reference: str = ""
#: Optional: the writer's own public key, so a build can prove the package it just
#: wrote opens again instead of discovering the problem at the customer.
verify_key_pem: Optional[bytes] = None
def encrypts(self) -> bool:
return bool(self.key_id) and self.provider is not None
def signs(self) -> bool:
return self.signing_key_pem is not None
@dataclass(frozen=True)
class CompileOptions:
asset_id: str
@@ -341,6 +580,11 @@ class CompileOptions:
max_housing_triangles: int
signing_key_reference: str
encryption_key_id: Optional[str] = None
#: Where the released package may be displayed. ``None`` leaves it unlocked, which
#: is the right default for an engineering build and the wrong one for a release.
platform_lock: Optional[PlatformLock] = None
#: The resolved key material for signing and sealing (see :class:`Sealing`).
sealing: Optional[Sealing] = None
@dataclass(frozen=True)
@@ -570,6 +814,16 @@ def validate_assets(
raise error("geometry_mismatch", "a source vertex index is negative")
out: list[Diagnostic] = []
if assets.platform_lock is not None:
# The lock travels inside the signed manifest, so a shape that cannot round
# trip through JSON is a package nobody can check at the far end.
try:
PlatformLock.from_dict(assets.platform_lock.to_dict())
except ThreeDError as exc:
raise error(
"asset_rejected",
f"the platform lock is malformed: {exc.diagnostic.message}",
) from exc
if expected is not None:
if expected.geometry_id != assets.geometry_id:
raise error(
@@ -72,6 +72,22 @@ def content_digest(
return hasher.hexdigest()
def bytes_digest(data: bytes, *, domain: str = "bytes") -> str:
"""SHA-256 over raw bytes, labelled by what they are.
Used for a payload and for a ciphertext, where there is no array to name: the
label keeps a digest of one kind of object from ever colliding with another, and
the length is fed separately so two different lengths cannot hash the same way.
Still a *digest*: it detects corruption, it does not authenticate (see
``protect.py`` for that).
"""
hasher = hashlib.sha256()
_feed(hasher, "schema", DIGEST_SCHEMA.encode("ascii"))
_feed(hasher, f"bytes:{domain}:len", str(len(data)).encode("ascii"))
_feed(hasher, f"bytes:{domain}", bytes(data))
return hasher.hexdigest()
def geometry_id(parts: Iterable[MeshPart]) -> str:
"""The identity of the *shape*: part roles, positions and triangles.
+427 -151
View File
@@ -1,28 +1,34 @@
"""The ``.oryscene`` runtime package: manifest, entries, verification.
"""The ``.oryscene`` runtime package: manifest, entries, verification, sealing.
Stage 2 in ``docs/architecture/studio-3d-stages.md``; signing and encryption are
stage 5 and are *not* here. What is here is the part that stage 5 wraps: a single
file whose every byte is accounted for in a manifest, and a reader that verifies
before it believes.
Stage 2 (the plain container) and stage 5 (signature, optional encryption, platform
lock) in ``docs/architecture/studio-3d-stages.md``.
The container is a zip archive (stdlib, no pickle, no code execution) holding:
The container is a zip archive (stdlib, no pickle, no code execution). Two envelope
versions exist and both are read:
``envelope.json``
magic, envelope version, payload state, entry count. Stage 5 adds the key id,
nonce and signature here.
``manifest.json``
canonical JSON: identity, coordinate frame, units, the UV domain, the parts,
and one entry record (dtype, shape, sha256) per array.
``sensing/*.npy`` (and ``housing/*.npy`` when present)
plain ``.npy`` arrays, written by :func:`numpy.save` into the archive.
``envelope_version`` 1 — plain
``envelope.json``, ``manifest.json``, ``sensing/*.npy``, ``housing/*.npy``.
Everything a v1 reader wrote before stage 5 still loads.
``envelope_version`` 2 — sealed
``envelope.json`` (public, signed metadata) and ``payload``: the AES-256-GCM
ciphertext of exactly the bytes a v1 package's *content* would have been, i.e. a
zip holding ``manifest.json`` and the arrays. Written only when a key provider and
a signing key are supplied; read only when the verification key (and, for an
encrypted one, the content key) is available.
The reader refuses, without trying to repair anything: a wrong magic, an unknown
envelope version, a missing manifest, an entry whose bytes do not match its
recorded hash, an entry the manifest does not describe (or describes and never
provides), a path that tries to escape, a dtype/shape that disagrees with the
record, non-finite positions, a binding that belongs to another mesh, and an asset
that does not match the device. An encrypted envelope is refused with its own
message rather than silently opened as if it were plain.
envelope version, a missing manifest, an entry whose bytes do not match its recorded
hash, an entry the manifest does not describe (or describes and never provides), a
path that tries to escape, a dtype/shape that disagrees with the record, non-finite
positions, a binding that belongs to another mesh, an asset that does not match the
device, a package larger than a geometry package has any business being, and — since
stage 5 — a bad signature, a ciphertext that was changed, and a platform the package
was not released for.
One property is worth stating because it is the point of the two versions: the
*payload* of a sealed package is built by the same code as the content of a plain
one, so a sealed package cannot drift into a different format from the one the tests
already cover.
"""
from __future__ import annotations
@@ -43,7 +49,9 @@ from orisys_app_shared.scene3d.contracts import (
ExpectedGeometry,
Material,
MeshPart,
PlatformLock,
SceneAssets,
Sealing,
SurfaceBinding,
error,
freeze,
@@ -56,9 +64,19 @@ from orisys_app_shared.scene3d.digest import (
content_digest,
geometry_id as compute_geometry_id,
)
from orisys_app_shared.scene3d import protect as _protect
#: Bumped when the container layout changes incompatibly.
ENVELOPE_VERSION = 1
#: Re-exported so a caller can ask "can this build seal a package at all?" without
#: importing the crypto module directly.
backend_available = _protect.backend_available
backend_version = _protect.backend_version
CryptoUnavailable = _protect.CryptoUnavailable
#: The version this build *writes*.
ENVELOPE_VERSION = 2
#: Every version this build *reads*. A v1 package is a release from before stage 5 and
#: has to keep working: refusing it would turn a format improvement into a recall.
SUPPORTED_ENVELOPE_VERSIONS = (1, 2)
SCHEMA_VERSION = "1.0"
MAGIC = "orisys-scene"
@@ -67,6 +85,12 @@ MAGIC = "orisys-scene"
MAX_ENTRIES = 64
MAX_ENTRY_BYTES = 256 << 20
MAX_TOTAL_BYTES = 512 << 20
#: And refuse a compression ratio that no geometry package has: a manifest is
#: repetitive JSON, not a megabyte of zeros, and an entry that expands 200-fold is a
#: decompression bomb aimed at this process's memory. Checked above a floor so a tiny
#: entry with an unusual ratio (an empty array compresses enormously) is not refused.
MAX_COMPRESSION_RATIO = 200
RATIO_FLOOR_BYTES = 4 << 20
#: The arrays a sensing part is made of, and the role each belongs to.
SENSING_ARRAYS = {
@@ -86,6 +110,9 @@ HOUSING_ARRAYS = {
_DTYPE_BY_KEY = {"f4": np.float32, "u4": np.uint32, "i4": np.int32}
_KEY_BY_DTYPE = {np.dtype(v): k for k, v in _DTYPE_BY_KEY.items()}
#: Inside a sealed package the ciphertext is one entry with this name.
PAYLOAD_ENTRY = "payload"
@dataclass(frozen=True)
class PackageContents:
@@ -167,21 +194,16 @@ def build_manifest(
],
"entries": {name: dict(entries[name]) for name in sorted(entries)},
"bounds_sensor_m": [[float(v) for v in row] for row in assets.bounds_sensor_m],
# ``None`` means "not locked". Stored as an explicit null rather than omitted,
# so a reader cannot mistake "the writer had no lock feature" for "unlocked".
"platform_lock": (
assets.platform_lock.to_dict() if assets.platform_lock is not None else None
),
}
def write_package(
assets: SceneAssets,
output: Path,
*,
sensor_model: str = "finger",
hardware_revisions: Iterable[str] = ("*",),
) -> CompileReport:
"""Write a runtime package atomically: a half-written package never appears."""
from orisys_app_shared.scene3d.contracts import validate_assets as _validate
_validate(assets)
output = Path(output)
def _arrays_of(assets: SceneAssets) -> dict[str, np.ndarray]:
"""Every array a package holds, in its canonical entry names."""
arrays: dict[str, np.ndarray] = {}
sensing = assets.sensing_part()
arrays["sensing/positions.npy"] = np.ascontiguousarray(sensing.positions_sensor_m)
@@ -198,7 +220,21 @@ def write_package(
arrays["housing/positions.npy"] = np.ascontiguousarray(part.positions_sensor_m)
arrays["housing/normals.npy"] = np.ascontiguousarray(part.normals_sensor)
arrays["housing/triangles.npy"] = np.ascontiguousarray(part.triangles)
return arrays
def _payload_bytes(
assets: SceneAssets,
*,
sensor_model: str,
hardware_revisions: Iterable[str],
) -> tuple[bytes, dict[str, Any], str]:
"""The *content* of a package as one zip in memory, plus its manifest and hash.
Shared by both envelope versions, which is what stops a sealed package from
becoming a second format with its own bugs: sealing encrypts exactly these bytes.
"""
arrays = _arrays_of(assets)
entries = {name: _record(array) for name, array in arrays.items()}
manifest = build_manifest(
assets, entries=entries, sensor_model=sensor_model,
@@ -209,30 +245,124 @@ def write_package(
)
manifest["asset_hash"] = digest
manifest["entry_digest"] = {name: record["sha256"] for name, record in entries.items()}
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as payload:
payload.writestr("manifest.json", json.dumps(manifest, indent=2, sort_keys=True))
for name in sorted(arrays):
payload.writestr(name, _npy_bytes(arrays[name]))
return buffer.getvalue(), manifest, digest
envelope = {
def write_package(
assets: SceneAssets,
output: Path,
*,
sensor_model: str = "finger",
hardware_revisions: Iterable[str] = ("*",),
sealing: Optional[Sealing] = None,
) -> CompileReport:
"""Write a runtime package atomically: a half-written package never appears.
Three cases, and the design document's order of protection decides them:
* **no key material** — ``envelope_version`` 1, plain and unsigned. This is what an
engineering build produces, and what every package written before stage 5 is. Its
manifest is still self-checked on read (hashes, shapes, identities), so a
corrupted package is refused; what it does not have is *origin*.
* **a signing key only** — ``envelope_version`` 2, still plain, but the manifest's
asset hash and the manifest itself are signed. This is the design's "完整性 and
来源" step: the client carries only a public key, and a customer who edits a mesh
or a sampling table is caught, because the hash that covers them is inside the
signature.
* **a content key too** — ``envelope_version`` 2 with the content encrypted by
AES-256-GCM (``protect.py``). Encryption is *in addition to* the signature, never
instead of it.
A sealed write also *reads itself back* when the caller supplied a verification key:
a build that cannot open its own release should not have produced it.
"""
from orisys_app_shared.scene3d.contracts import validate_assets as _validate
_validate(assets)
output = Path(output)
payload, manifest, digest = _payload_bytes(
assets, sensor_model=sensor_model, hardware_revisions=hardware_revisions
)
seal = sealing or Sealing()
if seal.key_id and seal.provider is None:
# A key id with no key is the dangerous half-state: the caller asked for a
# sealed package and would get a plain one that a release script then ships as
# protected. Refuse, rather than decide on the caller's behalf.
raise error(
"asset_auth_failed",
f"a content key id ({seal.key_id}) was given without a key provider: "
"refusing to write a plain package in its place",
)
members = _payload_members(payload)
common: dict[str, Any] = {
"magic": MAGIC,
"envelope_version": ENVELOPE_VERSION,
"payload": "plain",
"schema_version": SCHEMA_VERSION,
"asset_id": assets.asset_id,
"asset_version": assets.asset_version,
"entry_count": len(arrays),
"note": "unsigned and unencrypted: stage 5 adds the signature and the AEAD",
# The array entries, not the archive's members: this field has meant "how many
# arrays" since version 1, and a reader that counted members would disagree.
"entry_count": len(manifest.get("entries") or {}),
}
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(output.suffix + ".partial")
try:
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
archive.writestr("manifest.json", json.dumps(manifest, indent=2, sort_keys=True))
for name in sorted(arrays):
buffer = _npy_bytes(arrays[name])
archive.writestr(name, buffer)
if seal.encrypts():
envelope, ciphertext = _protect.seal_payload(
payload,
key_id=seal.key_id,
provider=seal.provider,
signing_key_pem=seal.signing_key_pem,
signing_key_reference=seal.signing_key_reference,
extra=common,
)
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
# Stored, not deflated: the ciphertext is incompressible, and a second
# compression layer only hides the fact that it is ciphertext.
archive.writestr(PAYLOAD_ENTRY, ciphertext)
elif seal.signs():
envelope = dict(common)
envelope["envelope_version"] = 2
envelope["payload"] = "plain"
# For a plain payload the signed digest *is* the manifest's asset hash: the
# manifest already covers every entry's sha256, so signing it covers the
# geometry, the sampling table and the platform lock without inventing a
# second, differently-computed number for the same thing.
envelope["payload_sha256"] = digest
envelope = _protect.sign_envelope(
envelope,
private_key_pem=seal.signing_key_pem or b"",
reference=seal.signing_key_reference,
)
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
for name in sorted(members):
archive.writestr(name, members[name])
else:
envelope = dict(common)
envelope["envelope_version"] = 1
envelope["payload"] = "plain"
envelope["note"] = (
"plain and unsigned: integrity only, no origin (see protect.py for what "
"a signature adds)"
)
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("envelope.json", json.dumps(envelope, indent=2, sort_keys=True))
for name in sorted(members):
archive.writestr(name, members[name])
temporary.replace(output)
except Exception:
temporary.unlink(missing_ok=True)
raise
if seal.verify_key_pem is not None and (seal.encrypts() or seal.signs()):
# The build machine proves the release opens. Without this, the first machine to
# discover a broken envelope is the customer's.
read_package(output, keys=seal.provider, verify_key_pem=seal.verify_key_pem)
return CompileReport(
output=output,
asset_hash=digest,
@@ -241,6 +371,12 @@ def write_package(
)
def _payload_members(payload: bytes) -> dict[str, bytes]:
"""The members of the in-memory content zip, by name."""
with zipfile.ZipFile(io.BytesIO(payload)) as content:
return {name: content.read(name) for name in content.namelist()}
def _npy_bytes(array: np.ndarray) -> bytes:
import io
@@ -249,112 +385,220 @@ def _npy_bytes(array: np.ndarray) -> bytes:
return buffer.getvalue()
def read_package(path: Path, *, keys: Any = None) -> PackageContents:
"""Open, verify and hand back the arrays. Refuses rather than repairs."""
def _guard_sizes(archive: zipfile.ZipFile) -> None:
"""Refuse an archive that wants more memory than a geometry package needs.
Two limits, and the ratio is the one that matters: a 2 KB zip can *declare* a
256 MB entry, and the declared size is what this process would allocate while
decompressing it. So a ratio no geometry package reaches (a manifest is repetitive
JSON, not megabytes of zeros) is refused above a floor that keeps small,
genuinely-compressible arrays legitimate.
"""
names = archive.namelist()
if len(names) > MAX_ENTRIES:
raise error("asset_auth_failed", f"the package declares {len(names)} entries")
total = 0
for info in archive.infolist():
size = int(info.file_size)
total += size
if size > MAX_ENTRY_BYTES or total > MAX_TOTAL_BYTES:
raise error("asset_auth_failed", "the package is larger than allowed")
if size > RATIO_FLOOR_BYTES:
ratio = size / max(int(info.compress_size), 1)
if ratio > MAX_COMPRESSION_RATIO:
raise error(
"asset_auth_failed",
f"{info.filename} expands {ratio:.0f}x: refusing a decompression bomb",
)
def _read_envelope(archive: zipfile.ZipFile) -> dict[str, Any]:
try:
envelope = json.loads(archive.read("envelope.json"))
except KeyError as exc:
raise error("asset_auth_failed", "the envelope is missing") from exc
except Exception as exc: # noqa: BLE001
raise error("asset_auth_failed", f"the envelope is unreadable: {exc}") from exc
if not isinstance(envelope, dict) or envelope.get("magic") != MAGIC:
raise error("asset_auth_failed", "this is not an orisys scene package")
version = int(envelope.get("envelope_version", 0))
if version not in SUPPORTED_ENVELOPE_VERSIONS:
raise error(
"asset_auth_failed",
f"unsupported envelope version {envelope.get('envelope_version')!r} "
f"(this build reads {SUPPORTED_ENVELOPE_VERSIONS})",
)
envelope["envelope_version"] = version
return envelope
def _verify_members(
members: Mapping[str, bytes], *, expected_hash: Optional[str] = None
) -> PackageContents:
"""The stage-2 verification, over a name→bytes mapping from either envelope."""
try:
manifest = json.loads(members["manifest.json"])
except KeyError as exc:
raise error("asset_auth_failed", "the manifest is missing") from exc
except Exception as exc: # noqa: BLE001
raise error("asset_auth_failed", f"the manifest is unreadable: {exc}") from exc
if not isinstance(manifest, dict):
raise error("asset_auth_failed", "the manifest is not an object")
entries = manifest.get("entries")
if not isinstance(entries, dict) or not entries:
raise error("asset_auth_failed", "the manifest describes no entries")
recorded_digest = str(manifest.get("asset_hash") or "")
# Verify the asset hash over the entries and the manifest *without* the
# hash fields, exactly as the writer computed it.
stripped = {
key: value
for key, value in manifest.items()
if key not in ("asset_hash", "entry_digest")
}
recomputed = compute_asset_hash(
{name: str(record.get("sha256", "")) for name, record in entries.items()},
stripped,
)
if not recorded_digest or recomputed != recorded_digest:
raise error(
"asset_auth_failed",
"the manifest hash does not match its contents",
recorded=recorded_digest[:16],
recomputed=recomputed[:16],
)
if expected_hash and str(expected_hash) != recorded_digest:
# For a signed plain package this is where the signature meets the content: the
# signed digest and the manifest's own hash have to be the same number, or the
# signature covers something other than what was delivered.
raise error(
"asset_auth_failed",
"the signed payload digest does not match the manifest's asset hash",
signed=str(expected_hash)[:16],
manifest=recorded_digest[:16],
)
arrays: dict[str, np.ndarray] = {}
for name, record in entries.items():
_entry_name(name)
if name not in members:
raise error("asset_auth_failed", f"the manifest promises {name!r}, which is absent")
raw = members[name]
try:
# ``io.BytesIO``: numpy's reader wants a real file object (it seeks,
# tells and reads headers), and a hand-rolled stub fails obscurely.
array = np.load(io.BytesIO(raw), allow_pickle=False)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
f"{name} is not a readable array: {type(exc).__name__}: {exc}",
) from exc
dtype_key = str(record.get("dtype"))
expected_dtype = _DTYPE_BY_KEY.get(dtype_key)
if expected_dtype is None or array.dtype != np.dtype(expected_dtype):
raise error(
"field_domain_mismatch",
f"{name}: dtype {array.dtype} does not match the manifest {dtype_key!r}",
)
expected_shape = tuple(int(v) for v in record.get("shape", ()))
if tuple(array.shape) != expected_shape:
raise error(
"field_domain_mismatch",
f"{name}: shape {tuple(array.shape)} does not match the manifest "
f"{expected_shape}",
)
if content_digest({"data": np.ascontiguousarray(array)}) != str(
record.get("sha256")
):
raise error("asset_auth_failed", f"{name} does not match its recorded hash")
arrays[name] = array
unknown = [
name
for name in members
if name not in entries and name not in ("envelope.json", "manifest.json")
]
if unknown:
raise error("asset_auth_failed", f"undeclared entries: {unknown}")
return PackageContents(manifest=manifest, arrays=arrays)
def read_package(
path: Path,
*,
keys: Any = None,
verify_key_pem: Optional[bytes] = None,
) -> PackageContents:
"""Open, verify and hand back the arrays. Refuses rather than repairs.
``keys`` is the :class:`~orisys_app_shared.scene3d.contracts.KeyProvider` for an
encrypted package; ``verify_key_pem`` the public key a signed one is checked
against. Both are optional, and each missing one is refused *specifically* — a
sealed package without a provider is not the same failure as a signed package
whose signature is wrong, and the message has to say which happened.
"""
path = Path(path)
if not path.is_file():
raise error("asset_rejected", f"package not found: {path}")
diagnostics: list[Diagnostic] = []
with zipfile.ZipFile(path) as archive:
_guard_sizes(archive)
envelope = _read_envelope(archive)
names = archive.namelist()
if len(names) > MAX_ENTRIES:
raise error("asset_auth_failed", f"the package declares {len(names)} entries")
total = 0
for info in archive.infolist():
total += int(info.file_size)
if int(info.file_size) > MAX_ENTRY_BYTES or total > MAX_TOTAL_BYTES:
raise error("asset_auth_failed", "the package is larger than allowed")
try:
envelope = json.loads(archive.read("envelope.json"))
except KeyError as exc:
raise error("asset_auth_failed", "the envelope is missing") from exc
except Exception as exc: # noqa: BLE001
raise error("asset_auth_failed", f"the envelope is unreadable: {exc}") from exc
if not isinstance(envelope, dict) or envelope.get("magic") != MAGIC:
raise error("asset_auth_failed", "this is not an orisys scene package")
if int(envelope.get("envelope_version", 0)) != ENVELOPE_VERSION:
raise error(
"asset_auth_failed",
f"unsupported envelope version {envelope.get('envelope_version')!r}",
)
if str(envelope.get("payload")) != "plain":
# Stage 5 territory. Saying so is better than pretending the bytes are
# data and failing later with a numpy error.
raise error(
"asset_auth_failed",
f"the payload is {envelope.get('payload')!r}; this build opens plain "
"packages only (signing and encryption are a later stage)",
)
try:
manifest = json.loads(archive.read("manifest.json"))
except KeyError as exc:
raise error("asset_auth_failed", "the manifest is missing") from exc
except Exception as exc: # noqa: BLE001
raise error("asset_auth_failed", f"the manifest is unreadable: {exc}") from exc
if not isinstance(manifest, dict):
raise error("asset_auth_failed", "the manifest is not an object")
entries = manifest.get("entries")
if not isinstance(entries, dict) or not entries:
raise error("asset_auth_failed", "the manifest describes no entries")
recorded_digest = str(manifest.get("asset_hash") or "")
# Verify the asset hash over the entries and the manifest *without* the
# hash fields, exactly as the writer computed it.
stripped = {
key: value
for key, value in manifest.items()
if key not in ("asset_hash", "entry_digest")
}
recomputed = compute_asset_hash(
{name: str(record.get("sha256", "")) for name, record in entries.items()},
stripped,
)
if not recorded_digest or recomputed != recorded_digest:
raise error(
"asset_auth_failed",
"the manifest hash does not match its contents",
recorded=recorded_digest[:16],
recomputed=recomputed[:16],
)
arrays: dict[str, np.ndarray] = {}
for name, record in entries.items():
entry = _entry_name(name)
if name not in names:
raise error("asset_auth_failed", f"the manifest promises {name!r}, which is absent")
raw = archive.read(name)
try:
# ``io.BytesIO``: numpy's reader wants a real file object (it seeks,
# tells and reads headers), and a hand-rolled stub fails obscurely.
array = np.load(io.BytesIO(raw), allow_pickle=False)
except Exception as exc: # noqa: BLE001
version = int(envelope["envelope_version"])
kind = str(envelope.get("payload"))
if version == 1:
if kind != "plain":
raise error(
"asset_auth_failed",
f"{name} is not a readable array: {type(exc).__name__}: {exc}",
f"envelope version 1 cannot carry a {kind!r} payload",
)
members = {
name: archive.read(name)
for name in names
if name != "envelope.json"
}
return _verify_members(members)
if kind == "aes-gcm":
if PAYLOAD_ENTRY not in names:
raise error("asset_auth_failed", "the encrypted payload entry is missing")
plaintext = _protect.open_payload(
envelope,
archive.read(PAYLOAD_ENTRY),
provider=keys,
public_key_pem=verify_key_pem,
)
try:
with zipfile.ZipFile(io.BytesIO(plaintext)) as content:
_guard_sizes(content)
members = {name: content.read(name) for name in content.namelist()}
except zipfile.BadZipFile as exc:
raise error(
"asset_auth_failed", "the decrypted payload is not a readable archive"
) from exc
dtype_key = str(record.get("dtype"))
expected_dtype = _DTYPE_BY_KEY.get(dtype_key)
if expected_dtype is None or array.dtype != np.dtype(expected_dtype):
raise error(
"field_domain_mismatch",
f"{name}: dtype {array.dtype} does not match the manifest {dtype_key!r}",
)
expected_shape = tuple(int(v) for v in record.get("shape", ()))
if tuple(array.shape) != expected_shape:
raise error(
"field_domain_mismatch",
f"{name}: shape {tuple(array.shape)} does not match the manifest "
f"{expected_shape}",
)
if content_digest({"data": np.ascontiguousarray(array)}) != str(
record.get("sha256")
):
raise error("asset_auth_failed", f"{name} does not match its recorded hash")
arrays[name] = array
unknown = [name for name in names if name not in entries and name not in (
"envelope.json", "manifest.json"
)]
if unknown:
raise error("asset_auth_failed", f"undeclared entries: {unknown}")
return PackageContents(manifest=manifest, arrays=arrays, diagnostics=tuple(diagnostics))
return _verify_members(members)
if kind == "plain":
# Signed, not encrypted: the arrays are in the clear, and the signature is
# what makes them the producer's. A reader that does not check it would be
# accepting an unsigned package while claiming otherwise.
if isinstance(envelope.get("signature"), Mapping):
if verify_key_pem is None:
raise error(
"asset_auth_failed",
"this package is signed; a verification key is required "
"(pass --verify-key or set one in the application)",
)
_protect.verify_envelope(envelope, public_key_pem=verify_key_pem)
members = {
name: archive.read(name)
for name in names
if name != "envelope.json"
}
return _verify_members(
members, expected_hash=str(envelope.get("payload_sha256") or "")
)
raise error(
"asset_auth_failed",
f"unsupported payload kind {kind!r} in envelope version {version}",
)
return PackageContents(manifest={}, arrays={}, diagnostics=tuple(diagnostics))
def scene_from_contents(
@@ -454,26 +698,26 @@ def scene_from_contents(
source_vertex_of_render=(
freeze(source, dtype=np.int32) if source is not None else None
),
platform_lock=PlatformLock.from_dict(manifest.get("platform_lock")),
)
validate_assets(assets, expected)
return assets
class PackagedAssetLoader:
"""The stage-2 loader: plain, verified packages (stage 5 adds the envelope)."""
"""The loader: plain, signed and encrypted packages through one entry point."""
def load(
self,
path: Path,
expected: ExpectedGeometry,
keys: Any = None,
*,
verify_key_pem: Optional[bytes] = None,
) -> SceneAssets:
if keys is not None:
raise error(
"asset_auth_failed",
"this build cannot use a key provider: packages are plain so far",
)
return scene_from_contents(read_package(path), expected)
return scene_from_contents(
read_package(path, keys=keys, verify_key_pem=verify_key_pem), expected
)
def validate(
self, assets: SceneAssets, expected: ExpectedGeometry
@@ -481,10 +725,16 @@ class PackagedAssetLoader:
return validate_assets(assets, expected)
def describe_package(path: Path) -> dict[str, Any]:
def describe_package(
path: Path,
*,
keys: Any = None,
verify_key_pem: Optional[bytes] = None,
) -> dict[str, Any]:
"""A report on a package without building a scene: for the validate tool."""
contents = read_package(path)
contents = read_package(path, keys=keys, verify_key_pem=verify_key_pem)
manifest = contents.manifest
lock = manifest.get("platform_lock")
return {
"asset_id": manifest.get("asset_id"),
"asset_version": manifest.get("asset_version"),
@@ -492,6 +742,7 @@ def describe_package(path: Path) -> dict[str, Any]:
"field_domain_id": (manifest.get("field_domain") or {}).get("id"),
"shape_hw": (manifest.get("field_domain") or {}).get("shape_hw"),
"asset_hash": manifest.get("asset_hash"),
"platform_lock": lock,
"parts": {part.get("id"): part.get("role") for part in manifest.get("parts", [])},
"entries": {
name: {"dtype": r.get("dtype"), "shape": r.get("shape")}
@@ -501,3 +752,28 @@ def describe_package(path: Path) -> dict[str, Any]:
"sensing": int(contents.arrays["sensing/positions.npy"].shape[0]),
},
}
def describe_envelope(path: Path) -> dict[str, Any]:
"""What the container says about itself, without opening the payload.
Used by the tools to report whether a package is plain, signed or encrypted —
and, importantly, to do so *without* a key: a support engineer can say "this
package is sealed for key id X and signed by Y" without being able to read it.
"""
path = Path(path)
if not path.is_file():
raise error("asset_rejected", f"package not found: {path}")
with zipfile.ZipFile(path) as archive:
_guard_sizes(archive)
envelope = _read_envelope(archive)
names = set(archive.namelist())
sealed_bytes = (
int(archive.getinfo(PAYLOAD_ENTRY).file_size)
if PAYLOAD_ENTRY in names
else 0
)
summary = _protect.envelope_summary(envelope)
summary["members"] = sorted(names)
summary["sealed_bytes"] = sealed_bytes
return summary
@@ -0,0 +1,532 @@
"""Sealed packages: Ed25519 signatures and optional AES-GCM, with no home-made crypto.
Stage 5 in ``docs/architecture/studio-3d-stages.md``; §5.2 of the design document is
the specification this module implements:
* **integrity and origin** (item 2): the build machine signs the package with an
Ed25519 private key and the client carries only the public key. A SHA-256 digest
says what the bytes are; it does not say who produced them, so the digest alone is
never described here as tamper protection.
* **optional encryption** (item 3): AES-256-GCM from a mature library, one fresh
nonce per sealed package, the envelope metadata bound as associated data, and the
plaintext digest inside the signed part. No XOR, no password hidden in the
extension, no encrypted-looking zip.
* **key isolation** (item 4): the signing private key never enters this module's
runtime path — :func:`sign_envelope` is a *build-time* function and only the public
key (``verify_envelope``) is needed to read a package. Content keys come from a
:class:`~orisys_app_shared.scene3d.contracts.KeyProvider`, so authorisation stays
outside the format.
* **decryption scope** (item 5): the payload is opened in memory and never written to
a plaintext temporary file; no function here logs a key, a nonce or a plaintext
digest of anything but a package the caller already holds.
Two things this module deliberately does **not** claim:
* it cannot stop somebody who controls the machine from reading the geometry after
decryption — the renderer needs the mesh, and a Python process cannot wipe its own
memory. The design document says the goal is to raise the cost of copying, not to
make it impossible, and that a structure which must not leak has to be replaced by
a visual proxy instead of by encryption;
* it does not protect anything if the same geometry ships in plaintext elsewhere.
Signing this package while the plain SDK mesh travels beside it protects nothing.
The format (``envelope_version`` 2, one ``payload`` entry):
``envelope.json``
public, signed metadata: magic, version, ``payload: "aes-gcm"``, ``key_id``,
``cipher``, ``nonce``, ``salt``, ``kdf``, ``payload_sha256`` (of the *plaintext*),
``ciphertext_sha256`` (of the stored bytes) and the ``signature`` block.
``payload``
the AES-256-GCM ciphertext of exactly the bytes a v1 package would have held: a
zip archive with ``manifest.json`` and the ``.npy`` entries.
The signed bytes are ``_DOMAIN + canonical_json(envelope without "signature") +
plaintext digest``, which is why the signature covers the manifest and every entry
(the manifest is inside the plaintext, whose digest is signed) as well as the key id,
the nonce, the salt and the ciphertext's own digest. The GCM associated data is the
same canonical envelope-without-signature bytes, so a package whose metadata was
edited fails decryption *and* verification, not just one of the two.
"""
from __future__ import annotations
import base64
import json
import secrets
from pathlib import Path
from typing import Any, Mapping, Optional
from orisys_app_shared.scene3d.contracts import (
Diagnostic,
KeyProvider,
diagnostic,
error,
)
from orisys_app_shared.scene3d.digest import bytes_digest
#: Separates our messages from any other use of the same key, so a signature made for
#: something else can never be replayed as a package signature.
_DOMAIN = b"orisys-scene/envelope/v2\x00"
#: The only cipher and KDF we write, and the only ones we accept on read. Names are
#: spelled out rather than negotiated: an unknown one is refused, not downgraded.
CIPHER = "AES-256-GCM"
KDF = "HKDF-SHA256"
SIGNATURE_ALGORITHM = "Ed25519"
NONCE_BYTES = 12
SALT_BYTES = 32
KEY_BYTES = 32
SIGNATURE_BYTES = 64
#: How the per-package content key is derived from the provider's key. The key id is
#: bound in, so a key handed out for one product cannot decrypt another product's
#: package even when the provider returns the same bytes.
_KDF_INFO = b"orisys-scene/content-key/v2\x00"
class CryptoUnavailable(RuntimeError):
"""The crypto backend is not installed. Its own type, so callers can say so."""
def backend_available() -> bool:
"""Whether a mature crypto backend is importable in this process.
Checked rather than assumed: the *runtime* needs the backend only to read a
sealed package, so a machine without it keeps working with plain packages, and
the compiler refuses to write a sealed one instead of writing a plain file that
claims to be protected.
"""
try:
import cryptography # noqa: F401
except Exception: # noqa: BLE001
return False
return True
def backend_version() -> str:
try:
import cryptography
return str(getattr(cryptography, "__version__", "unknown"))
except Exception: # noqa: BLE001
return "unavailable"
def _backend():
"""The AES-GCM/Ed25519/HKDF primitives, or a clear refusal."""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
except Exception as exc: # noqa: BLE001
raise CryptoUnavailable(
"reading or writing a sealed scene package needs the 'cryptography' "
f"package, which is not installed here ({type(exc).__name__}: {exc})"
) from exc
return {
"hashes": hashes,
"serialization": serialization,
"Ed25519PrivateKey": Ed25519PrivateKey,
"Ed25519PublicKey": Ed25519PublicKey,
"AESGCM": AESGCM,
"HKDF": HKDF,
}
# --------------------------------------------------------------------- encoding
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _unb64(text: Any, *, field: str, length: Optional[int] = None) -> bytes:
if not isinstance(text, str) or not text:
raise error("asset_auth_failed", f"the envelope's {field} is missing")
try:
raw = base64.b64decode(text, validate=True)
except Exception as exc: # noqa: BLE001
raise error("asset_auth_failed", f"the envelope's {field} is not base64") from exc
if length is not None and len(raw) != length:
raise error(
"asset_auth_failed",
f"the envelope's {field} is {len(raw)} bytes, expected {length}",
)
return raw
def canonical_envelope(envelope: Mapping[str, Any]) -> bytes:
"""The bytes that are signed.
Everything except the signature itself, in a canonical JSON form. Sorted keys and
no whitespace, so the same content always signs the same bytes on every platform.
This *does* include the ciphertext's digest and length, which is why a package
whose ciphertext was swapped cannot keep a valid signature.
"""
body = {key: value for key, value in envelope.items() if key != "signature"}
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)
#: Fields that cannot be part of the AEAD's associated data: they only exist *after*
#: the ciphertext does. They are still covered by the signature, so dropping them here
#: does not leave them unprotected — it just keeps the two mechanisms in their proper
#: order (encrypt, then describe the ciphertext, then sign everything).
_POST_ENCRYPTION_FIELDS = ("signature", "ciphertext_sha256", "ciphertext_bytes")
def gcm_aad(envelope: Mapping[str, Any]) -> bytes:
"""The associated data bound into the ciphertext: all pre-encryption metadata."""
body = {
key: value
for key, value in envelope.items()
if key not in _POST_ENCRYPTION_FIELDS
}
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
"utf-8"
)
def signed_bytes(envelope: Mapping[str, Any], plaintext_digest: str) -> bytes:
"""Domain-separated message: the envelope bytes plus the plaintext's digest."""
return _DOMAIN + canonical_envelope(envelope) + b"\n" + str(plaintext_digest).encode("ascii")
# ------------------------------------------------------------------- key material
def generate_signing_key() -> bytes:
"""A fresh Ed25519 private key in PEM form, for a build machine."""
backend = _backend()
key = backend["Ed25519PrivateKey"].generate()
return key.private_bytes(
encoding=backend["serialization"].Encoding.PEM,
format=backend["serialization"].PrivateFormat.PKCS8,
encryption_algorithm=backend["serialization"].NoEncryption(),
)
def public_key_pem(private_key_pem: bytes) -> bytes:
"""The half that ships with the client."""
backend = _backend()
key = _load_private(private_key_pem, backend)
return key.public_key().public_bytes(
encoding=backend["serialization"].Encoding.PEM,
format=backend["serialization"].PublicFormat.SubjectPublicKeyInfo,
)
def _load_private(pem: bytes, backend: Mapping[str, Any]):
try:
return backend["serialization"].load_pem_private_key(bytes(pem), password=None)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
f"the signing key is not a readable PEM private key: {type(exc).__name__}",
) from exc
def _load_public(pem: bytes, backend: Mapping[str, Any]):
try:
return backend["serialization"].load_pem_public_key(bytes(pem))
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
f"the verification key is not a readable PEM public key: {type(exc).__name__}",
) from exc
class MappingKeyProvider:
"""A :class:`KeyProvider` over an in-memory table.
For tests and for a build machine. A production client is expected to implement
the same one-method protocol over whatever it really has (a device key, a licence
server, an offline token) — which is why the format only ever asks for
``get_content_key(key_id)``.
"""
def __init__(self, keys: Optional[Mapping[str, Any]] = None) -> None:
self.keys: dict[str, bytes] = {
str(key_id): _key_bytes(value, key_id=str(key_id))
for key_id, value in (keys or {}).items()
}
def get_content_key(self, key_id: str) -> bytes:
key = self.keys.get(str(key_id))
if key is None:
# No key material in the message, ever.
raise error(
"asset_auth_failed",
f"no content key is available for key id {key_id!r}",
)
return key
def key_ids(self) -> tuple[str, ...]:
return tuple(sorted(self.keys))
def _key_bytes(value: Any, *, key_id: str) -> bytes:
if isinstance(value, bytes):
raw = value
elif isinstance(value, str):
text = value.strip()
try:
raw = bytes.fromhex(text)
except ValueError:
try:
raw = base64.b64decode(text, validate=True)
except Exception as exc: # noqa: BLE001
raise error(
"asset_rejected",
f"the key for {key_id!r} is neither hex nor base64",
) from exc
else:
raise error("asset_rejected", f"the key for {key_id!r} must be bytes or text")
if len(raw) != KEY_BYTES:
raise error(
"asset_rejected",
f"the key for {key_id!r} is {len(raw)} bytes; {KEY_BYTES} are required",
)
return raw
def load_key_file(path: Path, *, key_id: str) -> MappingKeyProvider:
"""A key provider with one key, read from a file (hex, base64 or raw)."""
path = Path(path)
if not path.is_file():
raise error("asset_rejected", f"the key file is missing: {path}")
raw = path.read_bytes()
text = raw.decode("utf-8", errors="ignore").strip()
try:
key = _key_bytes(text, key_id=key_id)
except Exception: # noqa: BLE001 - a binary key file is legitimate
key = _key_bytes(raw, key_id=key_id)
return MappingKeyProvider({key_id: key})
# ------------------------------------------------------------------- derive/open
def _content_key(provider: KeyProvider, key_id: str, salt: bytes) -> bytes:
backend = _backend()
master = provider.get_content_key(key_id)
if not isinstance(master, (bytes, bytearray)) or len(bytes(master)) != KEY_BYTES:
size = len(bytes(master)) if isinstance(master, (bytes, bytearray)) else "not bytes"
raise error(
"asset_auth_failed",
f"the provider returned {size} for a content key; {KEY_BYTES} bytes are required",
)
info = _KDF_INFO + str(key_id).encode("utf-8")
return backend["HKDF"](
algorithm=backend["hashes"].SHA256(),
length=KEY_BYTES,
salt=salt,
info=info,
).derive(bytes(master))
def seal_payload(
payload: bytes,
*,
key_id: str,
provider: KeyProvider,
signing_key_pem: Optional[bytes] = None,
signing_key_reference: str = "",
extra: Optional[Mapping[str, Any]] = None,
) -> tuple[dict[str, Any], bytes]:
"""Encrypt (and optionally sign) one plaintext payload.
Returns the public envelope and the ciphertext. A fresh random nonce and salt are
generated per call: the design demands that a nonce is never reused under one key,
and a 96-bit random nonce is the standard way to get that (with a test that the
same payload sealed twice shares neither nonce nor ciphertext). The salt keeps the
*derived* key unique per package as well, so a reused master key is never used
directly.
"""
backend = _backend()
plaintext = bytes(payload)
plaintext_digest = bytes_digest(plaintext, domain="payload")
salt = secrets.token_bytes(SALT_BYTES)
nonce = secrets.token_bytes(NONCE_BYTES)
key = _content_key(provider, str(key_id), salt)
envelope: dict[str, Any] = {
"envelope_version": 2,
"payload": "aes-gcm",
"cipher": CIPHER,
"kdf": KDF,
"key_id": str(key_id),
"salt": _b64(salt),
"nonce": _b64(nonce),
"payload_sha256": plaintext_digest,
"crypto_backend": f"cryptography/{backend_version()}",
}
if extra:
envelope.update({str(k): v for k, v in extra.items()})
aad = gcm_aad(envelope)
ciphertext = backend["AESGCM"](key).encrypt(nonce, plaintext, aad)
envelope["ciphertext_sha256"] = bytes_digest(ciphertext, domain="ciphertext")
envelope["ciphertext_bytes"] = len(ciphertext)
if signing_key_pem is not None:
envelope["signature"] = _sign(
envelope,
plaintext_digest=plaintext_digest,
private_key_pem=signing_key_pem,
reference=signing_key_reference,
)
return envelope, ciphertext
def _sign(
envelope: Mapping[str, Any],
*,
plaintext_digest: str,
private_key_pem: bytes,
reference: str,
) -> dict[str, Any]:
backend = _backend()
key = _load_private(private_key_pem, backend)
signature = key.sign(signed_bytes(envelope, plaintext_digest))
return {
"algorithm": SIGNATURE_ALGORITHM,
"key_id": str(reference or "unknown"),
"value": _b64(signature),
}
def sign_envelope(
envelope: Mapping[str, Any],
*,
private_key_pem: bytes,
reference: str = "",
) -> dict[str, Any]:
"""Sign an existing envelope (build-time). Returns a copy with ``signature``."""
if not envelope.get("payload_sha256"):
raise error("asset_auth_failed", "the envelope has no payload digest to sign")
out = dict(envelope)
out["signature"] = _sign(
out,
plaintext_digest=str(envelope["payload_sha256"]),
private_key_pem=private_key_pem,
reference=reference,
)
return out
def verify_envelope(
envelope: Mapping[str, Any], *, public_key_pem: bytes
) -> tuple[Diagnostic, ...]:
"""Verify the signature. Raises on a bad one; returns warnings otherwise."""
backend = _backend()
signature = envelope.get("signature")
if not isinstance(signature, Mapping):
raise error("asset_auth_failed", "the envelope is not signed")
if str(signature.get("algorithm")) != SIGNATURE_ALGORITHM:
raise error(
"asset_auth_failed",
f"unsupported signature algorithm {signature.get('algorithm')!r}",
)
raw = _unb64(signature.get("value"), field="signature", length=SIGNATURE_BYTES)
key = _load_public(public_key_pem, backend)
message = signed_bytes(envelope, str(envelope.get("payload_sha256") or ""))
try:
key.verify(raw, message)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
"the package signature does not match its contents",
signing_key=str(signature.get("key_id") or ""),
) from exc
return ()
def open_payload(
envelope: Mapping[str, Any],
ciphertext: bytes,
*,
provider: Optional[KeyProvider],
public_key_pem: Optional[bytes] = None,
) -> bytes:
"""Verify, decrypt and hand back the plaintext payload.
Order matters and is not negotiable: the signature is checked *before* the key is
asked for and before anything is decrypted, so a tampered package cannot make the
client touch its key material or its cipher at all.
"""
backend = _backend()
if str(envelope.get("payload")) != "aes-gcm":
raise error(
"asset_auth_failed",
f"the payload is {envelope.get('payload')!r}, not {('aes-gcm')!r}",
)
if public_key_pem is not None:
verify_envelope(envelope, public_key_pem=public_key_pem)
elif "signature" in envelope:
raise error(
"asset_auth_failed",
"the package is signed but no verification key was supplied",
)
if str(envelope.get("cipher")) != CIPHER:
raise error("asset_auth_failed", f"unsupported cipher {envelope.get('cipher')!r}")
if str(envelope.get("kdf")) != KDF:
raise error("asset_auth_failed", f"unsupported key derivation {envelope.get('kdf')!r}")
if provider is None:
raise error(
"asset_auth_failed",
"this package is encrypted and no key provider was supplied",
)
recorded_cipher = str(envelope.get("ciphertext_sha256") or "")
if recorded_cipher and bytes_digest(ciphertext, domain="ciphertext") != recorded_cipher:
raise error("asset_auth_failed", "the ciphertext does not match its recorded hash")
declared = envelope.get("ciphertext_bytes")
if declared is not None and int(declared) != len(ciphertext):
raise error(
"asset_auth_failed",
f"the envelope declares {declared} ciphertext bytes, found {len(ciphertext)}",
)
salt = _unb64(envelope.get("salt"), field="salt", length=SALT_BYTES)
nonce = _unb64(envelope.get("nonce"), field="nonce", length=NONCE_BYTES)
key = _content_key(provider, str(envelope.get("key_id") or ""), salt)
aad = gcm_aad(envelope)
try:
plaintext = backend["AESGCM"](key).decrypt(nonce, bytes(ciphertext), aad)
except Exception as exc: # noqa: BLE001
raise error(
"asset_auth_failed",
"the package could not be decrypted: wrong key, or the bytes were changed",
key_id=str(envelope.get("key_id") or ""),
) from exc
recorded_plain = str(envelope.get("payload_sha256") or "")
if not recorded_plain or bytes_digest(plaintext, domain="payload") != recorded_plain:
raise error(
"asset_auth_failed",
"the decrypted payload does not match the digest that was signed",
)
return plaintext
def envelope_summary(envelope: Mapping[str, Any]) -> dict[str, Any]:
"""A log-safe view: no key material, no digests of anything secret.
Used by the validate tool and by diagnostics, so a support ticket can say what a
package is without printing anything that would help decrypt another one.
"""
signature = envelope.get("signature")
return {
"envelope_version": envelope.get("envelope_version"),
"payload": envelope.get("payload"),
"cipher": envelope.get("cipher"),
"key_id": envelope.get("key_id"),
"sealed": str(envelope.get("payload")) == "aes-gcm",
"ciphertext_bytes": envelope.get("ciphertext_bytes"),
"signed": isinstance(signature, Mapping),
"signing_key": (signature or {}).get("key_id") if isinstance(signature, Mapping) else None,
"crypto_backend": envelope.get("crypto_backend"),
}
def diagnose_unavailable(exc: BaseException) -> Diagnostic:
"""One place turns "no crypto backend" into the diagnostic the UI shows."""
return diagnostic(
"asset_auth_failed",
"sealed scene packages need the 'cryptography' package, which is not "
"installed on this machine; plain packages still work",
detail=str(exc),
)
+4 -2
View File
@@ -424,7 +424,8 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
)
assert composed.scalar.shape == (9,)
# The loading facade is real too (stage 2), and the compiler refuses what it
# cannot yet do rather than writing something unprotected.
# cannot honestly do: asking for encryption without key material, or without the
# crypto backend, must not produce a plain package that looks protected.
assert callable(assets.PackagedAssetLoader().load)
with pytest.raises(c.ThreeDError) as excinfo:
assets.SceneCompiler().compile(
@@ -442,7 +443,8 @@ def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
encryption_key_id="customer-1",
),
)
assert "stage 5" in excinfo.value.diagnostic.message
assert "key" in excinfo.value.diagnostic.message
assert excinfo.value.diagnostic.code == "asset_auth_failed"
def test_summarize_parts_counts_vertices_per_part():
+58 -9
View File
@@ -291,11 +291,11 @@ def test_an_envelope_from_the_future_or_another_tool_is_refused(package, tmp_pat
assert "envelope version" in excinfo.value.diagnostic.message
def test_an_encrypted_envelope_says_so_instead_of_failing_obscurely(package, tmp_path):
"""Stage 5 will open these; until then the message has to be honest."""
def test_an_envelope_that_claims_an_unknown_payload_kind_is_refused(package, tmp_path):
"""An unknown payload is a refusal with its own reason, not a numpy error later."""
def encrypt(members):
envelope = json.loads(members["envelope.json"])
envelope["payload"] = "aead"
envelope["payload"] = "rot13"
envelope["key_id"] = "customer-1"
members["envelope.json"] = json.dumps(envelope).encode()
return members
@@ -305,20 +305,66 @@ def test_an_encrypted_envelope_says_so_instead_of_failing_obscurely(package, tmp
read_package(broken)
item = excinfo.value.diagnostic
assert item.code == "asset_auth_failed"
assert "plain packages only" in item.message
assert "rot13" in item.message
def test_a_key_provider_is_refused_while_packages_are_plain(package, scene):
def test_an_encrypted_payload_needs_a_key_provider_and_a_verification_key(scene, tmp_path):
"""The two ways a sealed package can be unreadable here, named separately.
Both are *licensing* states, not corruption: the message has to let an operator
tell "this machine has no key for it" from "these bytes were changed".
"""
crypto = pytest.importorskip(
"cryptography", reason="sealing needs the crypto backend"
)
del crypto
from orisys_app_shared.scene3d.contracts import Sealing
from orisys_app_shared.scene3d import protect as protect_module
private = protect_module.generate_signing_key()
public = protect_module.public_key_pem(private)
provider = protect_module.MappingKeyProvider({"customer-1": "11" * 32})
sealed = tmp_path / "sealed.oryscene"
write_package(
scene,
sealed,
sealing=Sealing(
key_id="customer-1",
provider=provider,
signing_key_pem=private,
signing_key_reference="build-key",
verify_key_pem=public,
),
)
with pytest.raises(ThreeDError) as no_key:
read_package(sealed, verify_key_pem=public)
assert "key provider" in no_key.value.diagnostic.message
with pytest.raises(ThreeDError) as no_verify:
read_package(sealed, keys=provider)
assert "verification key" in no_verify.value.diagnostic.message
contents = read_package(sealed, keys=provider, verify_key_pem=public)
assert contents.arrays["sensing/positions.npy"].shape[0] > 0
def test_a_key_provider_is_unused_by_a_plain_package(package, scene):
"""A provider is not an error on a plain package: it is simply not needed.
Refusing here would break the one call site that hands the same provider to every
load, which is how a client with a mix of sealed and plain packages is written.
"""
class Keys:
def get_content_key(self, key_id: str) -> bytes: # pragma: no cover - not used
return b"secret"
raise AssertionError("a plain package must not ask for a key")
expected = ExpectedGeometry(
"finger", "rev-a", scene.geometry_id, scene.field_domain_id, scene.shape_hw
)
with pytest.raises(ThreeDError) as excinfo:
PackagedAssetLoader().load(package, expected, Keys())
assert "key provider" in excinfo.value.diagnostic.message
assets = PackagedAssetLoader().load(package, expected, Keys())
assert assets.geometry_id == scene.geometry_id
def test_the_manifest_carries_what_a_release_needs(package):
@@ -364,6 +410,9 @@ def test_the_envelope_names_the_magic_and_the_payload_state(package):
with zipfile.ZipFile(package) as archive:
envelope = json.loads(archive.read("envelope.json"))
assert envelope["magic"] == MAGIC
# An unsealed write stays at version 1: it is the package every release before
# stage 5 produced, and bumping it would make old readers refuse a plain file.
assert envelope["envelope_version"] == 1
assert envelope["payload"] == "plain"
assert envelope["entry_count"] == len(read_package(package).arrays)
assert "signature" not in envelope, "a plain package must not look signed"
+662
View File
@@ -0,0 +1,662 @@
"""Stage 5 acceptance: sealing, signatures, the platform lock and the record block.
The design document's §5.2 lists five protections and is explicit about what each one
buys; these tests are about holding the code to *those* claims and to no larger ones:
* **origin.** A signed package verifies against the shipped public key, and every way
of editing it — the manifest, an entry's bytes, the envelope's metadata, the
ciphertext — is refused. The tests also assert the negative: a signature over a
*plain* package is not accepted as encryption, and a package with no signature is
reported as unsigned rather than quietly accepted as if it were signed.
* **optional encryption.** AES-256-GCM with a fresh nonce per seal, the envelope bound
as associated data, and the plaintext digest inside the signed part. Two seals of
the same content must not share a nonce or a ciphertext.
* **key isolation.** Reading a sealed package needs a :class:`KeyProvider` and a
verification key; a *wrong* key is a refusal, not a crash, and no message anywhere
contains key material.
* **the platform lock.** A package compiled for one operating system, architecture,
GL profile or GPU is refused elsewhere — with the reason — and an unlocked package
is not the same as one locked to nothing.
* **the record block.** ``session.json`` says what the pane was; a recording without
the block (an older file) reads as "not supported" instead of raising.
"""
from __future__ import annotations
import json
import zipfile
from dataclasses import replace
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.record.visualization3d import (
BLOCK_VERSION,
build_block,
read_block,
)
from orisys_app_shared.scene3d import protect, synthetic
from orisys_app_shared.scene3d.contracts import (
PlatformInfo,
PlatformLock,
Sealing,
ThreeDError,
check_platform_lock,
)
from orisys_app_shared.scene3d.package import (
ENVELOPE_VERSION,
PAYLOAD_ENTRY,
SUPPORTED_ENVELOPE_VERSIONS,
describe_envelope,
read_package,
scene_from_contents,
write_package,
)
pytest.importorskip("cryptography", reason="stage 5 sealing needs the crypto backend")
@pytest.fixture(scope="module")
def signing_key() -> bytes:
return protect.generate_signing_key()
@pytest.fixture(scope="module")
def verify_key(signing_key) -> bytes:
return protect.public_key_pem(signing_key)
@pytest.fixture
def provider() -> protect.MappingKeyProvider:
return protect.MappingKeyProvider({"customer-1": "11" * 32})
@pytest.fixture
def scene():
return synthetic.poc_scene()
def _seal(tmp_path, scene, *, signing_key, verify_key, provider, name="sealed.oryscene"):
path = tmp_path / name
write_package(
scene,
path,
sealing=Sealing(
key_id="customer-1",
provider=provider,
signing_key_pem=signing_key,
signing_key_reference="build-2024",
verify_key_pem=verify_key,
),
)
return path
def _sign_only(tmp_path, scene, *, signing_key, verify_key, name="signed.oryscene"):
path = tmp_path / name
write_package(
scene,
path,
sealing=Sealing(
signing_key_pem=signing_key,
signing_key_reference="build-2024",
verify_key_pem=verify_key,
),
)
return path
# ------------------------------------------------------------------ the envelope
def test_a_sealed_package_round_trips_and_carries_the_same_content(
tmp_path, scene, signing_key, verify_key, provider
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
contents = read_package(sealed, keys=provider, verify_key_pem=verify_key)
reference = read_package(plain)
assert set(contents.arrays) == set(reference.arrays)
for name in contents.arrays:
assert np.array_equal(contents.arrays[name], reference.arrays[name]), name
assert contents.manifest["asset_hash"] == reference.manifest["asset_hash"], (
"sealing must not change the asset identity"
)
assets = scene_from_contents(contents)
assert assets.geometry_id == scene.geometry_id
def test_the_envelope_says_it_is_sealed_without_any_key(
tmp_path, scene, signing_key, verify_key, provider
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
summary = describe_envelope(sealed)
assert summary["envelope_version"] == ENVELOPE_VERSION == 2
assert summary["payload"] == "aes-gcm"
assert summary["sealed"] is True and summary["signed"] is True
assert summary["key_id"] == "customer-1"
assert summary["signing_key"] == "build-2024"
assert summary["ciphertext_bytes"] > 0
assert PAYLOAD_ENTRY in summary["members"]
# The summary is what a support ticket quotes: no key material, no nonce, no salt.
text = json.dumps(summary)
assert "11" * 32 not in text and "nonce" not in text and "salt" not in text
assert SUPPORTED_ENVELOPE_VERSIONS == (1, 2)
def test_a_plain_package_still_reads_and_says_it_is_unsigned(tmp_path, scene):
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
summary = describe_envelope(plain)
assert summary["envelope_version"] == 1
assert summary["payload"] == "plain"
assert summary["signed"] is False and summary["sealed"] is False
assert scene_from_contents(read_package(plain)).asset_id == scene.asset_id
def test_a_signed_plain_package_needs_the_public_key_and_verifies_with_it(
tmp_path, scene, signing_key, verify_key
):
signed = _sign_only(tmp_path, scene, signing_key=signing_key, verify_key=verify_key)
summary = describe_envelope(signed)
assert summary["payload"] == "plain" and summary["signed"] is True
assert summary["sealed"] is False
with pytest.raises(ThreeDError) as excinfo:
read_package(signed)
assert "verification key" in excinfo.value.diagnostic.message
contents = read_package(signed, verify_key_pem=verify_key)
assert contents.manifest["asset_id"] == scene.asset_id
# ------------------------------------------------------------------- tampering
def _rewrite(path: Path, target: Path, mutate) -> Path:
"""Copy a package while changing one member (or the envelope)."""
with zipfile.ZipFile(path) as source:
members = {name: source.read(name) for name in source.namelist()}
members = mutate(members)
with zipfile.ZipFile(target, "w") as archive:
for name, payload in members.items():
archive.writestr(name, payload)
return target
@pytest.mark.parametrize("what", ["ciphertext", "entry", "manifest", "metadata"])
def test_every_edit_of_a_sealed_package_is_refused(
tmp_path, scene, signing_key, verify_key, provider, what
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
def mutate(members: dict[str, bytes]) -> dict[str, bytes]:
if what == "ciphertext":
payload = bytearray(members[PAYLOAD_ENTRY])
payload[len(payload) // 2] ^= 0x01
members[PAYLOAD_ENTRY] = bytes(payload)
elif what == "entry":
# A signed *plain* package is where an entry can be edited directly.
raise AssertionError("covered by the signed-plain case below")
elif what == "manifest":
payload = members[PAYLOAD_ENTRY]
members[PAYLOAD_ENTRY] = payload + b"\x00"
else:
envelope = json.loads(members["envelope.json"])
envelope["key_id"] = "customer-2"
members["envelope.json"] = json.dumps(envelope).encode()
return members
if what == "entry":
pytest.skip("an entry cannot be edited inside a ciphertext; see the signed case")
broken = _rewrite(sealed, tmp_path / f"broken-{what}.oryscene", mutate)
with pytest.raises(ThreeDError) as excinfo:
read_package(broken, keys=provider, verify_key_pem=verify_key)
assert excinfo.value.diagnostic.code in ("asset_auth_failed",)
assert excinfo.value.diagnostic.message
def test_editing_an_entry_of_a_signed_package_is_refused(
tmp_path, scene, signing_key, verify_key
):
"""The signature covers the entries: this is what "origin" has to mean."""
signed = _sign_only(tmp_path, scene, signing_key=signing_key, verify_key=verify_key)
def mutate(members):
payload = bytearray(members["sensing/positions.npy"])
payload[-1] ^= 0x01
members["sensing/positions.npy"] = bytes(payload)
return members
broken = _rewrite(signed, tmp_path / "broken-entry.oryscene", mutate)
with pytest.raises(ThreeDError) as excinfo:
read_package(broken, verify_key_pem=verify_key)
assert excinfo.value.diagnostic.code == "asset_auth_failed"
def test_a_wrong_verification_key_is_refused(tmp_path, scene, signing_key, verify_key, provider):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
other = protect.public_key_pem(protect.generate_signing_key())
with pytest.raises(ThreeDError) as excinfo:
read_package(sealed, keys=provider, verify_key_pem=other)
assert "signature" in excinfo.value.diagnostic.message
def test_a_wrong_content_key_is_refused_and_reveals_nothing(
tmp_path, scene, signing_key, verify_key, provider
):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
wrong = protect.MappingKeyProvider({"customer-1": "22" * 32})
with pytest.raises(ThreeDError) as excinfo:
read_package(sealed, keys=wrong, verify_key_pem=verify_key)
item = excinfo.value.diagnostic
assert item.code == "asset_auth_failed"
assert "22" * 32 not in item.message
assert "11" * 32 not in item.message
def test_a_missing_key_for_the_key_id_is_named(tmp_path, scene, signing_key, verify_key, provider):
sealed = _seal(tmp_path, scene, signing_key=signing_key, verify_key=verify_key, provider=provider)
empty = protect.MappingKeyProvider({})
with pytest.raises(ThreeDError) as excinfo:
read_package(sealed, keys=empty, verify_key_pem=verify_key)
assert "customer-1" in excinfo.value.diagnostic.message
# ------------------------------------------------------------- the primitives
def test_a_nonce_is_never_reused_and_the_salt_is_fresh(provider):
first, first_bytes = protect.seal_payload(
b"the same payload", key_id="customer-1", provider=provider
)
second, second_bytes = protect.seal_payload(
b"the same payload", key_id="customer-1", provider=provider
)
assert first["nonce"] != second["nonce"]
assert first["salt"] != second["salt"]
assert first_bytes != second_bytes, "the same plaintext must not produce the same bytes"
assert len(protect._unb64(first["nonce"], field="nonce", length=protect.NONCE_BYTES)) == 12
def test_the_envelope_metadata_is_bound_into_the_ciphertext(provider):
envelope, ciphertext = protect.seal_payload(
b"payload", key_id="customer-1", provider=provider, extra={"asset_id": "x"}
)
assert protect.open_payload(envelope, ciphertext, provider=provider) == b"payload"
changed = dict(envelope)
changed["asset_id"] = "y"
with pytest.raises(ThreeDError):
protect.open_payload(changed, ciphertext, provider=provider)
def test_a_signed_envelope_covers_the_plaintext_digest(signing_key, verify_key, provider):
envelope, _ = protect.seal_payload(
b"payload", key_id="customer-1", provider=provider,
signing_key_pem=signing_key, signing_key_reference="k",
)
protect.verify_envelope(envelope, public_key_pem=verify_key)
changed = dict(envelope)
changed["payload_sha256"] = "0" * 64
with pytest.raises(ThreeDError) as excinfo:
protect.verify_envelope(changed, public_key_pem=verify_key)
assert "signature" in excinfo.value.diagnostic.message
def test_a_key_provider_must_return_a_usable_key():
with pytest.raises(ThreeDError):
protect.MappingKeyProvider({"short": "abcd"})
with pytest.raises(ThreeDError):
protect.MappingKeyProvider({"not-text": 17})
class Wrong:
def get_content_key(self, key_id: str) -> bytes:
return b"too short"
with pytest.raises(ThreeDError) as excinfo:
protect.seal_payload(b"x", key_id="k", provider=Wrong())
assert "32" in excinfo.value.diagnostic.message
def test_a_key_file_is_read_as_hex_base64_or_raw(tmp_path):
raw = bytes(range(32))
for name, text in (
("hex.key", raw.hex()),
("b64.key", __import__("base64").b64encode(raw).decode()),
):
path = tmp_path / name
path.write_text(text, encoding="utf-8")
assert protect.load_key_file(path, key_id="k").get_content_key("k") == raw
binary = tmp_path / "raw.key"
binary.write_bytes(raw)
assert protect.load_key_file(binary, key_id="k").get_content_key("k") == raw
def test_the_writer_refuses_to_seal_without_a_backend_or_without_a_key(
tmp_path, scene, monkeypatch
):
with pytest.raises(ThreeDError) as missing:
write_package(
scene,
tmp_path / "no-key.oryscene",
sealing=Sealing(key_id="customer-1", provider=None),
)
assert missing.value.diagnostic.code == "asset_auth_failed"
import orisys_app_shared.scene3d.package as package_module
monkeypatch.setattr(package_module, "backend_available", lambda: False)
from orisys_app_shared.scene3d.assets import SceneCompiler
options = _compile_options(
encryption_key_id="customer-1",
sealing=Sealing(
key_id="customer-1", provider=protect.MappingKeyProvider({"customer-1": "11" * 32})
),
)
with pytest.raises(ThreeDError) as no_backend:
SceneCompiler().compile(scene, None, tmp_path / "no-backend.oryscene", options)
assert "cryptography" in no_backend.value.diagnostic.message
def _compile_options(*, encryption_key_id=None, sealing=None, lock=None):
from orisys_app_shared.scene3d.contracts import CompileOptions
return CompileOptions(
asset_id="x",
version="0",
expected=synthetic.poc_expected(),
transform_sensor_from_glb=np.eye(4),
max_surface_vertices=0,
max_housing_triangles=1000,
signing_key_reference="build-key",
encryption_key_id=encryption_key_id,
platform_lock=lock,
sealing=sealing,
)
# ------------------------------------------------------------------ the lock
def test_an_unlocked_package_is_not_locked_to_nothing():
assert PlatformLock().is_unrestricted()
assert PlatformLock().to_dict()["os_family"] == []
assert check_platform_lock(None, PlatformInfo("linux", "aarch64")) == ()
assert check_platform_lock(PlatformLock(), PlatformInfo("linux", "aarch64")) == ()
def test_the_lock_covers_os_arch_profile_gpu_and_software_renderers():
lock = PlatformLock(
os_family=("windows",),
machine=("amd64",),
gl_profile="desktop33",
allowed_vendors=("NVIDIA",),
allowed_renderers=("RTX 4080",),
min_texture_size=16384,
note="dev box",
)
# The machine this was qualified on passes.
assert (
check_platform_lock(
lock,
PlatformInfo("windows", "amd64", "desktop33", "NVIDIA Corporation",
"NVIDIA GeForce RTX 4080 SUPER", False, 32768),
)
== ()
)
# Operating system and architecture are checkable with no graphics information.
static = check_platform_lock(lock, PlatformInfo("linux", "aarch64"))
assert len(static) == 2
assert all(item.code == "platform_mismatch" for item in static)
# Every graphics violation at once: profile, software renderer, vendor,
# renderer and texture size.
full = check_platform_lock(
lock,
PlatformInfo("windows", "amd64", "gles30", "Mesa", "llvmpipe", True, 4096),
)
joined = " ".join(item.message for item in full)
assert "profile" in joined
assert "software rasteriser" in joined
assert "vendor" in joined or "renderer" in joined
assert "texture" in joined
def test_the_lock_can_be_used_to_endorse_a_gpu_by_name():
"""Vendor is 'NVIDIA Corporation', the renderer is the specific card string."""
lock = PlatformLock(allowed_renderers=("RTX 4080",))
assert check_platform_lock(
lock, PlatformInfo("windows", "amd64", "desktop33", "NVIDIA",
"NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2", False, 32768)
) == ()
assert check_platform_lock(
lock, PlatformInfo("windows", "amd64", "desktop33", "Intel", "Intel UHD", False, 16384)
)
def test_a_malformed_lock_is_rejected_rather_than_ignored():
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"gl_profile": "vulkan"})
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"os_family": "windows"})
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"min_texture_size": -4})
with pytest.raises(ThreeDError):
PlatformLock.from_dict({"os_family": ["windows"], "min_texture_size": "big"})
def test_the_lock_travels_into_the_manifest_and_the_asset(tmp_path, scene):
locked = replace(
scene,
platform_lock=PlatformLock(os_family=("windows",), machine=("amd64",), note="dev"),
)
path = tmp_path / "locked.oryscene"
write_package(locked, path)
contents = read_package(path)
assert contents.manifest["platform_lock"]["os_family"] == ["windows"]
assets = scene_from_contents(contents)
assert assets.platform_lock is not None
assert assets.platform_lock.machine == ("amd64",)
assert assets.platform_lock.note == "dev"
# And a locked asset is a different package from an unlocked one.
other = tmp_path / "unlocked.oryscene"
write_package(scene, other)
assert read_package(path).manifest["asset_hash"] != read_package(other).manifest["asset_hash"]
# --------------------------------------------------------------- the record block
def test_the_block_records_what_the_pane_was():
block = build_block(
enabled=True,
available=True,
scene={"asset_id": "finger-display", "asset_hash": "a" * 64},
gl={"profile": "desktop33", "renderer": "RTX 4080", "software_renderer": False},
display={"deform_enabled": True, "display_gain": 50.0, "max_fps": 30},
stream={"quantity": "flow_magnitude", "unit": "px", "quality": "approximate"},
)
assert block["block_version"] == BLOCK_VERSION
record = read_block({"visualization3d": block})
assert record.supported and record.enabled and record.available
assert record.asset_hash() == "a" * 64
assert record.was_showing_measurements() is True
assert record.display["display_gain"] == 50.0
def test_a_disabled_pane_records_its_reason_and_no_measurement():
block = build_block(enabled=False, available=True, reason="the operator turned it off")
record = read_block({"visualization3d": block})
assert record.supported and not record.enabled
assert record.reason == "the operator turned it off"
assert record.was_showing_measurements() is False
def test_a_reference_model_is_not_an_observation():
block = build_block(
enabled=True,
available=True,
stream={"quantity": "unavailable", "unit": "1", "quality": "display_only"},
)
assert read_block({"visualization3d": block}).was_showing_measurements() is False
@pytest.mark.parametrize(
"session,reason_part",
[
(None, "no session metadata"),
({}, "no visualization3d block"),
({"visualization3d": 7}, "not an object"),
({"visualization3d": {"enabled": True}}, "version"),
({"visualization3d": {"block_version": 99}}, "not"),
],
)
def test_an_old_or_broken_recording_reads_as_unsupported(session, reason_part):
record = read_block(session)
assert record.supported is False
assert reason_part in record.reason
assert record.enabled is False
def test_an_empty_reason_is_not_written_for_a_working_pane():
block = build_block(enabled=True, available=True, reason="ignored")
assert block["reason"] == "", "a working pane must not carry a reason"
# ------------------------------------------------- the release workflow, end to end
def _tool(name: str):
"""Import a tool by path: the tools are scripts, not an installed package."""
import importlib.util
path = Path(__file__).resolve().parents[2] / "tools" / "scene_assets" / f"{name}.py"
spec = importlib.util.spec_from_file_location(f"scene_tool_{name}", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _shipped_asset_dirs() -> tuple[Path, Path] | None:
"""The Blender export and its unroll map, when the repository carries them."""
base = (
Path(__file__).resolve().parents[2]
/ "apps"
/ "studio"
/ "config"
/ "sensors"
/ "finger"
/ "assets"
/ "30fps-0703"
)
mapping = base / "unroll_map_uv_s0.4.npz"
if (base / "mesh.npz").is_file() and mapping.is_file():
return base, mapping
return None
def test_the_release_workflow_produces_a_signed_sealed_locked_package(tmp_path):
"""``compile_scene.py`` → ``validate_scene.py``, the way a release script runs it.
This is the stage-5 acceptance in one test: a real export compiled with a signing
key, a content key and a platform lock taken from a probe report, then validated
with only the public key and the content key — and then the same validation run
against a package with one byte changed, which must fail.
"""
shipped = _shipped_asset_dirs()
if shipped is None:
pytest.skip("the shipped finger export is not in this checkout")
legacy_dir, mapping = shipped
compile_tool = _tool("compile_scene")
validate_tool = _tool("validate_scene")
keys = tmp_path / "keys"
keys.mkdir()
signing = keys / "build-ed25519.pem"
signing.write_bytes(protect.generate_signing_key())
public = keys / "build-ed25519.pub.pem"
public.write_bytes(protect.public_key_pem(signing.read_bytes()))
content = keys / "finger-display.key"
content.write_text(bytes(range(32)).hex(), encoding="utf-8")
probe = tmp_path / "gl-report.json"
probe.write_text(
json.dumps(
{
"profile_requested": "desktop33",
"host": {"os_family": "windows", "machine": "amd64"},
"context": {
"profile_used": "desktop33",
"vendor": "NVIDIA Corporation",
"renderer": "NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
"software_renderer": False,
"max_texture_size": 32768,
},
}
),
encoding="utf-8",
)
package = tmp_path / "finger-display-1.0.0.oryscene"
code = compile_tool.main(
[
"--legacy-dir", str(legacy_dir),
"--map", str(mapping),
"--meters-per-source-unit", "0.001",
"--sign-key", str(signing),
"--sign-key-id", "build-2024",
"--write-verify-key", str(public),
"--encrypt-key-id", "finger-display",
"--key-file", str(content),
"--lock-from-probe", str(probe),
"--output", str(package),
]
)
assert code == 0
summary = describe_envelope(package)
assert summary["sealed"] and summary["signed"]
assert summary["key_id"] == "finger-display"
contents = read_package(
package,
keys=protect.load_key_file(content, key_id="finger-display"),
verify_key_pem=public.read_bytes(),
)
assets = scene_from_contents(contents)
assert assets.sensing_part().positions_sensor_m.shape[0] == 1032
assert assets.platform_lock is not None
assert assets.platform_lock.gl_profile == "desktop33"
assert assets.platform_lock.allowed_renderers == (
"NVIDIA GeForce RTX 4080 SUPER/PCIe/SSE2",
)
# The lock is inside the signed manifest, so the asset hash covers it.
assert contents.manifest["platform_lock"]["min_texture_size"] == 32768
release_args = [
str(package),
"--key-file", str(content),
"--key-id", "finger-display",
"--verify-key", str(public),
"--require-signature",
]
assert validate_tool.main(release_args) == 0
# One flipped byte in the ciphertext: the release gate must fail, not warn.
damaged = tmp_path / "damaged.oryscene"
with zipfile.ZipFile(package) as source:
members = {name: source.read(name) for name in source.namelist()}
payload = bytearray(members[PAYLOAD_ENTRY])
payload[0] ^= 0x01
members[PAYLOAD_ENTRY] = bytes(payload)
with zipfile.ZipFile(damaged, "w") as target:
for name, value in members.items():
target.writestr(name, value)
assert (
validate_tool.main(
[
str(damaged),
"--key-file", str(content),
"--key-id", "finger-display",
"--verify-key", str(public),
"--require-signature",
]
)
== 2
)
def test_the_release_gate_fails_an_unsigned_package(tmp_path, scene):
"""``--require-signature`` is the difference between a build and a release."""
validate_tool = _tool("validate_scene")
plain = tmp_path / "plain.oryscene"
write_package(scene, plain)
assert validate_tool.main([str(plain)]) == 0, "an engineering package is acceptable"
assert validate_tool.main([str(plain), "--require-signature"]) == 2