← Community
bugfixed

The paid-module trust check verifies the marketplace token online but never from cache, so an unsigned cached document grants entitlement

ShwetaShweta#114d ago · 26 views
affected: station-v1.0.0fixed in: station-v1.4.0

module_entitlement.check_server_trust is the layer that catches a refunded or
canceled subscription before the local license file's embedded expires_at runs
out — the loader's comment describes it as closing "the refund-abuse gap where a
canceled sub keeps granting until embedded expires_at". Online it authenticates
the marketplace's answer. Offline it authenticates nothing, and reaching the
offline path only requires that the marketplace be unreachable from the machine,
which is decided by the party this control exists to constrain.

The online path, in order:

if not verify_trust_signature(token, signature):
return {"trusted": False, "reason": "trust-token signature not from the
marketplace (or tampered)"}
if token.get("install_pubkey", "").lower() != install_pubkey_hex.lower():
return {"trusted": False, "reason": "trust token bound to a different install"}
trust_cache_write(ws, module_id, fresh)
if token.get("status") == "active":
return {"trusted": True, ...}

The offline path, in full:

fresh = _trust_check_fetch(marketplace_url, module_id, install_pubkey_hex)
if fresh is None:
cached = trust_cache_read(ws, module_id)
return _trust_apply_grace(cached, t, _trust_grace_days(license_state))

def _trust_apply_grace(cached, now, grace_days):
token = (cached or {}).get("trust_token") or {}
verified_at = _parse_iso(token.get("verified_at")) or 0
...
status = token.get("status")
if status != "active": return {"trusted": False, ...}
age_days = int(max(0, (now - verified_at) // 86400))
if age_days > grace_days: return {"trusted": False, ...}
return {"trusted": True, "cached": True, ...}

Two unauthenticated fields decide it. No verify_trust_signature call. No
install_pubkey comparison. No module_id comparison. Not even the schema check
that verify_trust_signature performs, so the signature key does not have to be
present at all. trust_cache_read is a bare json.load of
<ws>/module_licenses/verify_cache/<safe_module_id>.json.

verify_trust_signature's own docstring states the standard the cached path does
not meet: "the trust token IS the security boundary here; a forged token that
verifies would defeat the whole trust service."

Both unchecked bindings are separately reachable: because module_id is not
compared, a genuine still-active token for a free module is accepted as proof of
entitlement for a paid one if placed at the paid module's cache path; because
install_pubkey is not compared, one machine's token works on another. Neither
requires forgery — though forgery is not required either, since an unsigned
document is accepted.

This is distinct from the already-fixed issue on this same function, where a
cached status="canceled" was granted under grace. That fix taught the cached path
to read the status field; it did not make the cached path authenticate the
document the field was read from. The repro confirms the status fix still works.

Reproduction steps:

  1. Extract the station-v1.0.0 tarball to a clean directory and put workbench/

on sys.path.

  1. Stand up a loopback HTTP server that answers POST /licenses/verify with

{"trust_token": {"schema": "railcall_trust_token.v1", "listing_id":
"<module>", "install_pubkey": "<this install>", "status": "active",
"verified_at": "<now, ISO Z>"}, "signature": ""} — an unsigned token.

  1. Call module_entitlement.check_server_trust(ws, module, install_pk,

<that server's URL>, {"valid": True, "tier": "core"}) — the online path.

  1. Write the same document to module_entitlement.trust_cache_path(ws, module)

and call check_server_trust again with a URL where nothing is listening.

  1. Repeat step 4 with the token's install_pubkey set to another machine's, with

its listing_id set to a different module, with the schema key removed, and
with the token reduced to just {"status", "verified_at"}.

  1. Repeat step 4 with status "canceled" to confirm the status check still holds.

Expected: the same bytes get the same verdict on both paths, and a cached
document is accepted only if it carries a valid marketplace signature over a
token bound to this install and this module.

Actual:
step 3 trusted=False "trust-token signature not from the marketplace (or tampered)"
step 4 trusted=True "trusting cache (0/7 grace days used) — marketplace unreachable"
step 5 trusted=True for all four — another install's binding, another
module's token, no schema, and a two-field document
step 6 trusted=False "marketplace last reported status=canceled …"

Root cause: the cache stores the marketplace's response but the reader treats
it as the marketplace's verdict. Authentication was placed on the transport
(the fresh fetch) rather than on the artifact, so it does not survive the
artifact being written down and read back.

Suggested fix: make _trust_apply_grace re-run the same checks the fresh path
runs — verify_trust_signature over the cached token, then the install_pubkey
comparison, then a module_id comparison against the module being loaded (worth
adding to the fresh path too, which currently checks only the install binding) —
before it looks at status or age. A cached document that fails any of them is
not a cache miss but a tampering signal, and should refuse rather than fall
through. Verifying on write as well as on read is cheap and would stop a bad
document from ever landing.

1 reply

Fixed in station-v1.4.0 — same root cause as your other filing of this (a7e9b4); the offline trust path now re-verifies the cached token's marketplace signature + install/module bindings before granting grace. Marking this duplicate fixed; credited once on the primary thread. Thanks for the thorough report.

Sign in to reply.