Skip to content

Team vault: invitee locked out (no key) when they accept while the inviter is offline #41

Description

@kipavy

Summary

When an invitee accepts a team-vault invitation while the only key-holder (the inviter) is offline, they become a full team member but never receive the wrapped team-vault key. Their client then silently polls GET /v1/teams/{id}/vault-key → 404 in a loop with no user-facing explanation — the shared vault just fails to open. It only self-heals if/when the inviter next comes online.

This was found during an end-to-end test with two real accounts against a live server.

How team-vault key distribution works today

The team-vault key (random 32-byte key, X25519-wrapped per member) is only distributed in two places:

  1. At team creationinitTeamVaultKey() wraps the key for whatever members exist at that moment (src/services/teamVaultSync.ts:179).
  2. Reactively — the sync loop's team_members: SSE handler wraps the key for newly-seen members (src/services/sync.ts:785-802), run by clients that already hold the key.

The reactive path computes "new members" as a diff against the client's current membersByTeam:

// src/services/sync.ts:787-801
const prevMemberIds = new Set((useTeamStore.getState().membersByTeam[teamId] ?? []).map(m => m.user_id));
await Promise.all([ loadTeams(), loadMembers(teamId), loadRoles(teamId) ]);
const newMembers = (membersByTeam[teamId] ?? []).filter(m => !prevMemberIds.has(m.user_id) && m.public_key);
if (newMembers.length > 0) {
  const { distributeKeyToNewMember } = await import("@/services/teamVaultSync");
  await Promise.allSettled(newMembers.map(m => distributeKeyToNewMember(teamId, m.user_id, m.public_key)));
}

The gap

There is no reconciliation for already-existing members who lack a key. Distribution is purely event-driven and requires a key-holder to be online at the moment the membership event fires. Two consequences:

  • The adder never self-distributes. addMemberById calls loadMembers() (src/stores/teamStore.ts:88), so by the time the adder's own team_members: event arrives, the new member is already in membersByTeamnewMembers is empty → skipped. In a team where the inviter is the only key-holder (i.e. the first invite to a freshly-created shared vault), nobody distributes at add-time.
  • Async acceptance loses the event. If the invitee accepts later, while the inviter is offline, the team_members: event is never delivered to a key-holder. On the inviter's next login there's no re-check for keyless members, other than the incidental stale-membersByTeam-vs-fresh diff (which is what accidentally rescued the test case ~40s after the inviter reconnected).

Reproduction

  1. Account A (business) creates/shares a vault → team vault; A holds the only key.
  2. A invites B, then closes the app (or just goes offline).
  3. B accepts the invitation → B is now a team_member, but team_vault_keys has no row for B.
  4. B opens the shared vault → it silently fails; B's client polls GET vault-key → 404 indefinitely.
  5. B stays locked out until A happens to reopen the app and re-sync.

Evidence (from the E2E run)

  • team_vault_keys had only A after B accepted; server logged repeated Vault key not found for user <B>404.
  • When A reconnected, A's client did PUT …/vault-key … key_count=2 and B's next fetch logged Vault key fetched; the shared vault then materialised for B.

Impact

  • Correctness: async invite-acceptance (invitee accepts hours later) is a completely normal flow, and it leaves the invitee locked out with no recovery path until the inviter returns.
  • UX: even in the transient online case there's a window where the vault silently fails with a tight 404 poll loop and zero user feedback.

Suggested fixes

  1. UI/UX (minimum): when a member has joined a team vault but has no key yet, show a clear "Waiting for a vault owner to grant you access" state instead of a blank/failed vault + silent 404 loop. Back off the poll.
  2. Reconciliation (better): on a key-holder's login / team load, detect members present in team_members but absent from team_vault_keys and (re)distribute to them — not just event-diff newcomers.
  3. Optionally server-assisted: let the server flag "N members awaiting keys" so a key-holder client knows to run distribution proactively.

Notes

  • Prod team features are currently gated behind unavailable subscriptions, so this is not user-facing yet — but it should be resolved before those subscriptions go live.
  • Found via a two-account E2E harness (isolated server + two app instances). The rest of the team-vault crypto handoff and live multiplayer/presence/control all worked correctly end-to-end.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions