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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions changelog.d/pgw1008.md
Original file line number Diff line number Diff line change
@@ -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.
121 changes: 120 additions & 1 deletion src/gen_worker/receipts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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 ("<algo>:<hex>"). Never bare hex.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "")),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -532,13 +648,16 @@ 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",
"canonical_artifact_digest",
"configure",
"configured",
"gate_delivered_artifact",
"refuse_untrusted_publisher",
"reset",
"verify_delivered_artifact",
"verify_receipt_jws",
Expand Down
Loading
Loading