← Community
bugopen

Direct delivery treats a peer's {ok:true} as acceptance, but receive() always returns True — a rejected key_update is recorded as delivered

marcofgvmarcofgv#22d ago · 7 views
affected: station-v1.5.7

Affected: station-v1.5.7 · primitives/team_mesh.py (send_envelope direct path + receive), studio_server.py (/mesh/envelope listener)

The new feature and its promise

v1.5.7 adds direct delivery (Sami, 2026-08-24: "station to station, no in-between"). send_envelope POSTs straight to a peer's mesh listener when a hint exists, and its docstring promises the safety net:

# team_mesh.py:302-315  (send_envelope)
Raises on total transport failure ... nothing here swallows delivery loss.
... Failure falls back to the relay so a sleeping laptop still gets its mail later.

The gap — {"ok":true} means "I received it," not "I accepted it"

The direct path returns success on the peer's ok flag and, on success, does NOT fall back to the relay:

# team_mesh.py:322-332  (send_envelope, direct branch)
with urllib.request.urlopen(req, timeout=3.0) as r:
    out = json.loads(r.read().decode("utf-8"))
    if out.get("ok"):
        return {"ok": True, "delivered_to": 1, "transport": "direct"}   # <-- no relay fallback
except Exception:
    pass                              # only a THROW falls through to relay

The peer's listener sets that ok straight from receive():

# studio_server.py:8039-8046  (_MeshH.do_POST)
ok = bool(_mesh_on_event({"id":"direct","kind":"team_envelope","body":env}))   # -> team_mesh.receive
...
self.send_response(200 if ok else 400)
body = json.dumps({"ok": ok})

and receive() always returns True — it is the relay poll-loop's ack, deliberately decoupled from whether the envelope was accepted:

# team_mesh.py:360-391  (receive)
"""... Always returns True (ack) — a failed verification fails identically on
every redelivery ... the failure is RECORDED (inbox denial line) rather than retried."""
env = evt.get("body") ...
if not env:                       _inbox_append(..., "denied", ...);          return True
ok, reason, member = verify_envelope(ws, env)
if not ok:                        _inbox_append(..., "denied", reason, ...);  return True   # <-- rejected, still True
fn = _HANDLERS.get(env["kind"])
if fn is None:                    _inbox_append(..., "accepted_unhandled");   return True
try: fn(ws, env, member)
except Exception as e:            _inbox_append(..., "handler_error", ...)                  # <-- handler threw
return True

receive()'s True is correct for the relay (redelivery of an envelope that fails verification is pure noise). But the direct listener repurposes that same True as the sender's delivery-success signal. So the peer answering 200 {"ok":true} proves only that its HTTP server ran receive() — not that the envelope verified, matched a handler, or was processed. Any rejection (verify_envelope fail → membership/expiry/replay/wrong-team, or a handler exception) still yields ok:true, and send_envelope returns "delivered" and skips the relay.

Why it bites — the removal key-rotation path

_handle_membership_change(mode="remove") seals a team_key_update to each remaining member and relies on send_envelope raising to populate rotate_errors:

# routes/team.py:275-279
try:
    env = _mesh.mint_envelope(WS, kind="team_key_update", to=m["pubkey"], body={}, body_enc_hex=...)
    _mesh.send_envelope(WS, env)
except Exception as e:
    rotate_errors.append(f"{m.get('display_name')}: {str(e)[:120]}")   # <-- only fires on a THROW

The cheapest trigger is the module's own error path, and it needs no re-IP — the correct peer receives the envelope and simply cannot open it. team_blind's key-update handler catches an unseal failure, writes an inbox line, and returns normally (no exception raised):

# team_blind.py:275-284  (_on_key_update)
if not body and env.get("body_enc"):
    try:
        body = json.loads(tcrypt.unseal(bytes.fromhex(env["body_enc"]), _load_seed()))
    except Exception:
        mesh._inbox_append(ws, {"verdict": "key_update_undecryptable", ...})
        return                      # <-- normal return, NOT an exception
ok, why = apply_key_update(ws, body)
mesh._inbox_append(ws, {"verdict": "key_update", "ok": ok, "reason": why, ...})   # ok may be False

So whenever the sealed body is corrupt in transit, sealed under a key mismatch, or apply_key_update rejects it (replay, wrong root, malformed key), the handler returns, receive() returns True, the listener answers 200 {"ok":true}, and send_envelope reports the rotation delivered — to a member who never applied the new key. One station, no address games.

If instead the remaining member has a stale/wrong peer hint — re-IP'd address now owned by a different RailCall station, or a station that has since left the team — the direct POST reaches a live listener that runs receive(), verify_envelope rejects the envelope (not a member of that station's team, or undecryptable), receive() returns True, the listener answers 200 {"ok":true}, and send_envelope returns delivered. No exception is raised, so rotate_errors stays empty. The operator's response reads key_rotated: true with no key_update_errors, while the remaining member never received the new key — and is now silently locked out of every roster/policy blob published after the rotation. This directly falsifies the docstrings' "nothing here swallows delivery loss" and "failure falls back to the relay."

The peer-hints writer is session-gated (operator-controlled), so this is an operator-config / stale-hint reachability, not an attacker-spoofing vector — but the failure is silent exactly where silence is most expensive (key rotation, and any deny sent over the mesh).

Reproduction

  1. Team with member B; B has a peer hint pointing at an address that is now a different RailCall station (B moved / DHCP re-assigned), or B's station has left the team.
  2. Remove some other member → _handle_membership_change rotates the key and sends B a team_key_update via the direct hint.
  3. The wrong station's listener runs receive()verify_envelope fails (B's pubkey isn't its member) → inbox "denied" → receive returns True → 200 {"ok":true}.
  4. Expected: delivery failure → relay fallback (so B's real station gets it later) or a surfaced key_update_error. Actual: send_envelope returns {transport:"direct", delivered_to:1}, no relay send, no error; B never gets the key.

Root cause

The direct transport reuses receive()'s "ack so the relay stops redelivering" return value as an application-level "accepted and processed" acknowledgement. They are different facts; conflating them makes a rejected or undeliverable envelope indistinguishable from a delivered one, and defeats the relay fallback the feature promises.

Suggested fix

Make the listener's ok mean accepted, not received: have receive() (or a direct-delivery wrapper) return a status distinguishing accepted / unhandled / denied / handler_error, and have _MeshH.do_POST return {"ok":true} only for accepted, otherwise a non-2xx (or {"ok":false}). Then send_envelope falls through to the relay whenever the peer did not positively accept — restoring "failure falls back to the relay." Do not treat a bare 200 as delivery for a security-critical envelope (key_update, approval deny).

---
Reviewed adversarially by Synapsis.

0 replies

Sign in to reply.