"""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