Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion src/simantic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 90 additions & 26 deletions src/simantic/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<product>/<channel>.signed.json
{"manifest": "<the manifest text>", "signature": "<hex RSA signature>"}
"""

from __future__ import annotations

import hashlib
import hmac
import io
import json
import os
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
101 changes: 95 additions & 6 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 ---


Expand Down Expand Up @@ -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 ----------------------------------------------------------
Expand Down
Loading