diff --git a/README.md b/README.md index 3708208..507cea2 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ and no port to talk to. > **Alpha, version 0.4.x.** We are still moving things around, so the API can > change without a deprecation period. Pin an exact version -> (`simantic==0.4.1`) if you depend on it, and please hold off on production +> (`simantic==0.4.2`) if you depend on it, and please hold off on production > pipelines for now. Tell us what breaks. ## Setup diff --git a/pyproject.toml b/pyproject.toml index fca2ab4..7e0ccad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "simantic" -version = "0.4.1" +version = "0.4.2" description = "Python SDK and pytest plugin for the Simantic circuit and firmware simulators" # PyYAML reads sim-fixtures test.yaml manifests; pythonnet hosts the # simulation engine (Simantic.Core, .NET) in-process for `simantic.Sim`. diff --git a/src/simantic/__init__.py b/src/simantic/__init__.py index 5b7dce1..cba3e76 100644 --- a/src/simantic/__init__.py +++ b/src/simantic/__init__.py @@ -28,7 +28,7 @@ from .session import BACKENDS, ExpectTimeout, Match, Sim from ._rust import NotSupported, ScenarioRun, run_scenario -__version__ = "0.4.1" +__version__ = "0.4.2" __all__ = [ # firmware diff --git a/src/simantic/install.py b/src/simantic/install.py index 1f1dd7e..3e0b858 100644 --- a/src/simantic/install.py +++ b/src/simantic/install.py @@ -15,14 +15,22 @@ written into site-packages: an installed package may be read-only, and a binary there would vanish on the next upgrade. -Releases are public objects, keyed by version, so fetching needs no account; -checksums from the manifest are what make a download trustworthy, so an -artifact without one is refused. +Releases are public objects, keyed by version, so fetching needs no account. +Trust comes from two checks: the manifest is signed with a release key whose +public half is below, and every artifact is verified against the checksum the +manifest gives for it. A manifest that is unsigned, or an artifact without a +checksum, is refused. + +What is fetched is the signed form of the manifest: + + releases//.signed.json + {"manifest": "", "signature": ""} """ from __future__ import annotations import hashlib +import hmac import io import json import os @@ -61,6 +69,81 @@ class InstallError(RuntimeError): """The binary could not be fetched or verified.""" +#: Public halves of the keys allowed to sign a release manifest, as +#: (modulus, exponent). More than one so a key can be rotated: a release signed +#: with any of them is accepted. +RELEASE_KEYS: tuple[tuple[int, int], ...] = ( + (int( + "9e3afacf572c746e16f9aa8cf37402f9ee413377101c2cabb83c0f2dcd5089bf" + "a29c3b0f0e98ae067d934267cb6263e074e2027932caded10d615328d857cee2" + "d1356e09c1d59c64ec50124ef485370ac593eb524b062eec951e2b88b9e1d5ee" + "63444f2a8949da2c7a4d1d605c9193d25bd380f925afe09b4032513f0ec8d6ca" + "6a225c5333c3c64fb6bff897fbaa93c9c2d3fba85930c4cf5463eb17b2a388a6" + "6b2bb1a11e3c73b789475e7978f4e06ec6ead6f1aaa9b3a066c05d2d5a1584c2" + "0ae0ec4565d1bb44e7807022216f7a3c4a4f9722ab159c352ae744c81d60fe1a" + "26c8f0065d0e81537f66dc40d3cbf782dbe189a7119d16d6783d56670fbf965a" + "3df664a12e9651e8c2622aa561bca00bb3d634f777937c63864b5312b3cc4ef8" + "3d9d90b3d5e1387ac869022d1f26950c0549caf44c3bf3446902bc21957d66c4" + "133fccd89144bd92ee7f7d3892cfcc0c9a4723c74ab53e97f6aadbb02a6c34ab" + "ad948bb674f981068cadc464fa0ab0c01d32083cc4020defc6a8817058987d1a" + "993a1c1c0fd116ff9ad02bb32f2d4ab7e0532b72be11109ae965f72a71241f30" + "0d5725d01393f881d08a4c76587578b6afd193c0755009f1670f638195309fe0" + "e79f052f20f44e1c4dc0d35ac6470fc56d2abf30ad906582703ee8ccb4608f56" + "d8fa90727295e2ac396c6798249270552c1c70b7aa0c9e7bb7f7d1d27173e68b", + 16, + ), 65537), +) + +#: DER prefix of a SHA-256 DigestInfo (RFC 8017, section 9.2). +_SHA256_PREFIX = bytes.fromhex("3031300d060960864801650304020105000420") + + +def _signed_by(message: bytes, signature: bytes, modulus: int, exponent: int) -> bool: + """RSASSA-PKCS1-v1_5 with SHA-256, the scheme `openssl dgst -sha256 -sign` + produces. Verification is one modular exponentiation and a comparison + against the only encoding that is valid, so it needs nothing beyond the + standard library.""" + size = (modulus.bit_length() + 7) // 8 + value = int.from_bytes(signature, "big") + if len(signature) != size or value >= modulus: + return False + digest = _SHA256_PREFIX + hashlib.sha256(message).digest() + expected = b"\x00\x01" + b"\xff" * (size - len(digest) - 3) + b"\x00" + digest + return hmac.compare_digest(pow(value, exponent, modulus).to_bytes(size, "big"), expected) + + +def _verified_manifest(body: bytes) -> dict: + """The manifest inside a signed envelope, or InstallError if no release + key signed it.""" + try: + envelope = json.loads(body) + text = envelope["manifest"] + signature = bytes.fromhex(envelope["signature"]) + if not isinstance(text, str): + raise TypeError("manifest is not text") + except (ValueError, KeyError, TypeError) as exc: + raise InstallError(f"release manifest is not a signed manifest: {exc}") from None + if not any(_signed_by(text.encode(), signature, n, e) for n, e in RELEASE_KEYS): + raise InstallError( + "release manifest signature is not valid. Refusing to install." + ) + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise InstallError(f"release manifest is not valid JSON: {exc}") from None + + +def _fetch_manifest(url: str, missing: str, timeout: float) -> dict: + try: + with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response: + body = response.read() + except urllib.error.HTTPError as exc: + raise InstallError(f"{missing} (HTTP {exc.code} from {url})") from None + except urllib.error.URLError as exc: + raise InstallError(f"cannot reach the release server: {exc.reason}") from None + return _verified_manifest(body) + + @dataclass(frozen=True) class Artifact: version: str @@ -119,19 +202,8 @@ def fetch_manifest( f"unknown binary {binary!r}; expected one of {sorted(PRODUCTS)}" ) channel = channel or default_channel() - url = f"{releases_url()}/{product}/{channel}.json" - try: - with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response: - return json.loads(response.read()) - except urllib.error.HTTPError as exc: - raise InstallError( - f"no published releases for {binary!r} (HTTP {exc.code} from {url}). " - "Install the binary yourself and point $SIMANTIC_* at it." - ) from None - except urllib.error.URLError as exc: - raise InstallError(f"cannot reach the release server: {exc.reason}") from None - except json.JSONDecodeError as exc: - raise InstallError(f"release manifest is not valid JSON: {exc}") from None + url = f"{releases_url()}/{product}/{channel}.signed.json" + return _fetch_manifest(url, f"no published releases for {binary!r}", timeout) def resolve( @@ -350,16 +422,8 @@ def installed_rust_engine() -> Path | None: def fetch_rust_manifest(*, channel: str | None = None, timeout: float = 30) -> dict: channel = channel or default_channel() - url = f"{releases_url()}/{RUST_ENGINE_PRODUCT}/{channel}.json" - try: - with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response: - return json.loads(response.read()) - except urllib.error.HTTPError as exc: - raise InstallError(f"no published Rust engine (HTTP {exc.code} from {url})") from None - except urllib.error.URLError as exc: - raise InstallError(f"cannot reach the release server: {exc.reason}") from None - except json.JSONDecodeError as exc: - raise InstallError(f"release manifest is not valid JSON: {exc}") from None + url = f"{releases_url()}/{RUST_ENGINE_PRODUCT}/{channel}.signed.json" + return _fetch_manifest(url, "no published Rust engine", timeout) def install_rust_engine(*, force: bool = False, channel: str | None = None) -> Path: diff --git a/tests/test_install.py b/tests/test_install.py index dacd276..84452f9 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -47,30 +47,30 @@ def zipped(name: str, body: bytes) -> bytes: def test_latest_is_the_default_channel(monkeypatch): - assert _requested_url(monkeypatch, {}) .endswith("/cli/latest.json") + assert _requested_url(monkeypatch, {}) .endswith("/cli/latest.signed.json") def test_channel_argument_selects_the_manifest(monkeypatch): url = _requested_url(monkeypatch, {}, channel="testing") - assert url.endswith("/cli/testing.json") + assert url.endswith("/cli/testing.signed.json") def test_environment_selects_the_channel(monkeypatch): """So a tester can opt in once instead of on every command.""" assert _requested_url(monkeypatch, {"SIMANTIC_CHANNEL": "testing"}).endswith( - "/cli/testing.json" + "/cli/testing.signed.json" ) def test_explicit_channel_beats_the_environment(monkeypatch): url = _requested_url(monkeypatch, {"SIMANTIC_CHANNEL": "testing"}, channel="latest") - assert url.endswith("/cli/latest.json") + assert url.endswith("/cli/latest.signed.json") def test_releases_host_can_be_redirected(monkeypatch): """Rehearse a release against staging before publishing it.""" url = _requested_url(monkeypatch, {"SIMANTIC_RELEASES_URL": "http://localhost:8765/"}) - assert url == "http://localhost:8765/cli/latest.json" + assert url == "http://localhost:8765/cli/latest.signed.json" def _requested_url(monkeypatch, env, *, binary="sim", **kwargs) -> str: @@ -214,6 +214,95 @@ def __exit__(self, *exc): return lambda *a, **k: Response() +# --- manifest signatures --- + +# A throwaway key that signs nothing real: it exists so these tests can sign. +TEST_N = int( + "c44280625993bbee6d52677b60a5fc32a0daeea4c763f462f0fe976bc993de4b" + "a6ec8f745e080f9be96da9f40e5e2ec1e33e9d4b261e1e37167cac37e02ce034" + "bd77e3ab2e2c3c2e404dce825f7cdfbdb9e87e465b0ac447c9f692604a180f5d" + "3a3acbc1bd733041794bd0c5018923897304133d4007cec8bee8ce0ad842ffc6" + "964c7cbbe75bc7a5aaacd5f25a0ff5d42f68ee1f6bfc03005241775e01c2ac7a" + "4aff1d471162f7977893146551405e3e91509427008f9f9353180a988ec0187e" + "77d068c90f7f11033881d02083dde711e98dbf96e143764f30e1369fa9bbc583" + "d7a28473d4d936d960129d8a36ce07fa191412da3afd46837b21fa453a6a9cbb", + 16, +) +TEST_D = int( + "0d5753f0db93fe5773d9012dd2e115a6bf668288730169707c5f621db2a3399e" + "3ce7a1ccd0438e0414371f3176f4920b1e0e7894ce2f87f048b80ae0f57d3774" + "7e58b30244ee3edd0a040000becaf74ea75f958de4cc739149ba5832f176773c" + "e8236d0c6b7b74114f5487098d542c3540bb4b2f83b5c429c34882111ca85948" + "a1032ad83215e3c0d8b2d643e3b4633a2ce23110373eedd660837cf912c1f27e" + "14df96b37f0e1abe52b03f977872b457edf038d59e69913be342dcb06de65dac" + "5d7a3e6a2d572101cf9aec01a6fd42cae29e704d7ef6766f8cc4b3850dc30cba" + "5d526a2dc51644cd17a973e962ef05f8046362d29544b6ad5c427f487cd75041", + 16, +) + + +def signed(manifest: dict | str, *, d: int = TEST_D, n: int = TEST_N) -> bytes: + """The envelope the release workflow publishes, signed with the test key.""" + text = manifest if isinstance(manifest, str) else json.dumps(manifest) + size = (n.bit_length() + 7) // 8 + digest = install._SHA256_PREFIX + hashlib.sha256(text.encode()).digest() + block = b"\x00\x01" + b"\xff" * (size - len(digest) - 3) + b"\x00" + digest + signature = pow(int.from_bytes(block, "big"), d, n).to_bytes(size, "big") + return json.dumps({"manifest": text, "signature": signature.hex()}).encode() + + +@pytest.fixture +def test_key(monkeypatch): + monkeypatch.setattr(install, "RELEASE_KEYS", ((TEST_N, 65537),)) + + +def test_a_signed_manifest_is_accepted(monkeypatch, test_key): + monkeypatch.setattr(install.urllib.request, "urlopen", _fake_urlopen(signed(MANIFEST))) + assert install.fetch_manifest("sim") == MANIFEST + + +def test_any_listed_key_may_sign(monkeypatch): + """Rotation: a release signed with the second key installs on a client + that still lists the first.""" + monkeypatch.setattr(install, "RELEASE_KEYS", (install.RELEASE_KEYS[0], (TEST_N, 65537))) + assert install._verified_manifest(signed(MANIFEST)) == MANIFEST + + +def test_a_manifest_signed_by_another_key_is_refused(): + """The shipped keys do not include the test key.""" + with pytest.raises(install.InstallError, match="signature is not valid"): + install._verified_manifest(signed(MANIFEST)) + + +def test_a_tampered_manifest_is_refused(test_key): + envelope = json.loads(signed(MANIFEST)) + envelope["manifest"] = envelope["manifest"].replace("example.invalid", "evil.invalid") + with pytest.raises(install.InstallError, match="signature is not valid"): + install._verified_manifest(json.dumps(envelope).encode()) + + +@pytest.mark.parametrize( + "body", + [ + json.dumps(MANIFEST).encode(), # the unsigned manifest, as published before + b'{"manifest": "{}", "signature": "zz"}', + b'{"manifest": "{}", "signature": ""}', + b'{"manifest": {}, "signature": "00"}', + b"not json", + ], +) +def test_an_unsigned_manifest_is_refused(body, test_key): + with pytest.raises(install.InstallError): + install._verified_manifest(body) + + +def test_the_shipped_key_is_the_release_key(): + """Guards the constant against a stray edit: SHA-256 of the modulus.""" + (n, e), = install.RELEASE_KEYS + assert n.bit_length() == 4096 and e == 65537 + assert hashlib.sha256(n.to_bytes(512, "big")).hexdigest() == "6957c5a276c268211db612c1916bd3ab133f28f0c60046c27391f6251261d5e9" + + # --- extraction --- @@ -470,7 +559,7 @@ def capture(request, **k): monkeypatch.setattr(install.urllib.request, "urlopen", capture) with pytest.raises(install.InstallError): install.fetch_rust_manifest() - assert seen[0].endswith("/pyrite/latest.json") + assert seen[0].endswith("/pyrite/latest.signed.json") # -- engine layouts ----------------------------------------------------------