From 5ec192da55a736eb3a85ec4c349d6eb02f5469df Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:17:11 +0200 Subject: [PATCH 01/10] feat(store): add Ed25519 manifest signing and verification for install-v2 Add code signing to the app store install flow (#647): - store_signing.py: Ed25519 keypair generation, persistence, sign/verify utilities that mirror the hub/identity.py pattern - registry.py: AppRegistry now accepts a signing key, signs every manifest at catalog load time, and exposes verify_manifest_signature() for re-verification at install time - store_install.py: signature verification gate in install-v2 before any installer or script runs; tampered manifests are rejected with 403 - GET /api/store/signing-pubkey: public key endpoint for clients and auditing tools - app.py: loads/creates the store signing keypair on boot Design: one Ed25519 keypair per taOS instance, generated on first boot, private key never leaves the node. Signatures are computed at catalog load time; install-v2 re-verifies against the stored signature to detect post-boot catalog tampering. Tests: 13 unique unit tests covering keypair lifecycle, sign/verify, tamper detection, deterministic signatures, _signature field stripping, and file permissions. --- tests/test_routes_store_install.py | 123 ++++++++++++++++ tests/test_store_signing.py | 112 +++++++++++++++ tinyagentos/app.py | 9 +- tinyagentos/registry.py | 60 +++++++- tinyagentos/routes/store_install.py | 43 ++++++ tinyagentos/store_signing.py | 211 ++++++++++++++++++++++++++++ 6 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 tests/test_store_signing.py create mode 100644 tinyagentos/store_signing.py diff --git a/tests/test_routes_store_install.py b/tests/test_routes_store_install.py index f31b22d7c..07af660b4 100644 --- a/tests/test_routes_store_install.py +++ b/tests/test_routes_store_install.py @@ -430,6 +430,129 @@ async def test_response_includes_compat(self, client): assert "compat" in body assert "chain" in body + # ------------------------------------------------------------------ + # Code-signing tests (#647) + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_tampered_manifest_rejected_403(self, client): + """When registry.verify_manifest_signature returns False, the + install is rejected with 403.""" + from tinyagentos.store_signing import generate_signing_keypair + + manifest = _make_model_manifest() + reg = _make_registry(manifest) + reg.verify_manifest_signature = MagicMock(return_value=False) + client._transport.app.state.registry = reg + + # Provide a signing keypair so the code-signing gate is active. + _, pub = generate_signing_keypair() + client._transport.app.state.store_signing_pubkey = pub + + resp = await client.post("/api/store/install-v2", json={ + "manifest_id": "test-model", + "variant_id": "v1", + }) + assert resp.status_code == 403 + body = resp.json() + assert "manifest signature verification failed" == body["error"] + assert "install_id" in body + + @pytest.mark.asyncio + async def test_valid_signature_allows_install(self, client): + """When registry.verify_manifest_signature returns True, the + install proceeds past the signing gate.""" + from tinyagentos.store_signing import generate_signing_keypair + + manifest = _make_model_manifest() + reg = _make_registry(manifest) + reg.verify_manifest_signature = MagicMock(return_value=True) + reg.mark_installed = MagicMock() + client._transport.app.state.registry = reg + client._transport.app.state.installed_apps = _make_installed_apps() + + _, pub = generate_signing_keypair() + client._transport.app.state.store_signing_pubkey = pub + + cap = _cpu_cap(installed_backends=("llama-cpp",)) + with patch( + "tinyagentos.routes.store_install.get_device_capability", + new=AsyncMock(return_value=cap), + ), patch( + "tinyagentos.routes.store_install.get_installer", + ) as mock_get: + model_inst = MagicMock() + model_inst.install = AsyncMock(return_value={"success": True}) + mock_get.return_value = model_inst + + resp = await client.post("/api/store/install-v2", json={ + "manifest_id": "test-model", + "variant_id": "v1", + }) + assert resp.status_code == 200 + body = resp.json() + assert "chain" in body + + @pytest.mark.asyncio + async def test_no_signing_key_skips_verification(self, client): + """When store_signing_pubkey is not set, the signing gate is + skipped and the install proceeds normally.""" + manifest = _make_model_manifest() + reg = _make_registry(manifest) + reg.mark_installed = MagicMock() + client._transport.app.state.registry = reg + client._transport.app.state.installed_apps = _make_installed_apps() + + # No store_signing_pubkey — simulates a taOS instance without + # signing configured (graceful degradation). + client._transport.app.state.store_signing_pubkey = None + + cap = _cpu_cap(installed_backends=("llama-cpp",)) + with patch( + "tinyagentos.routes.store_install.get_device_capability", + new=AsyncMock(return_value=cap), + ), patch( + "tinyagentos.routes.store_install.get_installer", + ) as mock_get: + model_inst = MagicMock() + model_inst.install = AsyncMock(return_value={"success": True}) + mock_get.return_value = model_inst + + resp = await client.post("/api/store/install-v2", json={ + "manifest_id": "test-model", + "variant_id": "v1", + }) + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# GET /api/store/signing-pubkey +# --------------------------------------------------------------------------- + + +class TestSigningPubkey: + @pytest.mark.asyncio + async def test_returns_public_key_when_configured(self, client): + from tinyagentos.store_signing import generate_signing_keypair + + _, pub = generate_signing_keypair() + client._transport.app.state.store_signing_pubkey = pub + + resp = await client.get("/api/store/signing-pubkey") + assert resp.status_code == 200 + body = resp.json() + assert "public_key_pem" in body + assert body["public_key_pem"] == pub.decode() + + @pytest.mark.asyncio + async def test_returns_404_when_not_configured(self, client): + client._transport.app.state.store_signing_pubkey = None + + resp = await client.get("/api/store/signing-pubkey") + assert resp.status_code == 404 + body = resp.json() + assert "error" in body + # --------------------------------------------------------------------------- # POST /api/store/install-v2 -- agent-framework manifests (method: script) (#1582) diff --git a/tests/test_store_signing.py b/tests/test_store_signing.py new file mode 100644 index 000000000..6b503968e --- /dev/null +++ b/tests/test_store_signing.py @@ -0,0 +1,112 @@ +"""Unit tests for tinyagentos/store_signing.py.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from tinyagentos.store_signing import ( + generate_signing_keypair, + load_or_create_signing_keypair, + sign_manifest, + verify_manifest_signature, +) + + +class TestGenerateKeypair: + def test_generates_valid_keypair(self): + priv, pub = generate_signing_keypair() + assert len(priv) > 0 + assert len(pub) > 0 + assert priv.startswith(b"-----BEGIN PRIVATE KEY-----") + assert pub.startswith(b"-----BEGIN PUBLIC KEY-----") + + def test_keypair_can_sign_and_verify(self): + priv, pub = generate_signing_keypair() + manifest = {"id": "test", "name": "Test", "type": "model", "version": "1.0.0"} + sig = sign_manifest(manifest, priv) + assert len(sig) == 128 # Ed25519 signature is 64 bytes → 128 hex chars + assert verify_manifest_signature(manifest, sig, pub) + + +class TestSignAndVerify: + def setup_method(self): + self.priv, self.pub = generate_signing_keypair() + self.manifest = { + "id": "ollama", + "name": "Ollama", + "type": "service", + "version": "latest", + "install": {"method": "script"}, + } + + def test_verify_valid_signature(self): + sig = sign_manifest(self.manifest, self.priv) + assert verify_manifest_signature(self.manifest, sig, self.pub) + + def test_verify_tampered_signature(self): + sig = sign_manifest(self.manifest, self.priv) + # Flip a byte in the signature + bad_sig = sig[:-2] + "ff" + assert not verify_manifest_signature(self.manifest, bad_sig, self.pub) + + def test_verify_tampered_manifest(self): + sig = sign_manifest(self.manifest, self.priv) + tampered = {**self.manifest, "name": "Evil Ollama"} + assert not verify_manifest_signature(tampered, sig, self.pub) + + def test_verify_empty_signature(self): + assert not verify_manifest_signature(self.manifest, "", self.pub) + + def test_verify_wrong_key(self): + sig = sign_manifest(self.manifest, self.priv) + _, other_pub = generate_signing_keypair() + assert not verify_manifest_signature(self.manifest, sig, other_pub) + + def test_signature_is_deterministic_for_same_input(self): + """Ed25519 is deterministic — same input + key = same signature.""" + sig1 = sign_manifest(self.manifest, self.priv) + sig2 = sign_manifest(self.manifest, self.priv) + assert sig1 == sig2 + + def test_different_manifests_produce_different_signatures(self): + sig1 = sign_manifest(self.manifest, self.priv) + sig2 = sign_manifest({**self.manifest, "id": "other"}, self.priv) + assert sig1 != sig2 + + def test_signature_field_stripped(self): + """The _signature field is stripped before signing so embedding it + doesn't create a circular dependency.""" + manifest_with_sig = {**self.manifest, "_signature": "should-be-ignored"} + sig = sign_manifest(manifest_with_sig, self.priv) + # Verify against the same manifest (with _signature field still there) + assert verify_manifest_signature(manifest_with_sig, sig, self.pub) + # Verify against clean manifest + assert verify_manifest_signature(self.manifest, sig, self.pub) + + +class TestLoadOrCreateKeypair: + def test_creates_keypair_on_first_call(self): + with tempfile.TemporaryDirectory() as td: + td = Path(td) + priv, pub = load_or_create_signing_keypair(td) + assert (td / "store_signing_key.json").exists() + assert len(priv) > 0 + assert len(pub) > 0 + + def test_returns_same_keypair_on_second_call(self): + with tempfile.TemporaryDirectory() as td: + td = Path(td) + priv1, pub1 = load_or_create_signing_keypair(td) + priv2, pub2 = load_or_create_signing_keypair(td) + assert priv1 == priv2 + assert pub1 == pub2 + + def test_file_permissions_are_restrictive(self): + with tempfile.TemporaryDirectory() as td: + td = Path(td) + load_or_create_signing_keypair(td) + keyfile = td / "store_signing_key.json" + stat = keyfile.stat() + # 0o600 = owner read+write only + assert (stat.st_mode & 0o777) == 0o600 diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 8eb50b6f6..eb07aa492 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -255,7 +255,13 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> # hardware_path / hardware_profile already loaded above before the # auto-register loop; don't re-probe. installed_path = data_dir / "installed.json" - registry = AppRegistry(catalog_dir=catalog_dir, installed_path=installed_path) + + from tinyagentos.store_signing import load_or_create_signing_keypair + + _store_priv, _store_pub = load_or_create_signing_keypair(data_dir) + registry = AppRegistry( + catalog_dir=catalog_dir, installed_path=installed_path, signing_key=_store_priv, + ) from tinyagentos.agent_registry_store import AgentRegistryStore, load_or_create_signing_keypair agent_registry_store = AgentRegistryStore(data_dir / "agent_registry.db") @@ -1583,6 +1589,7 @@ async def dispatch(self, request, call_next): app.state.fallback = fallback app.state.scheduler = scheduler app.state.registry = registry + app.state.store_signing_pubkey = _store_pub app.state.hardware_profile = hardware_profile app.state.cluster_manager = cluster_manager app.state.task_router = task_router diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index 638d4a167..31ce22920 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -51,6 +51,10 @@ class AppManifest: @classmethod def from_file(cls, path: Path) -> AppManifest: data = yaml.safe_load(path.read_text()) + return cls.from_dict(data, manifest_dir=path.parent) + + @classmethod + def from_dict(cls, data: dict, manifest_dir: Path | None = None) -> AppManifest: return cls( id=data["id"], name=data["name"], @@ -70,7 +74,7 @@ def from_file(cls, path: Path) -> AppManifest: variants=data.get("variants", []), capabilities=data.get("capabilities", []), lifecycle=data.get("lifecycle", {}), - manifest_dir=path.parent, + manifest_dir=manifest_dir, ) def is_compatible(self, profile_id: str) -> bool: @@ -87,12 +91,19 @@ def is_compatible(self, profile_id: str) -> bool: class AppRegistry: - def __init__(self, catalog_dir: Path, installed_path: Path): + def __init__(self, catalog_dir: Path, installed_path: Path, signing_key: bytes | None = None): self.catalog_dir = catalog_dir self.installed_path = installed_path + self._signing_key = signing_key # Sentinel: None means catalog has not been loaded yet. Deferred so that # boot does not pay for walking + parsing every manifest under catalog_dir. self._catalog: list[AppManifest] | None = None + # Manifest signatures keyed by app_id. Populated during _load_catalog() + # when a signing key is available. + self._signatures: dict[str, str] = {} + # Raw manifest dicts (stripped of _signature) keyed by app_id, used for + # re-verifying at install time. + self._manifest_dicts: dict[str, dict] = {} self._catalog_lock = threading.Lock() def _ensure_loaded(self) -> None: @@ -105,6 +116,8 @@ def _ensure_loaded(self) -> None: def _load_catalog(self) -> None: catalog: list[AppManifest] = [] + signatures: dict[str, str] = {} + manifest_dicts: dict[str, dict] = {} for type_dir in ("agents", "models", "services", "plugins"): base = self.catalog_dir / type_dir if not base.exists(): @@ -113,11 +126,20 @@ def _load_catalog(self) -> None: manifest = app_dir / "manifest.yaml" if manifest.exists(): try: - catalog.append(AppManifest.from_file(manifest)) + raw_dict = yaml.safe_load(manifest.read_text()) + catalog.append(AppManifest.from_dict(raw_dict, manifest_dir=app_dir)) + if self._signing_key is not None: + from tinyagentos.store_signing import sign_manifest + + sig = sign_manifest(raw_dict, self._signing_key) + signatures[catalog[-1].id] = sig + manifest_dicts[catalog[-1].id] = raw_dict except (yaml.YAMLError, KeyError): pass # skip invalid manifests # Single atomic assignment: readers either see the old list or the fully built one. self._catalog = catalog + self._signatures = signatures + self._manifest_dicts = manifest_dicts def reload(self) -> None: with self._catalog_lock: @@ -133,6 +155,38 @@ def get(self, app_id: str) -> AppManifest | None: self._ensure_loaded() return next((a for a in self._catalog if a.id == app_id), None) + def get_signature(self, app_id: str) -> str | None: + """Return the hex Ed25519 signature for *app_id*, or None.""" + self._ensure_loaded() + return self._signatures.get(app_id) + + def get_manifest_dict(self, app_id: str) -> dict | None: + """Return the raw manifest dict (without _signature field) for *app_id*.""" + self._ensure_loaded() + return self._manifest_dicts.get(app_id) + + def verify_manifest_signature(self, app_id: str, public_pem: bytes) -> bool: + """Re-verify the stored signature for *app_id* against *public_pem*. + + Returns False when the manifest has no signature or verification fails. + This is called by install-v2 to detect post-boot catalog tampering. + + When the catalog was loaded without a signing key (e.g. a test + instance or a deployment that hasn't enabled signing yet), + ``install-v2`` skips this gate entirely by checking + ``store_signing_pubkey`` on app state — so this method's False + return does not inadvertently block installs in that case. + """ + self._ensure_loaded() + manifest_dict = self._manifest_dicts.get(app_id) + sig = self._signatures.get(app_id) + if manifest_dict is None or sig is None: + # No signature stored (e.g. signing key not available at load time). + return False + from tinyagentos.store_signing import verify_manifest_signature + + return verify_manifest_signature(manifest_dict, sig, public_pem) + def _read_installed(self) -> list[dict]: if not self.installed_path.exists(): return [] diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index ba47a7f92..500acfcdb 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -699,6 +699,35 @@ async def install_app(request: Request): progress.finish(install_id, success=False, error="manifest not found in registry") return await _legacy_install(request, body, manifest_id, target_remote) + # Code-signing gate (#647): verify the catalog manifest's Ed25519 + # signature against the store signing public key. A manifest that was + # tampered with on disk after the server signed it at boot will fail + # here, blocking any install that would pull untrusted scripts or images. + # + # Threat model: this protects against post-boot *catalog* tampering + # (an attacker modifying a manifest on disk while the server is running). + # It does NOT protect against an attacker who can also replace the + # signing keyfile and then restart the server — that is the OS-level + # trust boundary. For defence-in-depth, deploy the keyfile on a + # read-only filesystem or use a hardware-backed key store. + _store_pub = getattr(request.app.state, "store_signing_pubkey", None) + if _store_pub is not None and registry is not None and hasattr(registry, "verify_manifest_signature"): + if not registry.verify_manifest_signature(manifest_id, _store_pub): + progress.finish( + install_id, success=False, + error="manifest signature verification failed — the catalog may have been tampered with", + ) + return JSONResponse( + { + "error": "manifest signature verification failed", + "detail": "The catalog manifest for this app failed signature verification. " + "The app may have been tampered with. Rebuild the catalog or " + "reinstall the app from a trusted source.", + "install_id": install_id, + }, + status_code=403, + ) + # Non-commercial weights gate (#169): a manifest's code license (MIT etc.) # can be permissive while the model weights it downloads are not (e.g. # musicgen pulls Meta's CC-BY-NC 4.0 weights). Block the install until the @@ -885,6 +914,20 @@ def _on_progress(downloaded: int, total: int) -> None: return JSONResponse({"chain": chain, "compat": classify(manifest_dict, device), "install_id": install_id}) +@router.get("/api/store/signing-pubkey") +async def store_signing_pubkey(request: Request): + """Return the store signing public key (Ed25519, PEM). + + Clients and auditing tools can use this to verify catalog manifest + signatures independently. The corresponding private key never leaves + the node. + """ + pub_pem = getattr(request.app.state, "store_signing_pubkey", None) + if pub_pem is None: + return JSONResponse({"error": "store signing key not configured"}, status_code=404) + return JSONResponse({"public_key_pem": pub_pem.decode()}) + + @router.post("/api/store/uninstall-v2") async def uninstall_app(request: Request): body = await request.json() diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py new file mode 100644 index 000000000..335f7603b --- /dev/null +++ b/tinyagentos/store_signing.py @@ -0,0 +1,211 @@ +"""Store manifest signing — Ed25519 signatures for catalog integrity. + +Mirrors the hub identity pattern: an Ed25519 keypair is generated on first +use and persisted to disk. Every catalog manifest is signed at load time; +the install-v2 endpoint verifies the signature before allowing an install +to proceed, so a compromised catalog entry (post-boot tampering, MITM, +supply-chain injection) is caught before any script or image is pulled. + +The public key is exposed via ``GET /api/store/signing-pubkey`` so clients +and auditing tools can verify signatures independently. + +Design decisions (see #647): + +- **One signing key per taOS instance.** Generated on first boot; the + private key never leaves the node. This matches the self-hosted model: + each instance trusts its own catalog. A future shared-catalog model + (e.g. a taOS App Store) would use a network-fetched public key. + +- **Signatures live in the manifest YAML.** A ``_signature`` field at the + root of the manifest holds the hex-encoded Ed25519 signature over the + canonical bytes of the manifest *with that field stripped*. At load + time, the registry strips ``_signature``, computes the signature, and + stores it in-memory. At verify time the same stripped view is used, so + flipping the signature doesn't change the bytes being verified. +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) + +logger = logging.getLogger(__name__) + +# Filename under / for the persisted keypair. +_KEYPAIR_FILE = "store_signing_key.json" + +# Field name in the manifest YAML that holds the detached signature. +SIGNATURE_FIELD = "_signature" + + +# --------------------------------------------------------------------------- +# Keypair lifecycle +# --------------------------------------------------------------------------- + + +def generate_signing_keypair() -> tuple[bytes, bytes]: + """Generate a fresh Ed25519 keypair. + + Returns ``(private_pem, public_pem)`` — PEM-encoded byte strings + without passphrase encryption (the private key is stored in a 0600 + file and the security model is local-tamper-detection, not secrecy). + """ + private = Ed25519PrivateKey.generate() + private_pem = private.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ) + public_pem = private.public_key().public_bytes( + encoding=Encoding.PEM, + format=PublicFormat.SubjectPublicKeyInfo, + ) + return private_pem, public_pem + + +def _enforce_permissions(keyfile: Path) -> None: + """Ensure the keyfile is owner-read/write only (0600). + + Called on every load so a migration, backup-restore, or manual + ``chmod`` cannot leave the private key world/group-readable. + """ + try: + st = keyfile.stat() + if (st.st_mode & 0o777) != 0o600: + os.chmod(keyfile, 0o600) + except OSError: + pass # non-fatal on exotic filesystems + + +def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: + """Return ``(private_pem, public_pem)``, minting the keypair on first use. + + Idempotent: once the keystore exists it is returned unchanged, so the + instance keeps the same signing identity across restarts. + """ + keyfile = data_dir / _KEYPAIR_FILE + if keyfile.exists(): + try: + data = json.loads(keyfile.read_text()) + priv = data["private_pem"].encode() + pub = data["public_pem"].encode() + # Enforce restrictive permissions on every load so a + # migration / backup-restore / manual chmod cannot leave + # the private key world/group-readable. + _enforce_permissions(keyfile) + # Sanity-check: can we deserialize the key? + _load_private_key(priv) + return priv, pub + except (json.JSONDecodeError, KeyError, ValueError) as exc: + logger.warning( + "store signing keyfile corrupt (%s), regenerating", exc, + ) + + priv, pub = generate_signing_keypair() + keyfile.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps( + {"private_pem": priv.decode(), "public_pem": pub.decode()}, + ) + # Atomic write + restrictive permissions (mirrors hub identity). + tmp = keyfile.with_suffix(keyfile.suffix + ".tmp") + tmp.write_text(payload) + os.chmod(tmp, 0o600) + os.replace(tmp, keyfile) + logger.info("store signing keypair created at %s", keyfile) + return priv, pub + + +# --------------------------------------------------------------------------- +# Serialisation helpers +# --------------------------------------------------------------------------- + + +def _load_private_key(private_pem: bytes) -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes( + _raw_private_bytes(private_pem), + ) + + +def _load_public_key(public_pem: bytes) -> Ed25519PublicKey: + from cryptography.hazmat.primitives.serialization import load_pem_public_key + + key = load_pem_public_key(public_pem) + if not isinstance(key, Ed25519PublicKey): + raise TypeError(f"expected Ed25519PublicKey, got {type(key).__name__}") + return key + + +def _raw_private_bytes(private_pem: bytes) -> bytes: + """Extract the 32-byte raw seed from a PKCS8 PEM.""" + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + key = load_pem_private_key(private_pem, password=None) + if not isinstance(key, Ed25519PrivateKey): + raise TypeError(f"expected Ed25519PrivateKey, got {type(key).__name__}") + # Ed25519PrivateKey.private_bytes_raw() returns the 32-byte seed. + return key.private_bytes_raw() + + +def _canonical_manifest_bytes(manifest_dict: dict) -> bytes: + """Deterministic byte representation of a manifest for signing. + + Strips ``_signature`` if present, then serialises as canonical JSON + (sorted keys, no trailing whitespace, UTF-8). This is stable across + YAML load/save cycles as long as the YAML library does not re-order + keys or change scalar representations. + """ + stripped = {k: v for k, v in manifest_dict.items() if k != SIGNATURE_FIELD} + return json.dumps(stripped, sort_keys=True, ensure_ascii=False).encode("utf-8") + + +# --------------------------------------------------------------------------- +# Sign / verify +# --------------------------------------------------------------------------- + + +def sign_manifest(manifest_dict: dict, private_pem: bytes) -> str: + """Return a hex-encoded Ed25519 signature over *manifest_dict*. + + ``_signature`` is stripped before signing so the field can be embedded + in the same dict without creating a circular dependency. + """ + data = _canonical_manifest_bytes(manifest_dict) + key = _load_private_key(private_pem) + return key.sign(data).hex() + + +def verify_manifest_signature( + manifest_dict: dict, + signature_hex: str, + public_pem: bytes, +) -> bool: + """Verify an Ed25519 signature over *manifest_dict*. + + Returns ``True`` when the signature is valid. Returns ``False`` (never + raises) on a bad signature, a wrong key, or malformed hex — the caller + can treat verification failure as a hard block. + """ + if not signature_hex: + return False + try: + sig_bytes = bytes.fromhex(signature_hex) + data = _canonical_manifest_bytes(manifest_dict) + key = _load_public_key(public_pem) + key.verify(sig_bytes, data) + return True + except (ValueError, InvalidSignature): + return False + except Exception: + logger.exception("unexpected error during manifest verification") + return False From ac135b650ecaccfdd23ef630dd6132c74ccd7eb9 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:31:34 +0200 Subject: [PATCH 02/10] fix(store): address Kilo bot review (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.py: wrap sign_manifest() in try/except so a malformed signing key does not crash the entire catalog load; add logging import - app.py: wrap load_or_create_signing_keypair() in try/except OSError so a read-only data_dir does not prevent server startup - store_signing.py: enforce 0600 permissions on existing keyfile load (from round 1) - store_install.py: document threat model limitation; change pubkey endpoint 500→404 when unconfigured - tests: update pubkey endpoint test to expect 404 --- tinyagentos/app.py | 11 ++++++++++- tinyagentos/registry.py | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tinyagentos/app.py b/tinyagentos/app.py index eb07aa492..1686f715c 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -258,7 +258,16 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> from tinyagentos.store_signing import load_or_create_signing_keypair - _store_priv, _store_pub = load_or_create_signing_keypair(data_dir) + _store_priv: bytes | None = None + _store_pub: bytes | None = None + try: + _store_priv, _store_pub = load_or_create_signing_keypair(data_dir) + except OSError: + logger.warning( + "store signing keypair could not be created (data_dir=%s may be " + "read-only) — catalog signatures will not be available", + data_dir, + ) registry = AppRegistry( catalog_dir=catalog_dir, installed_path=installed_path, signing_key=_store_priv, ) diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index 31ce22920..aeb2b26f1 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import logging import threading from dataclasses import dataclass, field from enum import Enum @@ -9,6 +10,8 @@ import yaml +logger = logging.getLogger(__name__) + class AppState(str, Enum): """Possible states for an installed app.""" @@ -131,9 +134,15 @@ def _load_catalog(self) -> None: if self._signing_key is not None: from tinyagentos.store_signing import sign_manifest - sig = sign_manifest(raw_dict, self._signing_key) - signatures[catalog[-1].id] = sig - manifest_dicts[catalog[-1].id] = raw_dict + try: + sig = sign_manifest(raw_dict, self._signing_key) + signatures[catalog[-1].id] = sig + manifest_dicts[catalog[-1].id] = raw_dict + except Exception: + logger.exception( + "failed to sign manifest %s — catalog load continues unsigned", + catalog[-1].id, + ) except (yaml.YAMLError, KeyError): pass # skip invalid manifests # Single atomic assignment: readers either see the old list or the fully built one. From 18524fd47d57b0cda611ee8b21dc5495bf3959a6 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:14:06 +0200 Subject: [PATCH 03/10] fix(store): resolve Ed25519 signing review issues (Kilo CRITICAL + nits) - store_signing.py: atomic keyfile creation with O_CREAT|O_EXCL+0o600, derive public key from loaded private key instead of trusting data['public_pem'], handle FileExistsError race by loading winner - registry.py: verify_manifest_signature now re-reads manifest.yaml from disk at install time, so post-boot catalog tampering is actually detected (previously compared two in-memory copies loaded at boot) - store_install.py: add _verify_manifest_for_install helper that distinguishes 'no stored signature' (graceful skip) from 'bad signature' (hard 403); verify backend manifests in install chain before running their installer - test_store_signing.py: fix flaky sig[:-2]+'ff' to XOR last byte so it always differs from original (~1/256 failure fixed) - test_routes_store_install.py: add end-to-end real-registry tamper test that mutates on-disk YAML and asserts 403 Tests: 13/13 store_signing pass. Install route fixture has pre-existing aiosqlite hang (CI will cover). --- tests/test_routes_store_install.py | 80 +++++++++++++++++++++ tests/test_store_signing.py | 7 +- tinyagentos/registry.py | 38 ++++++---- tinyagentos/routes/store_install.py | 106 +++++++++++++++++++++------- tinyagentos/store_signing.py | 51 +++++++++++-- 5 files changed, 238 insertions(+), 44 deletions(-) diff --git a/tests/test_routes_store_install.py b/tests/test_routes_store_install.py index 07af660b4..5d4b4ca0e 100644 --- a/tests/test_routes_store_install.py +++ b/tests/test_routes_store_install.py @@ -493,6 +493,86 @@ async def test_valid_signature_allows_install(self, client): body = resp.json() assert "chain" in body + @pytest.mark.asyncio + async def test_real_registry_detects_post_load_tampering(self, client): + """End-to-end test: a real AppRegistry re-reads the manifest from + disk at install time and rejects a manifest that was tampered with + after catalog load with 403. + + Unlike the mocked tests above that stub verify_manifest_signature, + this exercises the full path: sign-at-load, mutate-the-file, + verify-at-install. + """ + import tempfile + from pathlib import Path + + from tinyagentos.registry import AppRegistry + from tinyagentos.store_signing import generate_signing_keypair + + # 1. Create a catalog directory with one service manifest on disk. + catalog_dir = Path(tempfile.mkdtemp()) + svc_dir = catalog_dir / "services" / "test-svc" + svc_dir.mkdir(parents=True) + manifest_path = svc_dir / "manifest.yaml" + manifest_path.write_text( + "id: test-svc\n" + "name: Test Service\n" + "type: service\n" + "version: \"1.0\"\n" + "install:\n" + " method: download\n", + ) + + # 2. Build a real AppRegistry with a signing key. + priv, pub = generate_signing_keypair() + installed_path = Path(tempfile.mkstemp(suffix=".json")[1]) + reg = AppRegistry( + catalog_dir=catalog_dir, + installed_path=installed_path, + signing_key=priv, + ) + # Load the catalog to populate _signatures. + reg._ensure_loaded() + assert reg.get_signature("test-svc") is not None, ( + "expected a stored signature for test-svc" + ) + + # 3. Wire the real registry into the app state. + client._transport.app.state.registry = reg + client._transport.app.state.store_signing_pubkey = pub + client._transport.app.state.installed_apps = _make_installed_apps() + + # 4. Before tampering: the signature should verify and the install + # proceeds past the gate. (The legacy installer may fail because + # there is no real download URL, but the HTTP status is NOT 403.) + resp = await client.post("/api/store/install-v2", json={ + "manifest_id": "test-svc", + }) + assert resp.status_code != 403, ( + f"expected install to pass the signing gate, got 403: {resp.json()}" + ) + + # 5. Tamper with the manifest on disk. + manifest_path.write_text( + "id: test-svc\n" + "name: EVIL Service\n" + "type: service\n" + "version: \"1.0\"\n" + "install:\n" + " method: download\n", + ) + + # 6. Now the install MUST be rejected with 403. + resp = await client.post("/api/store/install-v2", json={ + "manifest_id": "test-svc", + }) + assert resp.status_code == 403, ( + f"expected 403 after tampering, got {resp.status_code}: {resp.json()}" + ) + body = resp.json() + assert body["error"] == "manifest signature verification failed" + assert "install_id" in body + @pytest.mark.asyncio async def test_no_signing_key_skips_verification(self, client): """When store_signing_pubkey is not set, the signing gate is diff --git a/tests/test_store_signing.py b/tests/test_store_signing.py index 6b503968e..8c3929d2c 100644 --- a/tests/test_store_signing.py +++ b/tests/test_store_signing.py @@ -46,8 +46,11 @@ def test_verify_valid_signature(self): def test_verify_tampered_signature(self): sig = sign_manifest(self.manifest, self.priv) - # Flip a byte in the signature - bad_sig = sig[:-2] + "ff" + # Flip the last byte so it ALWAYS differs from the original. + # sig[:-2]+"ff" fails ~1/256 of the time when the last byte + # already happens to be "ff". + last_byte = int(sig[-2:], 16) ^ 0x01 + bad_sig = sig[:-2] + f"{last_byte:02x}" assert not verify_manifest_signature(self.manifest, bad_sig, self.pub) def test_verify_tampered_manifest(self): diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index aeb2b26f1..cee01390d 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -177,24 +177,38 @@ def get_manifest_dict(self, app_id: str) -> dict | None: def verify_manifest_signature(self, app_id: str, public_pem: bytes) -> bool: """Re-verify the stored signature for *app_id* against *public_pem*. - Returns False when the manifest has no signature or verification fails. - This is called by install-v2 to detect post-boot catalog tampering. - - When the catalog was loaded without a signing key (e.g. a test - instance or a deployment that hasn't enabled signing yet), - ``install-v2`` skips this gate entirely by checking - ``store_signing_pubkey`` on app state — so this method's False - return does not inadvertently block installs in that case. + **Re-reads the manifest from disk at verify time**, then checks the + stored Ed25519 signature (computed at catalog-load time) against the + canonical bytes of the current on-disk YAML. This detects post-boot + catalog tampering — an attacker who modifies ``manifest.yaml`` after + the server started will produce a mismatch and the install is blocked. + + Returns ``True`` when the on-disk manifest matches the stored + signature. Returns ``False`` when no signature was stored for this + app_id **or** when the current on-disk manifest does not verify + against the stored signature (i.e. the manifest was tampered with). + + Callers MUST check ``get_signature(app_id)`` to distinguish the two + cases: ``None`` = never signed (graceful skip), non-``None`` + + ``False`` = tampered (hard block). """ self._ensure_loaded() - manifest_dict = self._manifest_dicts.get(app_id) sig = self._signatures.get(app_id) - if manifest_dict is None or sig is None: - # No signature stored (e.g. signing key not available at load time). + if sig is None: + return False + manifest = self.get(app_id) + if manifest is None or manifest.manifest_dir is None: + return False + manifest_path = manifest.manifest_dir / "manifest.yaml" + if not manifest_path.exists(): + return False + try: + on_disk = yaml.safe_load(manifest_path.read_text()) + except (yaml.YAMLError, OSError): return False from tinyagentos.store_signing import verify_manifest_signature - return verify_manifest_signature(manifest_dict, sig, public_pem) + return verify_manifest_signature(on_disk, sig, public_pem) def _read_installed(self) -> list[dict]: if not self.installed_path.exists(): diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 500acfcdb..08b2439e5 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -204,6 +204,40 @@ def _registry_get(registry, app_id: str): return registry.get(app_id) +def _verify_manifest_for_install( + manifest_id: str, + registry, + store_signing_pubkey: bytes | None, +) -> tuple[bool, str | None]: + """Verify a manifest's Ed25519 signature before installing. + + Returns ``(allowed, error_detail)``: + - ``(True, None)`` — proceed with install. + - ``(False, reason)`` — block install with the given error string. + + When the signing pubkey is not configured, the gate is skipped (allowed). + When a manifest has no stored signature (e.g. it was catalog-loaded + before signing was enabled), the gate is also skipped (allowed) rather + than returning a hard 403 — the absence of a signature is not evidence + of tampering. + """ + if store_signing_pubkey is None or registry is None: + return True, None + if not hasattr(registry, "verify_manifest_signature"): + return True, None + + stored_sig = registry.get_signature(manifest_id) + if stored_sig is None: + # Never signed — manifest was loaded before signing was enabled. + # Skip the gate; absence of a signature is not evidence of tampering. + return True, None + + if not registry.verify_manifest_signature(manifest_id, store_signing_pubkey): + return False, "manifest signature verification failed" + + return True, None + + async def _install_agent_framework( request: Request, manifest, app_id: str, meta: dict, body: dict, ) -> JSONResponse: @@ -700,33 +734,38 @@ async def install_app(request: Request): return await _legacy_install(request, body, manifest_id, target_remote) # Code-signing gate (#647): verify the catalog manifest's Ed25519 - # signature against the store signing public key. A manifest that was - # tampered with on disk after the server signed it at boot will fail - # here, blocking any install that would pull untrusted scripts or images. + # signature against the store signing public key. Re-reads the + # manifest from disk at install time so post-boot tampering is + # detected. Manifests that were catalog-loaded without a signing + # key configured are allowed through — no stored signature means + # no tampering evidence. # # Threat model: this protects against post-boot *catalog* tampering - # (an attacker modifying a manifest on disk while the server is running). - # It does NOT protect against an attacker who can also replace the - # signing keyfile and then restart the server — that is the OS-level - # trust boundary. For defence-in-depth, deploy the keyfile on a - # read-only filesystem or use a hardware-backed key store. + # (an attacker modifying a manifest on disk while the server is + # running). It does NOT protect against an attacker who can also + # replace the signing keyfile and then restart the server — that is + # the OS-level trust boundary. For defence-in-depth, deploy the + # keyfile on a read-only filesystem or use a hardware-backed key + # store. _store_pub = getattr(request.app.state, "store_signing_pubkey", None) - if _store_pub is not None and registry is not None and hasattr(registry, "verify_manifest_signature"): - if not registry.verify_manifest_signature(manifest_id, _store_pub): - progress.finish( - install_id, success=False, - error="manifest signature verification failed — the catalog may have been tampered with", - ) - return JSONResponse( - { - "error": "manifest signature verification failed", - "detail": "The catalog manifest for this app failed signature verification. " - "The app may have been tampered with. Rebuild the catalog or " - "reinstall the app from a trusted source.", - "install_id": install_id, - }, - status_code=403, - ) + verified, verify_err = _verify_manifest_for_install( + manifest_id, registry, _store_pub, + ) + if not verified: + progress.finish( + install_id, success=False, + error=verify_err or "manifest signature verification failed — the catalog may have been tampered with", + ) + return JSONResponse( + { + "error": verify_err or "manifest signature verification failed", + "detail": "The catalog manifest for this app failed signature verification. " + "The app may have been tampered with. Rebuild the catalog or " + "reinstall the app from a trusted source.", + "install_id": install_id, + }, + status_code=403, + ) # Non-commercial weights gate (#169): a manifest's code license (MIT etc.) # can be permissive while the model weights it downloads are not (e.g. @@ -818,6 +857,25 @@ async def install_app(request: Request): {"error": f"backend {result.backend_id!r} has no install.method", "install_id": install_id}, status_code=500, ) + # Verify the backend manifest's signature before running its installer. + # A backend manifest that was tampered with after catalog load would + # run an untrusted script/image — reject it here. + be_verified, be_verify_err = _verify_manifest_for_install( + result.backend_id, registry, _store_pub, + ) + if not be_verified: + progress.finish( + install_id, success=False, + error=be_verify_err or "backend manifest signature verification failed", + ) + return JSONResponse( + { + "error": be_verify_err or "backend manifest signature verification failed", + "detail": f"The backend manifest for {result.backend_id!r} failed signature verification.", + "install_id": install_id, + }, + status_code=403, + ) backend_installer = get_installer(backend_method) be_result = await backend_installer.install( result.backend_id, diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index 335f7603b..721ed6d99 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -99,13 +99,19 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: try: data = json.loads(keyfile.read_text()) priv = data["private_pem"].encode() - pub = data["public_pem"].encode() # Enforce restrictive permissions on every load so a # migration / backup-restore / manual chmod cannot leave # the private key world/group-readable. _enforce_permissions(keyfile) - # Sanity-check: can we deserialize the key? - _load_private_key(priv) + # Derive the public key from the loaded private key so + # a keyfile whose public_pem was replaced still yields + # the correct keypair (the private key is the root of + # trust, not its companion field). + key = _load_private_key(priv) + pub = key.public_key().public_bytes( + encoding=Encoding.PEM, + format=PublicFormat.SubjectPublicKeyInfo, + ) return priv, pub except (json.JSONDecodeError, KeyError, ValueError) as exc: logger.warning( @@ -117,10 +123,43 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: payload = json.dumps( {"private_pem": priv.decode(), "public_pem": pub.decode()}, ) - # Atomic write + restrictive permissions (mirrors hub identity). + + # Atomic creation with exclusive open + restrictive permissions. + # O_CREAT|O_EXCL guarantees at most one process wins the race; + # the loser loads the winner's key instead of overwriting it. + # The file descriptor starts with mode 0o600 so there is never + # a world-readable window — no separate chmod call is needed. + import os as _os_module + import time as _time + tmp = keyfile.with_suffix(keyfile.suffix + ".tmp") - tmp.write_text(payload) - os.chmod(tmp, 0o600) + try: + fd = _os_module.open(tmp, _os_module.O_CREAT | _os_module.O_EXCL | _os_module.O_WRONLY, 0o600) + except FileExistsError: + # Another process is creating the keypair. Wait for the + # winner to promote tmp→keyfile, then load its result. + for _ in range(30): # up to 3 s + _time.sleep(0.1) + if keyfile.exists(): + return load_or_create_signing_keypair(data_dir) + # After a reasonable wait the keyfile still does not exist. + # Remove the stale tmp (the competing process may have + # crashed or is stuck) and create our own. + try: + tmp.unlink(missing_ok=True) + except OSError: + pass + try: + fd = _os_module.open(tmp, _os_module.O_CREAT | _os_module.O_EXCL | _os_module.O_WRONLY, 0o600) + except FileExistsError: + if keyfile.exists(): + return load_or_create_signing_keypair(data_dir) + raise + # Write and atomically promote. + try: + _os_module.write(fd, payload.encode("utf-8")) + finally: + _os_module.close(fd) os.replace(tmp, keyfile) logger.info("store signing keypair created at %s", keyfile) return priv, pub From 147d6be03e7e28e919749ac2680bcbe94fdf19b9 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:48:15 +0200 Subject: [PATCH 04/10] fix(store): unique retry tmp path in signing keypair creation + remove redundant os import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileExistsError fallback now uses a PID-unique tmp path on retry so it never collides with a still-running winner's tmp file. - Added an early keyfile.exists() check after the 3s wait (the competing process may have completed os.replace by then). - Removed local 'import os as _os_module' and 'import time as _time' — os was already a top-level import and time is now imported at module level. - Replaced all _os_module.* and _time.* references with os.* and time.* respectively. Fixes: Kilo WARNING (store_signing.py:153) + SUGGESTION (line 132) --- tinyagentos/store_signing.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index 721ed6d99..cb9b665da 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -29,6 +29,7 @@ import json import logging import os +import time from pathlib import Path from cryptography.exceptions import InvalidSignature @@ -129,17 +130,14 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: # the loser loads the winner's key instead of overwriting it. # The file descriptor starts with mode 0o600 so there is never # a world-readable window — no separate chmod call is needed. - import os as _os_module - import time as _time - tmp = keyfile.with_suffix(keyfile.suffix + ".tmp") try: - fd = _os_module.open(tmp, _os_module.O_CREAT | _os_module.O_EXCL | _os_module.O_WRONLY, 0o600) + fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) except FileExistsError: # Another process is creating the keypair. Wait for the # winner to promote tmp→keyfile, then load its result. for _ in range(30): # up to 3 s - _time.sleep(0.1) + time.sleep(0.1) if keyfile.exists(): return load_or_create_signing_keypair(data_dir) # After a reasonable wait the keyfile still does not exist. @@ -149,17 +147,22 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: tmp.unlink(missing_ok=True) except OSError: pass + if keyfile.exists(): + return load_or_create_signing_keypair(data_dir) + # Retry with a unique tmp path so we never collide with a + # still-running winner that hasn't called os.replace yet. + tmp = keyfile.with_suffix(f"{keyfile.suffix}.tmp.{os.getpid()}") try: - fd = _os_module.open(tmp, _os_module.O_CREAT | _os_module.O_EXCL | _os_module.O_WRONLY, 0o600) + fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) except FileExistsError: if keyfile.exists(): return load_or_create_signing_keypair(data_dir) raise # Write and atomically promote. try: - _os_module.write(fd, payload.encode("utf-8")) + os.write(fd, payload.encode("utf-8")) finally: - _os_module.close(fd) + os.close(fd) os.replace(tmp, keyfile) logger.info("store signing keypair created at %s", keyfile) return priv, pub From 52fc402c3a0b37da2031d37726fe8f624864c81c Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:05:02 +0200 Subject: [PATCH 05/10] fix(store): address 5 security findings from jaylfc review of #1924 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. store_signing.py: enforce 0o600 BEFORE reading keyfile (umask bypass) 2. registry.py: fail-open for unsigned manifests, document policy, add set_signing_key() 3. store_install.py: TOCTOU guard — re-verify manifest from disk at execution time 4. app.py: defer keypair loading to lifespan (read-only data_dir no longer bricks startup) 5. store_install.py: 500→422 for backend without installer mapping Also fix test_real_registry_detects_post_load_tampering empty installed_path init. --- tests/test_routes_store_install.py | 1 + tinyagentos/app.py | 30 +++++++++++------ tinyagentos/registry.py | 35 +++++++++++++++----- tinyagentos/routes/store_install.py | 50 ++++++++++++++++++++++++++++- tinyagentos/store_signing.py | 10 +++--- 5 files changed, 104 insertions(+), 22 deletions(-) diff --git a/tests/test_routes_store_install.py b/tests/test_routes_store_install.py index 5d4b4ca0e..9d4e086a0 100644 --- a/tests/test_routes_store_install.py +++ b/tests/test_routes_store_install.py @@ -526,6 +526,7 @@ async def test_real_registry_detects_post_load_tampering(self, client): # 2. Build a real AppRegistry with a signing key. priv, pub = generate_signing_keypair() installed_path = Path(tempfile.mkstemp(suffix=".json")[1]) + installed_path.write_text("[]") # initialise with valid JSON reg = AppRegistry( catalog_dir=catalog_dir, installed_path=installed_path, diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 1686f715c..5cda2a164 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -258,18 +258,14 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> from tinyagentos.store_signing import load_or_create_signing_keypair + # Keypair loading is deferred to the lifespan so a read-only data_dir + # does not block create_app(). The registry starts with signing_key=None; + # the lifespan will attempt to load the keypair and call + # registry.set_signing_key() if it succeeds. _store_priv: bytes | None = None _store_pub: bytes | None = None - try: - _store_priv, _store_pub = load_or_create_signing_keypair(data_dir) - except OSError: - logger.warning( - "store signing keypair could not be created (data_dir=%s may be " - "read-only) — catalog signatures will not be available", - data_dir, - ) registry = AppRegistry( - catalog_dir=catalog_dir, installed_path=installed_path, signing_key=_store_priv, + catalog_dir=catalog_dir, installed_path=installed_path, signing_key=None, ) from tinyagentos.agent_registry_store import AgentRegistryStore, load_or_create_signing_keypair @@ -1598,6 +1594,22 @@ async def dispatch(self, request, call_next): app.state.fallback = fallback app.state.scheduler = scheduler app.state.registry = registry + # Load the store signing keypair lazily here in the lifespan, not in + # create_app(), so a read-only data_dir does not brick startup. + # When the keypair cannot be loaded (missing cryptography, unwritable + # data_dir), signing is simply disabled — the install gate falls + # through to unsigned (fail-open) and the pubkey endpoint returns 404. + _store_pub: bytes | None = None + try: + _store_priv, _store_pub = load_or_create_signing_keypair(data_dir) + if _store_priv is not None: + registry.set_signing_key(_store_priv) + except OSError: + logger.warning( + "store signing keypair could not be created (data_dir=%s may be " + "read-only) — catalog signatures will not be available", + data_dir, + ) app.state.store_signing_pubkey = _store_pub app.state.hardware_profile = hardware_profile app.state.cluster_manager = cluster_manager diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index cee01390d..17c42ed66 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -154,6 +154,16 @@ def reload(self) -> None: with self._catalog_lock: self._load_catalog() + def set_signing_key(self, key: bytes | None) -> None: + """Set (or clear) the signing key and reload the catalog. + + Call this after the keypair has been loaded (e.g. in the lifespan) + so the catalog is signed with the actual key. Passing ``None`` + disables signing. + """ + self._signing_key = key + self.reload() + def list_available(self, type_filter: str | None = None) -> list[AppManifest]: self._ensure_loaded() if type_filter: @@ -183,19 +193,28 @@ def verify_manifest_signature(self, app_id: str, public_pem: bytes) -> bool: catalog tampering — an attacker who modifies ``manifest.yaml`` after the server started will produce a mismatch and the install is blocked. - Returns ``True`` when the on-disk manifest matches the stored - signature. Returns ``False`` when no signature was stored for this - app_id **or** when the current on-disk manifest does not verify - against the stored signature (i.e. the manifest was tampered with). + Returns ``True`` when: - Callers MUST check ``get_signature(app_id)`` to distinguish the two - cases: ``None`` = never signed (graceful skip), non-``None`` + - ``False`` = tampered (hard block). + * the on-disk manifest matches the stored signature (valid), **or** + * no signature was stored for this app (unsigned — fail-open). + + Returns ``False`` only when a signature **was** stored but the + current on-disk manifest does not verify against it (tampered). + + The fail-open policy for unsigned manifests is intentional: the + absence of a signature is not evidence of tampering, and rejecting + unsigned manifests would block every catalog entry that predates + the signing feature. Once a manifest is signed, however, any + mismatch is treated as tampering and blocked with 403. """ self._ensure_loaded() sig = self._signatures.get(app_id) if sig is None: - return False + # Never signed — fail-open. The absence of a signature is + # not evidence of tampering (the manifest may predate the + # signing feature). Callers that need to distinguish + # unsigned from verified can check get_signature(app_id). + return True manifest = self.get(app_id) if manifest is None or manifest.manifest_dir is None: return False diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 08b2439e5..0a0b0d235 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -12,6 +12,7 @@ import asyncio import logging from dataclasses import asdict +from pathlib import Path from urllib.parse import urlparse from fastapi import APIRouter, Request @@ -767,6 +768,53 @@ async def install_app(request: Request): status_code=403, ) + # TOCTOU guard: the signing gate above verified the signature against + # the on-disk manifest, but the install below uses the in-memory + # manifest object loaded at boot. An attacker who swaps the on-disk + # file between verification and execution could bypass the gate. + # Re-read from disk and compare critical fields — if they differ, + # the manifest was modified after verification and the install is + # blocked. + _manifest_dir = getattr(manifest, "manifest_dir", None) + if _manifest_dir is not None and isinstance(_manifest_dir, Path): + disk_path = _manifest_dir / "manifest.yaml" + try: + import yaml as _yaml + on_disk = _yaml.safe_load(disk_path.read_text()) if disk_path.exists() else None + except Exception: + on_disk = None + if on_disk is not None: + # Compare the fields that affect install behaviour. + _disk_id = on_disk.get("id", "") + _disk_type = on_disk.get("type", "") + _disk_version = on_disk.get("version", "") + _disk_install = on_disk.get("install", {}) or {} + _disk_variants = on_disk.get("variants") or [] + _mem_install = getattr(manifest, "install", {}) or {} + if ( + _disk_id != manifest.id + or _disk_type != getattr(manifest, "type", "") + or _disk_version != getattr(manifest, "version", "") + or _disk_install.get("method") != (_mem_install.get("method") if isinstance(_mem_install, dict) else None) + or len(_disk_variants) != len(getattr(manifest, "variants", []) or []) + ): + progress.finish( + install_id, success=False, + error="manifest modified between signature verification and install", + ) + return JSONResponse( + { + "error": "manifest modified between signature verification and install", + "detail": ( + "The manifest on disk differs from the version that was " + "verified. This may indicate post-verification tampering. " + "Rebuild the catalog or reinstall the app from a trusted source." + ), + "install_id": install_id, + }, + status_code=403, + ) + # Non-commercial weights gate (#169): a manifest's code license (MIT etc.) # can be permissive while the model weights it downloads are not (e.g. # musicgen pulls Meta's CC-BY-NC 4.0 weights). Block the install until the @@ -923,7 +971,7 @@ async def install_app(request: Request): ), "install_id": install_id, }, - status_code=500, + status_code=422, ) model_installer = get_installer(install_method) install_config = dict(getattr(manifest, "install", None) or {}) diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index cb9b665da..aba428498 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -97,13 +97,15 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: """ keyfile = data_dir / _KEYPAIR_FILE if keyfile.exists(): + # Enforce restrictive permissions BEFORE reading so a key + # written under a loose umask is repaired before the bytes + # touch process memory. A migration / backup-restore / + # manual chmod cannot leave the private key world/group- + # readable across a restart. + _enforce_permissions(keyfile) try: data = json.loads(keyfile.read_text()) priv = data["private_pem"].encode() - # Enforce restrictive permissions on every load so a - # migration / backup-restore / manual chmod cannot leave - # the private key world/group-readable. - _enforce_permissions(keyfile) # Derive the public key from the loaded private key so # a keyfile whose public_pem was replaced still yields # the correct keypair (the private key is the root of From 60eefc23933125fe9d73a738469f7503579798c0 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:09:59 +0200 Subject: [PATCH 06/10] =?UTF-8?q?fix(store):=20resolve=20Kilo=20WARNING=20?= =?UTF-8?q?=E2=80=94=20remove=20tmp.unlink=20race=20in=20keygen=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the shared tmp name entirely after a FileExistsError contention timeout instead of unlinking it. Unlinking the shared tmp could race with a still-running winner process, causing a FileNotFoundError crash for the winner or inconsistent on-disk state. Jump directly to the unique PID-based tmp path, which is safe because every process gets its own name. --- tinyagentos/store_signing.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index aba428498..9edc32fb4 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -143,16 +143,12 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: if keyfile.exists(): return load_or_create_signing_keypair(data_dir) # After a reasonable wait the keyfile still does not exist. - # Remove the stale tmp (the competing process may have - # crashed or is stuck) and create our own. - try: - tmp.unlink(missing_ok=True) - except OSError: - pass + # The shared tmp is contested — skip straight to a unique + # PID-based name instead of unlinking (which could race with + # a still-running winner and crash it or leave inconsistent + # on-disk state). if keyfile.exists(): return load_or_create_signing_keypair(data_dir) - # Retry with a unique tmp path so we never collide with a - # still-running winner that hasn't called os.replace yet. tmp = keyfile.with_suffix(f"{keyfile.suffix}.tmp.{os.getpid()}") try: fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) From ba9e17f9d1a0e93046e3175cd99e3e39bb1ed033 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:47:04 +0200 Subject: [PATCH 07/10] =?UTF-8?q?fix(security):=20address=20Kilo=20SUGGEST?= =?UTF-8?q?IONS=20=E2=80=94=20fail-closed=20verify=5Fmanifest=5Fsignature?= =?UTF-8?q?=20+=20second=20signature=20re-verify=20TOCTOU=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.py: Change verify_manifest_signature from fail-open to fail-closed (returns False for unsigned manifests). The install gate (_verify_manifest_for_install) already short-circuits for unsigned manifests via get_signature() check, so the install path is unaffected. This prevents future callers from accidentally allowing unsigned manifests through. - store_install.py: Replace the TOCTOU field-comparison guard with a second signature re-verify against the re-read disk bytes. This is more robust: catches any change (not just the whitelisted fields), does not false-positive on legitimate catalog reloads, and aligns with the existing Ed25519 trust model. Fixes the 2 remaining Kilo SUGGESTIONS on #2023. --- tinyagentos/registry.py | 40 +++++++++-------- tinyagentos/routes/store_install.py | 69 +++++++++++++++-------------- 2 files changed, 57 insertions(+), 52 deletions(-) diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index 17c42ed66..62157e212 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -193,28 +193,32 @@ def verify_manifest_signature(self, app_id: str, public_pem: bytes) -> bool: catalog tampering — an attacker who modifies ``manifest.yaml`` after the server started will produce a mismatch and the install is blocked. - Returns ``True`` when: - - * the on-disk manifest matches the stored signature (valid), **or** - * no signature was stored for this app (unsigned — fail-open). - - Returns ``False`` only when a signature **was** stored but the - current on-disk manifest does not verify against it (tampered). - - The fail-open policy for unsigned manifests is intentional: the - absence of a signature is not evidence of tampering, and rejecting - unsigned manifests would block every catalog entry that predates - the signing feature. Once a manifest is signed, however, any - mismatch is treated as tampering and blocked with 403. + Returns ``True`` only when the on-disk manifest successfully verifies + against the stored Ed25519 signature. + + Returns ``False`` when: + + * no signature was stored for this app (unsigned — fail-closed), **or** + * the on-disk manifest does not verify against the stored signature + (tampered). + + This primitive is fail-closed on purpose: a future caller that does + ``if not registry.verify_manifest_signature(...)`` gets the safe + default. Callers that need a fail-open policy for unsigned manifests + (e.g. the install gate, which must not block catalog entries that + predate the signing feature) must check ``get_signature(app_id)`` + first and short-circuit before calling this method. See + ``_verify_manifest_for_install`` in ``routes/store_install.py`` for + the canonical fail-open pattern. """ self._ensure_loaded() sig = self._signatures.get(app_id) if sig is None: - # Never signed — fail-open. The absence of a signature is - # not evidence of tampering (the manifest may predate the - # signing feature). Callers that need to distinguish - # unsigned from verified can check get_signature(app_id). - return True + # Never signed — fail-closed. The absence of a signature + # means there is nothing to verify against. Callers that + # want a fail-open policy for unsigned manifests must check + # get_signature() first. + return False manifest = self.get(app_id) if manifest is None or manifest.manifest_dir is None: return False diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 0a0b0d235..9881ad597 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -772,11 +772,17 @@ async def install_app(request: Request): # the on-disk manifest, but the install below uses the in-memory # manifest object loaded at boot. An attacker who swaps the on-disk # file between verification and execution could bypass the gate. - # Re-read from disk and compare critical fields — if they differ, - # the manifest was modified after verification and the install is - # blocked. + # Re-read from disk and re-verify the signature — if it no longer + # verifies, the manifest was modified after the gate check and the + # install is blocked. _manifest_dir = getattr(manifest, "manifest_dir", None) - if _manifest_dir is not None and isinstance(_manifest_dir, Path): + if ( + _manifest_dir is not None + and isinstance(_manifest_dir, Path) + and _store_pub is not None + and registry is not None + and hasattr(registry, "verify_manifest_signature") + ): disk_path = _manifest_dir / "manifest.yaml" try: import yaml as _yaml @@ -784,36 +790,31 @@ async def install_app(request: Request): except Exception: on_disk = None if on_disk is not None: - # Compare the fields that affect install behaviour. - _disk_id = on_disk.get("id", "") - _disk_type = on_disk.get("type", "") - _disk_version = on_disk.get("version", "") - _disk_install = on_disk.get("install", {}) or {} - _disk_variants = on_disk.get("variants") or [] - _mem_install = getattr(manifest, "install", {}) or {} - if ( - _disk_id != manifest.id - or _disk_type != getattr(manifest, "type", "") - or _disk_version != getattr(manifest, "version", "") - or _disk_install.get("method") != (_mem_install.get("method") if isinstance(_mem_install, dict) else None) - or len(_disk_variants) != len(getattr(manifest, "variants", []) or []) - ): - progress.finish( - install_id, success=False, - error="manifest modified between signature verification and install", - ) - return JSONResponse( - { - "error": "manifest modified between signature verification and install", - "detail": ( - "The manifest on disk differs from the version that was " - "verified. This may indicate post-verification tampering. " - "Rebuild the catalog or reinstall the app from a trusted source." - ), - "install_id": install_id, - }, - status_code=403, - ) + # Re-verify the signature against the just-read bytes. + # This catches any change — not just the narrow set of fields + # the old comparison whitelisted — and does not false-positive + # on legitimate catalog reloads (which update signatures too). + stored_sig = registry.get_signature(manifest_id) + if stored_sig is not None: + from tinyagentos.store_signing import verify_manifest_signature as _verify_sig + if not _verify_sig(on_disk, stored_sig, _store_pub): + progress.finish( + install_id, success=False, + error="manifest modified between signature verification and install", + ) + return JSONResponse( + { + "error": "manifest modified between signature verification and install", + "detail": ( + "The manifest on disk was modified after the initial " + "signature verification. This may indicate post-verification " + "tampering. Rebuild the catalog or reinstall the app from " + "a trusted source." + ), + "install_id": install_id, + }, + status_code=403, + ) # Non-commercial weights gate (#169): a manifest's code license (MIT etc.) # can be permissive while the model weights it downloads are not (e.g. From 5263a0a0da836eabbdfc0c1c1b370e488d1adb9b Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:44:34 +0200 Subject: [PATCH 08/10] fix(store): update test assertions from 500 to 422 for unknown backend PR #2023 changed the _BACKEND_TO_METHOD lookup failure from 500 to 422. Update both test_unknown_backend_returns_500 assertions and docstrings: - tests/routes/test_store_install_v2.py::TestBackendToMethodMapping - tests/test_routes_store_install.py::TestInstallV2 --- tests/routes/test_store_install_v2.py | 4 ++-- tests/test_routes_store_install.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/routes/test_store_install_v2.py b/tests/routes/test_store_install_v2.py index 13ae89b77..e3d18fcb9 100644 --- a/tests/routes/test_store_install_v2.py +++ b/tests/routes/test_store_install_v2.py @@ -178,7 +178,7 @@ def test_all_entries_resolve_to_valid_installer_methods(self): @pytest.mark.asyncio async def test_unknown_backend_returns_500_not_exception(self, client, fake_registry): - """A backend_id absent from _BACKEND_TO_METHOD returns HTTP 500, not a traceback.""" + """A backend_id absent from _BACKEND_TO_METHOD returns HTTP 422, not a traceback.""" # Construct a manifest whose variant declares an unmapped backend. unknown_manifest = MagicMock() unknown_manifest.id = "test-model" @@ -224,7 +224,7 @@ async def test_unknown_backend_returns_500_not_exception(self, client, fake_regi "manifest_id": "test-model", "variant_id": "v1", }) - assert r.status_code == 500 + assert r.status_code == 422 assert "_BACKEND_TO_METHOD" in r.json()["error"] diff --git a/tests/test_routes_store_install.py b/tests/test_routes_store_install.py index 9d4e086a0..cce12a33d 100644 --- a/tests/test_routes_store_install.py +++ b/tests/test_routes_store_install.py @@ -338,7 +338,7 @@ async def test_model_install_failure_returns_500(self, client): @pytest.mark.asyncio async def test_unknown_backend_returns_500(self, client): - """A backend not in _BACKEND_TO_METHOD returns 500, not an exception.""" + """A backend not in _BACKEND_TO_METHOD returns 422, not an exception.""" manifest = _make_model_manifest(backend_id="totally-unknown-backend") reg = _make_registry(manifest) client._transport.app.state.registry = reg @@ -353,7 +353,7 @@ async def test_unknown_backend_returns_500(self, client): "manifest_id": "test-model", "variant_id": "v1", }) - assert resp.status_code == 500 + assert resp.status_code == 422 assert "_BACKEND_TO_METHOD" in resp.json()["error"] @pytest.mark.asyncio From b4519563123315ed60d0f77457be7a9356056d42 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:25:35 +0200 Subject: [PATCH 09/10] =?UTF-8?q?fix(store):=20fail-closed=20on=20sign=5Fm?= =?UTF-8?q?anifest=20failure=20=E2=80=94=20prevent=20install-gate=20bypass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sign_manifest raised, the exception was logged but no signature was stored. _verify_manifest_for_install saw None and short-circuited to (True, None) — allowing install with zero tamper protection for any manifest whose signing threw. An attacker inducing a signing failure for a target manifest could bypass the Ed25519 gate silently. Track signing failures separately in AppRegistry._signing_failures and expose had_signing_failure(). In _verify_manifest_for_install, check for a signing failure before the unsigned short-circuit, and block the install with a clear message when signing failed. Kilo: failures silently downgrade to unsigned Ref: PR #2027 review comment on tinyagentos/registry.py:141 --- tinyagentos/registry.py | 21 +++++++++++++++++++++ tinyagentos/routes/store_install.py | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/tinyagentos/registry.py b/tinyagentos/registry.py index 62157e212..f21092451 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -107,6 +107,11 @@ def __init__(self, catalog_dir: Path, installed_path: Path, signing_key: bytes | # Raw manifest dicts (stripped of _signature) keyed by app_id, used for # re-verifying at install time. self._manifest_dicts: dict[str, dict] = {} + # Manifests that failed signing during catalog load. An entry here + # means the install gate MUST block (fail-closed) — the signing + # failure could be a transient error OR evidence of a manifest that + # is somehow corrupt/malformed in a way the signer can't handle. + self._signing_failures: set[str] = set() self._catalog_lock = threading.Lock() def _ensure_loaded(self) -> None: @@ -121,6 +126,7 @@ def _load_catalog(self) -> None: catalog: list[AppManifest] = [] signatures: dict[str, str] = {} manifest_dicts: dict[str, dict] = {} + signing_failures: set[str] = set() for type_dir in ("agents", "models", "services", "plugins"): base = self.catalog_dir / type_dir if not base.exists(): @@ -143,12 +149,14 @@ def _load_catalog(self) -> None: "failed to sign manifest %s — catalog load continues unsigned", catalog[-1].id, ) + signing_failures.add(catalog[-1].id) except (yaml.YAMLError, KeyError): pass # skip invalid manifests # Single atomic assignment: readers either see the old list or the fully built one. self._catalog = catalog self._signatures = signatures self._manifest_dicts = manifest_dicts + self._signing_failures = signing_failures def reload(self) -> None: with self._catalog_lock: @@ -184,6 +192,19 @@ def get_manifest_dict(self, app_id: str) -> dict | None: self._ensure_loaded() return self._manifest_dicts.get(app_id) + def had_signing_failure(self, app_id: str) -> bool: + """Return True if *app_id* was loaded but signing failed. + + A signing failure means the manifest is present in the catalog but + its Ed25519 signature could not be computed (e.g. malformed content, + serialisation error). The install gate must block these manifests + rather than silently treating them as unsigned — an attacker who can + induce a signing failure for a target manifest would otherwise bypass + the entire tamper-protection gate. + """ + self._ensure_loaded() + return app_id in self._signing_failures + def verify_manifest_signature(self, app_id: str, public_pem: bytes) -> bool: """Re-verify the stored signature for *app_id* against *public_pem*. diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 9881ad597..8b035192c 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -229,6 +229,13 @@ def _verify_manifest_for_install( stored_sig = registry.get_signature(manifest_id) if stored_sig is None: + # Check for a signing failure first — a manifest that failed to + # sign must be blocked (fail-closed). An attacker who can induce + # a signing failure would otherwise bypass the tamper-protection + # gate entirely, since the None-signature short-circuit below + # would silently allow the install. + if hasattr(registry, "had_signing_failure") and registry.had_signing_failure(manifest_id): + return False, "manifest signing failed — catalog may be corrupt" # Never signed — manifest was loaded before signing was enabled. # Skip the gate; absence of a signature is not evidence of tampering. return True, None From e842727b6bd4fbe5a3d6d1cadf194e41df28f2cf Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:01:33 +0200 Subject: [PATCH 10/10] =?UTF-8?q?fix(store):=20resolve=20Kilo=20WARNING+SU?= =?UTF-8?q?GGESTION=20=E2=80=94=20date-safe=20canonicalisation=20+=20async?= =?UTF-8?q?=20TOCTOU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _canonical_manifest_bytes: add default=str to json.dumps so yaml.safe_load-produced date/datetime values don't cause signing failures on legitimate manifests (Kilo WARNING, registry.py:152) - TOCTOU re-verify: wrap disk read + Ed25519 verify in asyncio.to_thread to avoid blocking the event loop under load (Kilo SUGGESTION, store_install.py:796) --- tinyagentos/routes/store_install.py | 69 ++++++++++++++++------------- tinyagentos/store_signing.py | 4 +- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 8b035192c..38b4a2b9c 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -782,6 +782,9 @@ async def install_app(request: Request): # Re-read from disk and re-verify the signature — if it no longer # verifies, the manifest was modified after the gate check and the # install is blocked. + # + # Run via asyncio.to_thread to avoid blocking the event loop on disk + # I/O + Ed25519 verification under concurrent load. _manifest_dir = getattr(manifest, "manifest_dir", None) if ( _manifest_dir is not None @@ -790,38 +793,40 @@ async def install_app(request: Request): and registry is not None and hasattr(registry, "verify_manifest_signature") ): - disk_path = _manifest_dir / "manifest.yaml" - try: - import yaml as _yaml - on_disk = _yaml.safe_load(disk_path.read_text()) if disk_path.exists() else None - except Exception: - on_disk = None - if on_disk is not None: - # Re-verify the signature against the just-read bytes. - # This catches any change — not just the narrow set of fields - # the old comparison whitelisted — and does not false-positive - # on legitimate catalog reloads (which update signatures too). - stored_sig = registry.get_signature(manifest_id) - if stored_sig is not None: - from tinyagentos.store_signing import verify_manifest_signature as _verify_sig - if not _verify_sig(on_disk, stored_sig, _store_pub): - progress.finish( - install_id, success=False, - error="manifest modified between signature verification and install", - ) - return JSONResponse( - { - "error": "manifest modified between signature verification and install", - "detail": ( - "The manifest on disk was modified after the initial " - "signature verification. This may indicate post-verification " - "tampering. Rebuild the catalog or reinstall the app from " - "a trusted source." - ), - "install_id": install_id, - }, - status_code=403, - ) + + def _toctou_reverify(): + disk_path = _manifest_dir / "manifest.yaml" + try: + import yaml as _yaml + on_disk = _yaml.safe_load(disk_path.read_text()) if disk_path.exists() else None + except Exception: + on_disk = None + if on_disk is not None: + stored_sig = registry.get_signature(manifest_id) + if stored_sig is not None: + from tinyagentos.store_signing import verify_manifest_signature as _verify_sig + if not _verify_sig(on_disk, stored_sig, _store_pub): + return False + return True + + if not await asyncio.to_thread(_toctou_reverify): + progress.finish( + install_id, success=False, + error="manifest modified between signature verification and install", + ) + return JSONResponse( + { + "error": "manifest modified between signature verification and install", + "detail": ( + "The manifest on disk was modified after the initial " + "signature verification. This may indicate post-verification " + "tampering. Rebuild the catalog or reinstall the app from " + "a trusted source." + ), + "install_id": install_id, + }, + status_code=403, + ) # Non-commercial weights gate (#169): a manifest's code license (MIT etc.) # can be permissive while the model weights it downloads are not (e.g. diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index 9edc32fb4..212d7e83e 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -206,7 +206,9 @@ def _canonical_manifest_bytes(manifest_dict: dict) -> bytes: keys or change scalar representations. """ stripped = {k: v for k, v in manifest_dict.items() if k != SIGNATURE_FIELD} - return json.dumps(stripped, sort_keys=True, ensure_ascii=False).encode("utf-8") + return json.dumps( + stripped, sort_keys=True, ensure_ascii=False, default=str + ).encode("utf-8") # ---------------------------------------------------------------------------