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:
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user