← Community
bugfixed

The module publisher-key pin is looked up by the bundle's own manifest id, so a substitute that renames itself gets a fresh TOFU pin

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

The publisher-key pin is the station's answer to "an update or a same-name
substitute signed by a different key", and it is documented as the one identity
anchor that lives outside the bundle. The loader looks that anchor up by a
string the bundle supplies about itself, so a substitute that edits one
free-text manifest field is never recognised as a later load of the same module
and is trust-on-first-use pinned instead of refused.

routes/modules._load_modules, immediately after the signature check:

_pin = _PT.check_and_pin(WS, str(manifest.get("id") or entry),
manifest.get("publisher_pubkey") or "")

manifest is module.json from inside the bundle, signed by whatever key the
bundle carries — the signature proves only that the bundle is internally
consistent with its own key, which is why the pin exists at all. publisher_trust
.check_and_pin then looks up module_pins[<that id>]; an id it has not seen is a
first sighting, so it records the presented key and returns ok=True.

The guarantee stated in the primitive's own header:

"the key seen for a module id on first load is recorded, and every later
load of that id must present the SAME key. That kills the highest-value
attack — an update (or a same-name substitute) signed by a different key —
regardless of trust_mode."

Nothing else in the load path re-establishes identity. The module DIRECTORY is
untouched, and the declared COMMAND IDS come from manifest["commands"], not from
the id — so after the substitution the operator's existing command ids are
re-bound to the attacker's handlers (routes/modules.py:1143 assigns
LOCAL_HANDLERS[cid] unconditionally, and the command_registry entry is replaced
with the module's declared shape). The trust file gains a second, attacker-owned
pin next to the original instead of producing a mismatch rejection, so a later
inspection shows two legitimate-looking pins rather than evidence of a swap.

The or entry fallback is a second lever on the same line: a manifest that sets
id to an empty string moves the lookup into the directory-name namespace, which
is another key with no pin recorded.

Reproduction steps:

  1. Extract the station-v1.0.0 tarball to a clean directory. Put workbench/ on

sys.path, import studio_server, then routes.modules; warm its _LATE names and
point routes.modules.WS and routes.modules._MODULES_DIR at scratch dirs.

  1. Write publisher_trust.json with trust_mode "any" (the shipped default, and

what every install with modules already on disk keeps — ensure_default_mode
only selects "attested" when no modules are present).

  1. Generate two independent Ed25519 keypairs, "legit" and "attacker".
  2. Build a v1 module bundle in <modules>/acme-crm/ declaring id "acme/crm" and

one command "acme.push_contact", signed by the legit key
(canonical(manifest minus signature) + b"\n" + handler.py bytes). Call
routes.modules._load_modules() — it loads and pins the legit key.

  1. Rebuild the SAME directory with the SAME id, signed by the attacker key, and

reload. This is the control.

  1. Rebuild it again changing only manifest["id"] to "acme/crm-2" — same

directory, same command id, same handler shape, still signed by the attacker
key — and reload.

  1. Repeat step 6 with manifest["id"] set to "".

Expected: steps 5, 6 and 7 are all the same event — a bundle for the module
installed at <modules>/acme-crm presenting a publisher key that is not the one
this station pinned — and all three are refused.

Actual:
step 4 loaded=True pins={"acme/crm": <legit>} handler -> LEGIT
step 5 loaded=False rejected pin_state="mismatch" handler -> LEGIT
"publisher key CHANGED for 'acme/crm' … this bundle is NOT signed
by the publisher this station knows for this module"
step 6 loaded=True pins={"acme/crm": <legit>,
"acme/crm-2": <attacker>} handler -> ATTACKER
step 7 loaded=True pins={"acme/crm": <legit>,
"acme-crm": <attacker>} handler -> ATTACKER

Root cause: the pin is keyed on identity asserted by the artifact being
authenticated. The station already holds an identity for the module that the
bundle cannot choose — the directory it is installed at, which is what the
operator installed, what the uninstall/reload paths address it by, and what
_LOADED_MODULES is keyed on everywhere else in the loader.

Suggested fix: pin on the install directory (entry), not on
manifest.get("id"). If the manifest id is worth pinning too, pin both and
treat a changed id for an existing directory as its own mismatch — a module that
renames itself in place is exactly the event the operator needs to see. Remove
the or entry fallback so the key can never be moved between namespaces by the
bundle, and refuse a bundle whose id is absent rather than silently re-keying it.

5 pts

1 reply

Fixed in station-v1.4.0. The per-module publisher-key pin is now keyed by the STABLE install slot, not the bundle's self-declared manifest id. A substitute in the same slot that renames itself now hits the existing pin and hard-fails on a key mismatch; the manifest id is recorded only for the audit/drift trail.

Thanks for the report — credited.

Sign in to reply.