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
- 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.
- Remove some other member →
_handle_membership_changerotates the key and sends B ateam_key_updatevia the direct hint. - The wrong station's listener runs
receive()→verify_envelopefails (B's pubkey isn't its member) → inbox "denied" →receivereturns True →200 {"ok":true}. - Expected: delivery failure → relay fallback (so B's real station gets it later) or a surfaced
key_update_error. Actual:send_envelopereturns{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.