From b635d2cbb1887d100c3206b70f5b62fca521b292 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/11] 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 86330e179..7955b14c3 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -256,7 +256,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") @@ -1490,6 +1496,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 93b6333fe..a2c61b015 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -703,6 +703,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 @@ -889,6 +918,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 fad47e31ce123434e74d58908e9e24a9b3858f11 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/11] 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 7955b14c3..ce8043bb4 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -259,7 +259,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 989ea471d54a20f8d3166f200eddadf261caa1ad 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/11] 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 a2c61b015..40175eee7 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: @@ -704,33 +738,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. @@ -822,6 +861,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 8e2f09c3d588e38c082c66128fae94b22381484c 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/11] 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 3d0b255b58ea15edef1be73625abbcb1f442cb78 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/11] 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 ce8043bb4..242eb912e 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -259,18 +259,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 @@ -1505,6 +1501,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 40175eee7..28adf6cec 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 @@ -771,6 +772,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 @@ -927,7 +975,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 9eae7a914da2b618e993df1cb79eff215ca0ac40 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/11] =?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 48391208de8531f319f6f87083b36541fe922a2c 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/11] =?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 28adf6cec..887114856 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -776,11 +776,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 @@ -788,36 +794,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 61949bd961a852d21cac49b8df3565ce8b0936a4 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:15:07 +0200 Subject: [PATCH 08/11] =?UTF-8?q?fix(security):=20address=20CodeRabbit=20f?= =?UTF-8?q?indings=20=E2=80=94=20aliasing,=20permissions,=20race,=20snapsh?= =?UTF-8?q?ot,=20signing=20failures,=20422=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 CodeRabbit findings fixed: store_signing.py: - Docstring: document detached in-memory signatures (not YAML) + post-load model - _enforce_permissions: fail-closed — raise PermissionError instead of swallowing OSError - Keypair creation race: use os.link for non-overwriting claim instead of os.replace app.py: - Import aliasing: alias store_signing.load_or_create_signing_keypair to prevent shadowing by agent_registry_store version - Move signing key init from create_app() into lifespan (before _startup_complete) registry.py: - _CatalogState: bundle catalog/signatures/manifest_dicts into one atomic snapshot, preventing TOCTOU across the 3 independent assignments - signing_failures: track manifests that failed sign_manifest so install gate can block them (not treat as merely unsigned) store_install.py: - _verify_manifest_for_install: check is_signing_failure() before allowing unsigned manifests through tests: - test_unknown_backend_returns_422: expect 422 (matches production code) --- tests/test_routes_store_install.py | 6 +-- tinyagentos/app.py | 39 +++++++------- tinyagentos/registry.py | 81 +++++++++++++++++++++++------ tinyagentos/routes/store_install.py | 16 +++++- tinyagentos/store_signing.py | 48 ++++++++++++----- 5 files changed, 137 insertions(+), 53 deletions(-) diff --git a/tests/test_routes_store_install.py b/tests/test_routes_store_install.py index 9d4e086a0..8f9ff287a 100644 --- a/tests/test_routes_store_install.py +++ b/tests/test_routes_store_install.py @@ -337,8 +337,8 @@ async def test_model_install_failure_returns_500(self, client): assert "model install failed" in body["error"] @pytest.mark.asyncio - async def test_unknown_backend_returns_500(self, client): - """A backend not in _BACKEND_TO_METHOD returns 500, not an exception.""" + async def test_unknown_backend_returns_422(self, client): + """A backend not in _BACKEND_TO_METHOD returns 422.""" 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 diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 242eb912e..6beb8feb2 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -257,7 +257,9 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> # auto-register loop; don't re-probe. installed_path = data_dir / "installed.json" - from tinyagentos.store_signing import load_or_create_signing_keypair + from tinyagentos.store_signing import ( + load_or_create_signing_keypair as load_or_create_store_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; @@ -270,6 +272,7 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> ) from tinyagentos.agent_registry_store import AgentRegistryStore, load_or_create_signing_keypair + agent_registry_store = AgentRegistryStore(data_dir / "agent_registry.db") agent_registry_keypair = load_or_create_signing_keypair(data_dir) @@ -1252,6 +1255,22 @@ async def _web_push_sender(row: dict) -> None: app.state.notif_vapid_keypair = None logger.warning("notif web-push disabled: VAPID keypair unavailable", exc_info=True) + # Load the store signing keypair — deferred to the lifespan so a + # read-only data_dir does not brick startup. When the keypair + # cannot be loaded, signing is silently disabled and the install + # gate skips signature verification. + try: + _store_priv, _store_pub = load_or_create_store_signing_keypair(data_dir) + if _store_priv is not None: + registry.set_signing_key(_store_priv) + app.state.store_signing_pubkey = _store_pub + except (OSError, PermissionError): + logger.warning( + "store signing keypair could not be created (data_dir=%s may be " + "read-only) — catalog signatures will not be available", + data_dir, + ) + # All startup init complete — allow requests through. app.state._startup_complete = True logger.info("startup complete — accepting requests") @@ -1501,23 +1520,7 @@ 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.store_signing_pubkey = None # set by lifespan 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 62157e212..0c374c485 100644 --- a/tinyagentos/registry.py +++ b/tinyagentos/registry.py @@ -93,34 +93,67 @@ def is_compatible(self, profile_id: str) -> bool: return False +@dataclass(frozen=True) +class _CatalogState: + """Immutable snapshot of the catalog at one point in time. + + The entire snapshot is atomically replaced on reload so no reader can + observe a mix of old and new state (e.g. a manifest from the new catalog + paired with an old signatures dict that lacks its signature). + """ + + catalog: list[AppManifest] + signatures: dict[str, str] + manifest_dicts: dict[str, dict] + signing_failures: frozenset[str] = frozenset() + + class AppRegistry: + """Catalog + signing registry for the app store. + + The catalog state (manifests, signatures, raw dicts) is bundled into a + single ``_CatalogState`` snapshot that is atomically replaced on reload. + This prevents TOCTOU bugs where a reader could see a mix of old and new + state (e.g. a new manifest from _catalog paired with an old _signatures + dict that is missing its signature). + """ + 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] = {} + # 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._state: _CatalogState | None = None self._catalog_lock = threading.Lock() + # -- backward-compat aliases so existing callers read through the snapshot -- + @property + def _catalog(self) -> list[AppManifest] | None: + return self._state.catalog if self._state is not None else None + + @property + def _signatures(self) -> dict[str, str]: + return self._state.signatures if self._state is not None else {} + + @property + def _manifest_dicts(self) -> dict[str, dict]: + return self._state.manifest_dicts if self._state is not None else {} + def _ensure_loaded(self) -> None: # Double-checked locking: cheap path when already loaded, lock only on first miss. - if self._catalog is not None: + if self._state is not None: return with self._catalog_lock: - if self._catalog is None: + if self._state is None: self._load_catalog() 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(): @@ -140,20 +173,36 @@ def _load_catalog(self) -> None: manifest_dicts[catalog[-1].id] = raw_dict except Exception: logger.exception( - "failed to sign manifest %s — catalog load continues unsigned", + "failed to sign manifest %s — install gate will block it", 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 + # Single atomic assignment: the entire snapshot is replaced at once, + # so readers never see a mix of old and new state (e.g. a new manifest + # from the new catalog paired with an old signatures dict). + self._state = _CatalogState( + catalog=catalog, + signatures=signatures, + manifest_dicts=manifest_dicts, + signing_failures=frozenset(signing_failures), + ) def reload(self) -> None: with self._catalog_lock: self._load_catalog() + def is_signing_failure(self, app_id: str) -> bool: + """Return True if *app_id* failed to sign during catalog load. + + When signing is configured and a manifest fails to sign, the + install gate should block it rather than treating it as merely + unsigned (which would be a bypass). + """ + self._ensure_loaded() + return app_id in self._state.signing_failures + def set_signing_key(self, key: bytes | None) -> None: """Set (or clear) the signing key and reload the catalog. diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 887114856..6f52cc973 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -229,8 +229,20 @@ def _verify_manifest_for_install( 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. + # No stored signature. If signing is configured and this manifest + # failed to sign during catalog load, block it — treating a signing + # failure as "merely unsigned" would bypass the install gate. + if ( + hasattr(registry, "is_signing_failure") + and registry.is_signing_failure(manifest_id) + ): + return False, ( + f"manifest {manifest_id} failed to sign during catalog load — " + "the manifest may be malformed or the signing key may be invalid" + ) + # Otherwise: 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): diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index 9edc32fb4..6cce5be83 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -16,12 +16,20 @@ 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. +- **Signatures are detached and stored in-memory.** Manifest YAML files + on disk are never modified. The hex-encoded Ed25519 signature is + computed over the canonical bytes of the manifest and stored in + ``AppRegistry._signatures`` (in-memory). At load time the registry + strips any ``_signature`` field from the dict, computes the signature, + and caches it. At verify time the same stripped view is used, so the + signature does not affect the bytes being verified. + + This is a *post-load* tamper-detection model: manifests are signed from + their contents as loaded from disk, so pre-load supply-chain or disk + tampering (before the server starts) is trusted rather than detected. + The signature protects against post-boot catalog tampering — an attacker + who modifies ``manifest.yaml`` while the server is running. + """ from __future__ import annotations @@ -80,13 +88,15 @@ def _enforce_permissions(keyfile: Path) -> None: Called on every load so a migration, backup-restore, or manual ``chmod`` cannot leave the private key world/group-readable. + + Raises ``PermissionError`` when the permissions cannot be confirmed + as 0600, allowing the caller to disable signing rather than read a + potentially group/world-readable private key. """ - try: - st = keyfile.stat() - if (st.st_mode & 0o777) != 0o600: - os.chmod(keyfile, 0o600) - except OSError: - pass # non-fatal on exotic filesystems + if (keyfile.stat().st_mode & 0o777) != 0o600: + os.chmod(keyfile, 0o600) + if (keyfile.stat().st_mode & 0o777) != 0o600: + raise PermissionError(f"cannot enforce 0600 permissions on {keyfile}") def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: @@ -156,12 +166,22 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: if keyfile.exists(): return load_or_create_signing_keypair(data_dir) raise - # Write and atomically promote. + # Write and atomically promote. Use os.link as a non-overwriting + # claim: link(2) fails with EEXIST if the target already exists, + # so a late-arriving contender cannot overwrite a winner's keyfile. + # After the link succeeds, the tmp is unlinked to clean up. try: os.write(fd, payload.encode("utf-8")) finally: os.close(fd) - os.replace(tmp, keyfile) + try: + os.link(tmp, keyfile) + except FileExistsError: + # Another process won the race — discard our key and load theirs. + tmp.unlink(missing_ok=True) + return load_or_create_signing_keypair(data_dir) + else: + tmp.unlink(missing_ok=True) logger.info("store signing keypair created at %s", keyfile) return priv, pub From 0eb1f788f75ce0e7cc4d9b11b2b12fa1fc3419a4 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:40:13 +0200 Subject: [PATCH 09/11] fix(store): add TOCTOU re-verify guard for backend manifest and guard keyfile.stat() OSErrors - Backend manifest TOCTOU: re-read backend manifest.yaml from disk and re-verify signature immediately before backend_installer.install(), matching the primary manifest guard at lines 783-829. - store_signing: wrap keyfile.stat() calls in _enforce_permissions with try/except so non-PermissionError OSErrors are raised as PermissionError instead of raw OSError. --- tinyagentos/routes/store_install.py | 42 +++++++++++++++++++++++++++++ tinyagentos/store_signing.py | 12 +++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 6f52cc973..3bb043f8b 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -941,6 +941,48 @@ async def install_app(request: Request): }, status_code=403, ) + # TOCTOU guard: the signing gate above verified the signature of the + # backend manifest, but the install below runs an untrusted script/image + # with no re-verification immediately before execution. Re-read the + # backend manifest from disk and re-verify its signature — if it no + # longer verifies, the manifest was modified after the gate check and + # the install is blocked. + _be_manifest_dir = getattr(backend_manifest, "manifest_dir", None) + if ( + _be_manifest_dir is not None + and isinstance(_be_manifest_dir, Path) + and _store_pub is not None + and registry is not None + and hasattr(registry, "verify_manifest_signature") + ): + be_disk_path = _be_manifest_dir / "manifest.yaml" + try: + import yaml as _yaml + on_disk_be = _yaml.safe_load(be_disk_path.read_text()) if be_disk_path.exists() else None + except Exception: + on_disk_be = None + if on_disk_be is not None: + be_stored_sig = registry.get_signature(result.backend_id) + if be_stored_sig is not None: + from tinyagentos.store_signing import verify_manifest_signature as _verify_sig + if not _verify_sig(on_disk_be, be_stored_sig, _store_pub): + progress.finish( + install_id, success=False, + error="backend manifest modified between signature verification and install", + ) + return JSONResponse( + { + "error": "backend manifest modified between signature verification and install", + "detail": ( + f"The backend manifest for {result.backend_id!r} was modified " + "on disk after the initial signature verification. " + "This may indicate post-verification tampering. " + "Rebuild the catalog or reinstall from a trusted source." + ), + "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 6cce5be83..a426cbd2d 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -93,9 +93,17 @@ def _enforce_permissions(keyfile: Path) -> None: as 0600, allowing the caller to disable signing rather than read a potentially group/world-readable private key. """ - if (keyfile.stat().st_mode & 0o777) != 0o600: + try: + mode = keyfile.stat().st_mode & 0o777 + except OSError as exc: + raise PermissionError(f"cannot stat keyfile {keyfile}: {exc}") + if mode != 0o600: os.chmod(keyfile, 0o600) - if (keyfile.stat().st_mode & 0o777) != 0o600: + try: + mode_after = keyfile.stat().st_mode & 0o777 + except OSError as exc: + raise PermissionError(f"cannot stat keyfile {keyfile}: {exc}") + if mode_after != 0o600: raise PermissionError(f"cannot enforce 0600 permissions on {keyfile}") From a82bd881f58c4720c54513e37210c95c0577b594 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:04:41 +0200 Subject: [PATCH 10/11] fix(store): remove redundant secondary TOCTOU re-verify for backend manifest The primary gate at line 924 (_verify_manifest_for_install) already re-reads the manifest from disk and verifies its signature. No await/yield exists between the primary gate and the install call, so the coroutine cannot be preempted in that synchronous stretch. The secondary re-read + re-verify (~40 lines) adds no real attack-window reduction. Reported-by: Kilo Code bot --- tinyagentos/routes/store_install.py | 42 ----------------------------- 1 file changed, 42 deletions(-) diff --git a/tinyagentos/routes/store_install.py b/tinyagentos/routes/store_install.py index 3bb043f8b..6f52cc973 100644 --- a/tinyagentos/routes/store_install.py +++ b/tinyagentos/routes/store_install.py @@ -941,48 +941,6 @@ async def install_app(request: Request): }, status_code=403, ) - # TOCTOU guard: the signing gate above verified the signature of the - # backend manifest, but the install below runs an untrusted script/image - # with no re-verification immediately before execution. Re-read the - # backend manifest from disk and re-verify its signature — if it no - # longer verifies, the manifest was modified after the gate check and - # the install is blocked. - _be_manifest_dir = getattr(backend_manifest, "manifest_dir", None) - if ( - _be_manifest_dir is not None - and isinstance(_be_manifest_dir, Path) - and _store_pub is not None - and registry is not None - and hasattr(registry, "verify_manifest_signature") - ): - be_disk_path = _be_manifest_dir / "manifest.yaml" - try: - import yaml as _yaml - on_disk_be = _yaml.safe_load(be_disk_path.read_text()) if be_disk_path.exists() else None - except Exception: - on_disk_be = None - if on_disk_be is not None: - be_stored_sig = registry.get_signature(result.backend_id) - if be_stored_sig is not None: - from tinyagentos.store_signing import verify_manifest_signature as _verify_sig - if not _verify_sig(on_disk_be, be_stored_sig, _store_pub): - progress.finish( - install_id, success=False, - error="backend manifest modified between signature verification and install", - ) - return JSONResponse( - { - "error": "backend manifest modified between signature verification and install", - "detail": ( - f"The backend manifest for {result.backend_id!r} was modified " - "on disk after the initial signature verification. " - "This may indicate post-verification tampering. " - "Rebuild the catalog or reinstall from a trusted source." - ), - "install_id": install_id, - }, - status_code=403, - ) backend_installer = get_installer(backend_method) be_result = await backend_installer.install( result.backend_id, From 5821cd5ac980d7c5185d1376b402e1921dc2453e Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:01:28 +0200 Subject: [PATCH 11/11] fix(store): add os.replace fallback when os.link fails on unsupported filesystems Kilo WARNING: os.link raises generic OSError (not FileExistsError) on filesystems without hardlink support (overlayfs, FUSE, container layers, FAT). Catch the broad OSError and fall back to os.replace, which is universally supported but carries a small overwrite-race window bounded by the tmp file's exclusive-open and 0600 permissions. Reported-by: Kilo Code bot --- tinyagentos/store_signing.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tinyagentos/store_signing.py b/tinyagentos/store_signing.py index a426cbd2d..f5e09ec50 100644 --- a/tinyagentos/store_signing.py +++ b/tinyagentos/store_signing.py @@ -188,6 +188,13 @@ def load_or_create_signing_keypair(data_dir: Path) -> tuple[bytes, bytes]: # Another process won the race — discard our key and load theirs. tmp.unlink(missing_ok=True) return load_or_create_signing_keypair(data_dir) + except OSError: + # os.link may fail on filesystems without hardlink support + # (overlayfs, FUSE, some container layers, FAT, etc.). + # Fall back to os.replace — universally supported, but carries + # a small overwrite-race window that the tmp file's 0600 perms + # and exclusive-open already bound. + os.replace(tmp, keyfile) else: tmp.unlink(missing_ok=True) logger.info("store signing keypair created at %s", keyfile)