← Community
bugfixed

Keyring-less install: a module declaring provider _railcall_signing_seed reads the seed via vault_get, forging every signature (v0.99)

ShwetaShweta#112d ago · 20 views
affected: station-v0.99fixed in: station-v1.4.0

Reproduction steps:

  1. On an install without an OS keychain (the seed_store module unavailable — the

documented pure/offline fallback, common on headless servers), the Ed25519
signing seed is stored as hex in keys.local.json under the reserved key
"_railcall_signing_seed" (railcall_signing.ensure_keypair, line 159; only the
_seed_store is None branch, i.e. import failed).

  1. Install any module (trust default is "any", so a self-signed bundle installs)

whose manifest DECLARES that reserved key as a provider, e.g.
{"credential_spec": {"provider": "_railcall_signing_seed"}}
(a command provider/requires, or an allowed_destinations provider, works too).

  1. The module handler calls its injected, per-module vault_get("_railcall_signing_seed").
  2. Run repro_module_reads_signing_seed_no_keyring_v099.py against a clean v0.99

extraction (it forces the keyring-less state and drives the REAL
_make_module_vault_get shim). Output:
module vault_get('_railcall_signing_seed') -> bae95d91fec15ef0… (the seed)
forged signature verifies against the install's published public key -> True

Expected:
The private signing seed is documented as a reserved vault key "redacted/filtered
everywhere" and "never displayed, never [transmitted]" (railcall_signing.py:8,80).
No module — however it declares its scope — may read it; a signed receipt / mesh
envelope / team manifest is only trustworthy if its private key cannot be
extracted.

Actual:
The reserved-key filter (signing.is_reserved_vault_key) is applied ONLY on the
vault LISTING (studio_server.py:4336, "names only ... filter the reserved
Ed25519 signing-seed slot"). The VALUE path has no such filter at any step:
- _module_declared_providers (routes/modules.py:344) adds any declared provider
verbatim -> "_railcall_signing_seed" is in the module's allowed scope;
- the _make_module_vault_get shim (routes/modules.py:426) does
if pv in allowed: return _vault_get(pv) — no reserved check;
- _vault_get (studio_server.py:4733) -> credential_resolver.resolve(WS, provider)
-> keys.local.json.get(provider) — no reserved check;
- nothing at module install rejects a manifest declaring the reserved provider.
So on a keyring-less install a module reads the raw signing seed through its own
governed vault_get. The repro then signs attacker-chosen bytes with the leaked
seed and verifies them against the install's PUBLISHED public key — i.e. every
receipt, mesh envelope, and team manifest signature the station emits or verifies
is forgeable, and the audit trail (which rests entirely on these signatures) can
be fabricated wholesale.

Scope (honest): the seed is in keys.local.json only when the OS-keychain seed_store
is unavailable (import fails). With a keychain the seed migrates into seed_store
and resolve() returns nothing for the reserved key — so this does NOT reproduce on
a keychain-backed install. But the keyring-less state is a first-class, documented
fallback (headless servers, minimal-dependency tarballs), and on it the exposure is
total. It also compounds finding #3: on such installs the module-sandbox filesystem
holes let a module read keys.local.json directly, so even a module that does NOT
declare the reserved provider can reach the seed.

Suggested fix:
Filter RESERVED_VAULT_KEYS on the VALUE path, not just the listing:
1. credential_resolver.resolve()/resolve_dict(): return None for any provider in
signing.RESERVED_VAULT_KEYS (one guard closes every caller — _vault_get, the
module shim, workflow _client);
2. _module_declared_providers() / the module install validator: reject a manifest
that declares a reserved provider name outright (fail the install, loudly);
3. do not store the seed in keys.local.json at all — if seed_store is unavailable,
keep the seed in a dedicated 0600 file that the credential vault never reads,
so a resolver bug can never surface it. The signing seed must live outside the
namespace any credential lookup can address.

5 pts

1 reply

Fixed in station-v1.4.0. The reserved-key filter (is_reserved_vault_key) was enforced only on the vault LISTING; it now guards the module VALUE path too. _module_declared_providers drops reserved vault slots and the per-module vault shim refuses a reserved-key read outright — a module declaring provider _railcall_signing_seed gets an empty scope and vault_get("_railcall_signing_seed") returns None, so the Ed25519 signing seed can never be extracted (the check fails safe if it can't load). Nicely caught — the keyring-less fallback made this reachable.

Thanks for the report — credited.

Sign in to reply.