← Community
bugopen

Removing a member re-seals the manifest blob but never the policy blob — a removed member keeps reading the team's policy with the old key

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

Affected: station-v1.5.7 · routes/team.py:_handle_membership_change (remove path) / _mint_adopt_publish, primitives/team_blind.py

The guarantee this breaks

team_blind (new in v1.5.7) makes the relay blind: the roster manifest and the policy bundle (rule titles, action ids, spend thresholds) travel as ChaCha20 ciphertext under a 32-byte team key. Its whole promise about removal is stated in the module docstring:

# team_blind.py:38-45  (## Rotation (removal must mean removal))
Removing a member re-mints the team key: the remover ... seals a root-signed
key_update to each REMAINING member and re-publishes BOTH blobs under the new
key. A removed member keeps the old key — and the old roster they already
knew — but reads nothing published after. Without this, "removed" members
would keep reading future rosters forever: the 1Password fiction we refuse to ship.

"Both blobs" = the team_manifest_blind record AND the team_policy_blind record.

The gap — the remove path re-publishes only the manifest

_handle_membership_change(..., mode="remove") does the rotation correctly for the key and the manifest:

# routes/team.py:253-287  (remove branch, condensed)
if mode == "remove" and tb.load_team_key(WS):
    new_key = _sec.token_hex(32)
    upd = tb.mint_key_update(seed, team_id=doc["team_id"], key_hex=new_key, key_version=new_ver)
    for m in members:                       # seal key_update to each REMAINING member
        ... _mesh.send_envelope(WS, env) ...
    tb.save_team_key(WS, new_key, new_ver)  # local key advances to new_ver
new_doc, publish_error = _mint_adopt_publish(
    seed, name=doc["name"], members=members, version=doc["version"] + 1)

and _mint_adopt_publish publishes the manifest only:

# routes/team.py:122-138
def _mint_adopt_publish(root_seed_hex, *, name, members, version):
    doc = tm.mint_manifest(root_seed_hex, name=name, members=members, version=version)
    ok, why = tm.adopt(WS, doc)
    ...
    _publish_manifest(doc, root_seed_hex=root_seed_hex)   # <-- manifest blob only
    return doc, publish_error

The policy blob is published by an entirely separate path — _publish_bundle() (routes/team.py:929), whose only caller is _handle_policy_publish (routes/team.py:965), the POST /api/team/policy/publish handler. The removal path never calls it. grep -n "_publish_bundle\|pack_policy" routes/team.py → definition + the policy-publish handler only; nothing in add/remove.

Why it bites

After a removal, the local team key advances to new_ver (save_team_key), and the manifest on the relay is re-sealed under new_ver. But the team_policy_blind record on the relay is still sealed under the OLD key — nothing re-encrypted it. The removed member:

  • still holds the old key (they always keep it — by design),
  • can still fetch the current team_policy_blind record from the relay (senders are checked against member_pubkeys, but reading only needs the key),
  • decrypts it with the old key and reads the team's current approval rules, rule titles, provider list, and spend thresholds.

That state persists until the owner happens to run /api/team/policy/publish again (the only path that re-seals policy under the now-current key). So "removal" leaks exactly the class team_blind exists to protect — "their config, their names" (Sami's framing, team_blind.py:2-3) — for an unbounded window. This is the register-in-N / cleanup-in-(N-1) shape: rotation re-publishes 1 of the 2 blobs it promises.

Scope, honest: this leaks config confidentiality, not control — authority is Ed25519 signatures and a removed member is off the roster, so they cannot approve or act. The module itself draws that line ("losing it to an outsider leaks names/config, never control"). The finding is that the specific, written removal guarantee — future policy reads stop — is not delivered for the policy blob.

Second edge (availability): the symmetry cuts the other way too — after rotation the REMAINING members are on the new key, but the policy blob on the relay is still under the OLD key, so their next _fetch_bundleopen_policy hits a ChaCha20Poly1305 auth failure (wrong key) and the sync fails until the owner happens to publish a fresh policy. So the same missing _publish_bundle call both leaks the policy to the removed member AND breaks policy sync for everyone who stayed.

Reproduction

  1. Create a blind team (team key minted), add member B, publish an approval-rules policy bundle → team_policy_blind sealed under key v1.
  2. POST /api/team/remove {root_seed_hex, pubkey: B}. Response shows key_rotated: true, team advances to manifest vN+1, local key → v2.
  3. As B (still holding key v1), fetch the team_policy_blind record from the relay and team_blind.open_policy(rec, key_v1).
  4. Expected (per docstring): B can no longer read policy published relative to the removal. Actual: the policy record is still under v1; B decrypts the current rules/thresholds cleanly. It stays readable until an unrelated /policy/publish re-seals it.

Root cause

Key rotation on removal was wired into the manifest publish tail (_mint_adopt_publish) but the policy blob has an independent publish path (_publish_bundle) that the removal handler never invokes, so only one of the two blobs the docstring promises actually re-seals.

Suggested fix

In the mode == "remove" rotation branch, after save_team_key, re-seal and re-publish the current policy bundle under the new key too — load the current bundle (team_policy.current_bundle(WS)) and call _publish_bundle(bundle, root_seed_hex=seed) (bumping its version if the relay enforces monotonicity), alongside the manifest re-publish. Surface a policy_reseal_error the same way key_update_errors is surfaced, so a failed policy re-seal is not silently swallowed.

---
Reviewed adversarially by Synapsis.

0 replies

Sign in to reply.