diff --git a/changelog.d/pgw1008.md b/changelog.d/pgw1008.md new file mode 100644 index 00000000..e6564196 --- /dev/null +++ b/changelog.d/pgw1008.md @@ -0,0 +1,37 @@ +- **pgw#1008 (SECURITY, twin of th#1657): the arm gate verified WHO SIGNED a + cell and never WHO IT WAS FOR.** `owning_endpoint_id` has ridden the + hub-signed cell receipt since pgw#709 and was decoded straight into + `Receipt` — and then compared against nothing. So `verify_delivered_artifact` + checked the signature, the digest, the integral size and the packed cell key, + and armed. A valid signature was read as *"this cell is genuine"* rather than + *"the hub signed this cell for THIS pod"*, and endpoint A's cell `dlopen`ed on + endpoint B's pods, in another org. The SDK was already paying for the + attestation and discarding the one field that made it a trust decision. +- **The rule is Paul's ruling verbatim: a cell must have come from THIS + endpoint, or from a publisher the platform vouches for.** `Receipt` gains the + th#1657 v2 claims `publisher_tier` and `publisher_org_id`, and + `refuse_untrusted_publisher` runs LAST in `verify_delivered_artifact` — + after the signature, because a tier is only meaningful once the claims are + proven, and before the return, because the caller's next act is to arm the + cell and load native code. Typed refusal class: `publisher_untrusted`, on the + existing `cell_receipt_refused` wire event, so it is countable beside every + other reject class (pgw#824). +- **This pod's identity comes from the credential the hub issued it**, not from + config and not from an env var: `cell_read_endpoint_id`, which th#1657 stamps + on the th#1335 cell-read grant. The worker JWT payload is decoded WITHOUT + verifying its signature, deliberately — it is our own bearer token, not an + input, and the hub verifies it on every call. What must not be trusted is the + RECEIPT, and that one is signature-verified before it reaches the comparison. +- **Every route to "wider" is closed.** `publisher_tier` normalizes to `org` + unless it is exactly `platform` — no third value, no `unknown` branch, and + `PLATFORM`/`platform-ish`/absent all land on the narrower rule (§4.24 point 4: + absence must be explicit). An org-tier receipt naming no endpoint is adoptable + by nobody. A pod that cannot name its own endpoint is narrowed to + platform-tier cells, never widened. And `RECEIPT_VERSION` moves to + `cell-receipt-v2` so a v1 receipt is REFUSED rather than read as a v2 one with + the trust fields missing — the `omitempty` collapse that silently deletes a + boundary. +- **Nothing in the fleet changes behaviour today**, which is why it is cheap + now: every endpoint is currently platform-authored, so every cell publishes at + `platform` tier and arms exactly as before. The boundary has to exist before + that stops being true, not after. diff --git a/src/gen_worker/receipts.py b/src/gen_worker/receipts.py index effb71aa..4d672bc2 100644 --- a/src/gen_worker/receipts.py +++ b/src/gen_worker/receipts.py @@ -62,7 +62,20 @@ logger = logging.getLogger(__name__) -RECEIPT_VERSION = "cell-receipt-v1" +# th#1657 bumped v1 -> v2: the claim set gained `publisher_tier` and +# `publisher_org_id`, and they are LOAD-BEARING at the arm gate below. A v1 +# receipt must not be read as a v2 one with the trust fields missing, so the +# version check refuses it outright rather than defaulting them. +RECEIPT_VERSION = "cell-receipt-v2" + +# Publisher tiers (th#1657). `platform` means the platform vouches for the +# publishing org's endpoint code, so the cell is adoptable fleet-wide within its +# family. Anything else — including an absent or unrecognised value — is `org`, +# and an org-scoped cell is adoptable only by pods of the endpoint that minted +# it. There is deliberately no third value and no "unknown" branch: every +# unparseable tier must land on the NARROWER rule. +CELL_PUBLISHER_TIER_PLATFORM = "platform" +CELL_PUBLISHER_TIER_ORG = "org" # The algorithms this worker can actually recompute from local bytes. A # receipt naming anything else is refused, never assumed. ARTIFACT_DIGEST_ALGORITHMS = ("sha256",) @@ -92,6 +105,9 @@ class Receipt: axes: Dict[str, str] owning_endpoint_id: str publisher: str + # th#1657: the publisher-trust boundary, inside the signature. + publisher_tier: str + publisher_org_id: str snapshot_digest: str artifact_path: str # Canonical, ALGORITHM-TAGGED (":"). Never bare hex. @@ -150,6 +166,92 @@ def _b64url_decode(segment: str) -> bytes: raise ReceiptError("receipt_malformed", f"base64url decode failed: {exc}") from exc +# -- th#1657 publisher trust ------------------------------------------------ + + +def _normalize_publisher_tier(raw: object) -> str: + """Anything that is not exactly ``platform`` is ``org``. + + No error return and no third value on purpose: a caller forced to branch on + an error eventually gets the branch wrong, and every wrong branch here ends + in ``dlopen``. + """ + if str(raw or "").strip() == CELL_PUBLISHER_TIER_PLATFORM: + return CELL_PUBLISHER_TIER_PLATFORM + return CELL_PUBLISHER_TIER_ORG + + +def _self_endpoint_id(cfg: "_Config") -> str: + """The endpoint THIS pod serves, read out of the hub-issued worker JWT. + + The hub stamps ``cell_read_endpoint_id`` on the cell-read grant (th#1335 + + th#1657) precisely so both ends of the exchange can name the viewer. Reading + it here means the pod's identity comes from the credential the hub issued + for this pod — not from config, not from an env var, and not from anything + the cell being adopted can influence. + + The payload is decoded WITHOUT verifying the signature, and that is correct: + this is our OWN bearer token, not an input. A worker that forged its own + credential would be attacking itself, and the hub verifies it on every call + anyway. What must not be trusted is the RECEIPT, and that one is + signature-verified before it reaches the comparison. + """ + try: + token = str(cfg.worker_jwt() or "").strip() + except Exception: # noqa: BLE001 — a credential provider that raises is not an identity + return "" + parts = token.split(".") + if len(parts) != 3: + return "" + try: + payload = json.loads(_b64url_decode(parts[1]).decode("utf-8")) + except (ReceiptError, ValueError, UnicodeDecodeError): + return "" + if not isinstance(payload, dict): + return "" + return str(payload.get("cell_read_endpoint_id") or "").strip() + + +def refuse_untrusted_publisher(receipt: Receipt, self_endpoint_id: str) -> None: + """Raise unless this pod may adopt ``receipt``'s cell (th#1657). + + THE RULE, and it is Paul's ruling verbatim: a cell must have come from THIS + endpoint, or from a publisher the platform vouches for. + + THREAT: cross-tenant native-code execution. The artifact is a ``.so`` this + process is about to ``dlopen``. Nothing else prevents it — the digest proves + the bytes are the ones the hub signed, not that the hub meant them for US; + the cell key proves nothing at all, because the hub cannot verify + artifact-to-graph correspondence without recompiling; and the hub's own + listing filter (th#1657 hub half) is the only thing standing between us and + another org's cell on the ONE path that goes through a listing. This runs on + every path, and it is the last check before the load. + + THE FIELD WAS ALREADY HERE. ``owning_endpoint_id`` has ridden the signed + receipt since pgw#709 and was decoded into ``Receipt`` and then compared + against nothing — so a valid signature was read as "this cell is genuine" + and armed on whatever pod happened to fetch it. We were paying for the + attestation and discarding the field that made it a trust decision. + """ + if receipt.publisher_tier == CELL_PUBLISHER_TIER_PLATFORM: + return + owner = str(receipt.owning_endpoint_id or "").strip() + mine = str(self_endpoint_id or "").strip() + if not owner: + raise ReceiptError( + "publisher_untrusted", + "org-tier receipt names no owning endpoint, so no pod may adopt it") + if not mine: + raise ReceiptError( + "publisher_untrusted", + "this pod cannot name its own endpoint (no cell_read_endpoint_id on " + "the worker credential), so it may adopt platform-tier cells only") + if owner != mine: + raise ReceiptError( + "publisher_untrusted", + f"cell was minted for endpoint {owner} and this pod serves {mine}") + + def _rsa_key_from_jwk(jwk: Mapping[str, object]) -> Optional[rsa.RSAPublicKey]: if str(jwk.get("kty") or "") != "RSA": return None @@ -247,6 +349,8 @@ def verify_receipt_jws(jws: str, keys: Mapping[str, rsa.RSAPublicKey]) -> Receip axes=axes, owning_endpoint_id=str(payload.get("owning_endpoint_id") or ""), publisher=str(payload.get("publisher") or ""), + publisher_tier=_normalize_publisher_tier(payload.get("publisher_tier")), + publisher_org_id=str(payload.get("publisher_org_id") or ""), snapshot_digest=str(payload.get("snapshot_digest") or ""), artifact_path=str(artifact.get("path") or ""), artifact_digest=canonical_artifact_digest(str(artifact.get("digest") or "")), @@ -426,8 +530,14 @@ def verify_delivered_artifact(artifact: Path, family: str) -> Receipt: Chain of trust: receipt signature (hub key via JWKS) -> local bytes (the receipt's OWN algorithm + integral size) -> embedded metadata (inside the digested bytes) -> ``meta.cell_key == receipt.cell_key`` -> + **the PUBLISHER** (th#1657: platform tier, or this pod's own endpoint) -> the runtime's own computed key (enforced downstream by the th#883 selection brain). + + The publisher link is last because it is the only one that asks a question + about US rather than about the bytes, and it is the one that was missing: + every other link proved the artifact was the one the hub signed, and none + of them asked whether the hub signed it for this pod. """ with _LOCK: cfg = _CONFIG @@ -487,6 +597,12 @@ def verify_delivered_artifact(artifact: Path, family: str) -> Receipt: "cell_revoked", f"key={receipt.cell_key} snapshot={receipt.snapshot_digest} is recalled") + # th#1657, LAST: who published this, and may we run it. Deliberately after + # the signature — the tier is only meaningful once the claims are proven — + # and deliberately before the return, because the caller's next act is to + # arm the cell and dlopen it. + refuse_untrusted_publisher(receipt, _self_endpoint_id(cfg)) + return receipt @@ -532,6 +648,8 @@ def gate_delivered_artifact(artifact: Path, family: str) -> bool: __all__ = [ "ARTIFACT_DIGEST_ALGORITHMS", + "CELL_PUBLISHER_TIER_ORG", + "CELL_PUBLISHER_TIER_PLATFORM", "Receipt", "ReceiptError", "artifact_digests", @@ -539,6 +657,7 @@ def gate_delivered_artifact(artifact: Path, family: str) -> bool: "configure", "configured", "gate_delivered_artifact", + "refuse_untrusted_publisher", "reset", "verify_delivered_artifact", "verify_receipt_jws", diff --git a/tests/test_receipts_pgw709.py b/tests/test_receipts_pgw709.py index e7665543..b42fe3f2 100644 --- a/tests/test_receipts_pgw709.py +++ b/tests/test_receipts_pgw709.py @@ -29,6 +29,13 @@ FAMILY = "sdxl" CELL_KEY = "ck1-0123456789abcdef0123456789abcdef0123456789abcdef01234567" SNAPSHOT = "snapdigest-abc123" +# th#1657: the endpoint the test pod serves, and the one every fixture receipt +# is minted FOR unless a test deliberately mints it for someone else. Keeping +# the default a matching ORG-tier pair means the publisher gate is live in every +# case below, not just the ones that name it. +SELF_ENDPOINT = "3e0f8f7a-1111-2222-3333-444455556666" +OTHER_ENDPOINT = "9c1d2e3f-9999-8888-7777-666655554444" +SELF_ORG = "11111111-2222-3333-4444-555555555555" B3_HEX = "ab12cd34" * 8 SHA_HEX = "12ab34cd" * 8 @@ -74,12 +81,15 @@ def make_claims( else: artifact["digest"] = artifact_digest claims: Dict[str, Any] = { - "crv": "cell-receipt-v1", + "crv": "cell-receipt-v2", "family": FAMILY, "cell_key": CELL_KEY, "axes": {"sku": "rtx-4090", "image_digest": "sha256:feed", "gen_worker": "0.75.1"}, - "owning_endpoint_id": "3e0f8f7a-1111-2222-3333-444455556666", + "owning_endpoint_id": SELF_ENDPOINT, "publisher": "selfmint:worker=w1:pod=p1:release=r1", + # th#1657 publisher trust, inside the signature. + "publisher_tier": "org", + "publisher_org_id": SELF_ORG, "snapshot_digest": SNAPSHOT, "artifact": artifact, "manifest_digest": "sha256:aa", @@ -162,7 +172,7 @@ def test_wrong_key_refused(self, rsa_key: rsa.RSAPrivateKey) -> None: assert exc.value.reason == "receipt_signature_invalid" def test_wrong_version_refused(self, rsa_key: rsa.RSAPrivateKey, pub_map: Dict[str, rsa.RSAPublicKey]) -> None: - jws = sign_receipt(rsa_key, make_claims("sha256:" + SHA_HEX, 4096, crv="cell-receipt-v0")) + jws = sign_receipt(rsa_key, make_claims("sha256:" + SHA_HEX, 4096, crv="cell-receipt-v1")) with pytest.raises(receipts.ReceiptError) as exc: receipts.verify_receipt_jws(jws, pub_map) assert exc.value.reason == "receipt_version_unsupported" @@ -269,8 +279,24 @@ def hub(rsa_key: rsa.RSAPrivateKey) -> Iterator[HubStub]: receipts.reset() -def _configure(stub: HubStub) -> None: - receipts.configure(base_url=stub.base_url, worker_jwt=lambda: "test-worker-jwt") +def worker_jwt_for(endpoint_id: str) -> str: + """A hub-shaped worker credential naming the endpoint this pod serves. + + th#1657: the pod's own identity comes from the `cell_read_endpoint_id` the + hub stamps on the cell-read grant (th#1335), so the test builds the same + thing. The signature is never checked — this is our OWN bearer token, not an + input — so an unsigned third segment is faithful to what the gate reads. + """ + header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) + payload = _b64url(json.dumps({ + "sub": "worker-1", "cell_read_endpoint_id": endpoint_id, + }).encode()) + return header + "." + payload + ".not-checked-here" + + +def _configure(stub: HubStub, *, endpoint_id: str = SELF_ENDPOINT) -> None: + receipts.configure( + base_url=stub.base_url, worker_jwt=lambda: worker_jwt_for(endpoint_id)) class TestGateDeliveredArtifact: @@ -515,3 +541,156 @@ def test_digests_are_computed_in_one_pass(self, tmp_path: Path) -> None: assert set(got) == set(receipts.ARTIFACT_DIGEST_ALGORITHMS) == {"sha256"} raw = artifact.read_bytes() assert got["sha256"] == hashlib.sha256(raw).hexdigest() + + +# --------------------------------------------------------------------------- +# th#1657 — the publisher trust boundary at the arm gate (pgw#1008) +# --------------------------------------------------------------------------- + + +class TestPublisherTrustTh1657: + """A cell must have come from THIS endpoint, or from a publisher the + platform vouches for. + + THREAT: cross-tenant native-code execution. The artifact is a `.so` this + process is about to dlopen. Every other link in the chain proves the bytes + are the ones the hub signed; none of them asks whether the hub signed them + FOR THIS POD. `owning_endpoint_id` has ridden the signed receipt since + pgw#709 and was decoded into `Receipt` and compared against nothing. + + The first test is the RED one: before pgw#1008 it ARMS. + """ + + def test_another_endpoints_org_cell_is_refused( + self, tmp_path: Path, hub: HubStub + ) -> None: + """A genuine, correctly-signed, un-revoked receipt — for someone else. + + Nothing about this artifact is malformed. The signature verifies, the + digest matches, the size matches, the packed key matches, the family + matches, the pair is not revoked. It is simply not ours, and before + pgw#1008 that made no difference at all. + """ + artifact = make_artifact(tmp_path) + hub.serve_receipt_for( + artifact, owning_endpoint_id=OTHER_ENDPOINT, publisher_tier="org") + _configure(hub, endpoint_id=SELF_ENDPOINT) + + with pytest.raises(receipts.ReceiptError) as excinfo: + receipts.verify_delivered_artifact(artifact, FAMILY) + assert excinfo.value.reason == "publisher_untrusted" + assert OTHER_ENDPOINT in str(excinfo.value) + # And the arm hook refuses rather than raising into the boot. + assert receipts.gate_delivered_artifact(artifact, FAMILY) is False + + def test_our_own_org_cell_arms(self, tmp_path: Path, hub: HubStub) -> None: + """THE CONTROL. Without it, a gate that refused everything would pass + every other assertion in this class.""" + artifact = make_artifact(tmp_path) + hub.serve_receipt_for( + artifact, owning_endpoint_id=SELF_ENDPOINT, publisher_tier="org") + _configure(hub, endpoint_id=SELF_ENDPOINT) + + receipt = receipts.verify_delivered_artifact(artifact, FAMILY) + assert receipt.owning_endpoint_id == SELF_ENDPOINT + assert receipt.publisher_tier == "org" + assert receipts.gate_delivered_artifact(artifact, FAMILY) is True + + def test_platform_tier_arms_anywhere(self, tmp_path: Path, hub: HubStub) -> None: + """Platform-tier is the escape hatch the fleet actually runs on: the + platform authored that code and already runs it everywhere.""" + artifact = make_artifact(tmp_path) + hub.serve_receipt_for( + artifact, owning_endpoint_id=OTHER_ENDPOINT, publisher_tier="platform") + _configure(hub, endpoint_id=SELF_ENDPOINT) + + receipt = receipts.verify_delivered_artifact(artifact, FAMILY) + assert receipt.publisher_tier == "platform" + assert receipts.gate_delivered_artifact(artifact, FAMILY) is True + + @pytest.mark.parametrize("tier", [None, "", "platform-ish", "PLATFORM", "org"]) + def test_only_exactly_platform_widens( + self, tmp_path: Path, hub: HubStub, tier: object + ) -> None: + """§4.24 point 4: absence must be explicit. An unset, mis-cased or + invented tier must land on the NARROWER rule, never the wider one — a + receipt is a permanent statement, so there is no reader-side leniency + to lean on.""" + artifact = make_artifact(tmp_path) + overrides: Dict[str, Any] = {"owning_endpoint_id": OTHER_ENDPOINT} + if tier is not None: + overrides["publisher_tier"] = tier + else: + overrides["publisher_tier"] = None + hub.serve_receipt_for(artifact, **overrides) + _configure(hub, endpoint_id=SELF_ENDPOINT) + + with pytest.raises(receipts.ReceiptError) as excinfo: + receipts.verify_delivered_artifact(artifact, FAMILY) + assert excinfo.value.reason == "publisher_untrusted" + + def test_pod_that_cannot_name_itself_gets_platform_only( + self, tmp_path: Path, hub: HubStub + ) -> None: + """A worker credential with no `cell_read_endpoint_id` (a hub too old + for th#1657, or a grant that could not resolve one) narrows this pod to + platform-tier cells. It does NOT widen it — an identity we cannot + establish is not an identity that matches everyone.""" + artifact = make_artifact(tmp_path) + hub.serve_receipt_for( + artifact, owning_endpoint_id=SELF_ENDPOINT, publisher_tier="org") + receipts.configure(base_url=hub.base_url, worker_jwt=lambda: "not-a-jwt") + + with pytest.raises(receipts.ReceiptError) as excinfo: + receipts.verify_delivered_artifact(artifact, FAMILY) + assert excinfo.value.reason == "publisher_untrusted" + assert "cannot name its own endpoint" in str(excinfo.value) + + def test_org_receipt_naming_no_endpoint_is_adoptable_by_nobody( + self, tmp_path: Path, hub: HubStub + ) -> None: + artifact = make_artifact(tmp_path) + hub.serve_receipt_for( + artifact, owning_endpoint_id="", publisher_tier="org") + _configure(hub, endpoint_id=SELF_ENDPOINT) + + with pytest.raises(receipts.ReceiptError) as excinfo: + receipts.verify_delivered_artifact(artifact, FAMILY) + assert excinfo.value.reason == "publisher_untrusted" + + def test_v1_receipt_is_refused_not_defaulted( + self, tmp_path: Path, hub: HubStub + ) -> None: + """The trust fields are load-bearing, so a receipt minted before they + existed must not be read as a v2 one with them missing. That is the + `omitempty` collapse §4.24 point 4 names, and here it would silently + delete the boundary.""" + artifact = make_artifact(tmp_path) + hub.serve_receipt_for(artifact, crv="cell-receipt-v1") + _configure(hub, endpoint_id=SELF_ENDPOINT) + + with pytest.raises(receipts.ReceiptError) as excinfo: + receipts.verify_delivered_artifact(artifact, FAMILY) + assert excinfo.value.reason == "receipt_version_unsupported" + + def test_the_refusal_reaches_the_wire( + self, tmp_path: Path, hub: HubStub, monkeypatch: pytest.MonkeyPatch + ) -> None: + """pgw#824/pgw#999: a refusal nobody can count is a refusal nobody can + act on. The class must reach the activity event, not just the log.""" + events: List[Tuple[str, str, str]] = [] + monkeypatch.setattr( + receipts.activity_mod, "emit_event", + lambda kind, detail, phase="", **_: events.append((kind, detail, phase))) + + artifact = make_artifact(tmp_path) + hub.serve_receipt_for( + artifact, owning_endpoint_id=OTHER_ENDPOINT, publisher_tier="org") + _configure(hub, endpoint_id=SELF_ENDPOINT) + + assert receipts.gate_delivered_artifact(artifact, FAMILY) is False + assert events, "the refusal never reached the wire" + kind, detail, phase = events[-1] + assert kind == "cell_receipt_refused" + assert phase == "publisher_untrusted" + assert FAMILY in detail