Skip to content

Switching accounts breaks the team vault key: wrapped_user_secrets is a global keychain slot, not per-account #228

Description

@kipavy

Symptom

Switch back to an account through the quick switcher and its team vaults stop openingCouldn't load team vault · Something went wrong. Try again. Try again re-runs the identical failure. A webview reload does not help. Signing out completely and signing back in with the master password does fix it.

Found while live-testing #68 with two cloud accounts on an isolated server.

Repro

  1. Register cloud account A. Convert a private vault to a team vault (so A holds a wrapped team vault key).
  2. Account menu → Add another account… → register cloud account B.
  3. Account menu → switch back to A.
  4. Open A's team vault.

Observed at step 4:

  • GET /v1/teams/<id>/vault-key returns 200 — the wrapped key is served fine.
  • The DB is consistent: A's row in team_vault_keys is wrapped_by = A, and A's users.public_key is the key it was wrapped to.
  • The client still fails, so the break is local: the wrapped key cannot be unwrapped.

Mechanism

wrapped_user_secrets is a single, un-namespaced keychain slot, and the switcher does not carry it per account.

  1. createServerAccount (src/services/account.ts) generates a random DEK + an x25519 private key, wraps both under the KEK (deriveKeys(password, account_id)), and writes keychainSet("wrapped_user_secrets", …). The vault key becomes the DEK. Registering B overwrites A's value.
  2. SESSION_KEYS (src/services/savedAccounts.ts:18) is account_id, mode, master_password, email, server_url, jwt, refresh_token. wrapped_user_secrets is absent, so switchToAccount restores everything except the thing needed to recover the DEK.
  3. switchToAccount reloads, and the reload runs autoLogin — documented "local Tauri calls only — autoLogin stays instant offline". It calls passwordVaultKey(kek)adoptUserSecrets(A_kek, <B's wrapped secrets>) → unwrap fails → falls back to keyThatOpensVault(kek).
  4. multiplayerService.getMyX25519Keypair() derives the keypair from getVaultKey(). A different vault key means a different x25519 private key, so unwrapSessionKey(wrapped_key, wrapper.public_key) fails, getTeamVaultKey throws "error", and the panel shows the generic failure.

The healing asymmetry is the confirming evidence: login()'s reauth branch (account.ts:320-328) re-fetches wrapped_user_secrets from the server, unwraps it with the correct KEK, and rewrites the keychain slot. autoLogin never contacts the server. That is exactly why a reload does nothing and a full password sign-in heals it.

Secondary problems this exposes

  • The error is unactionable. Something went wrong. Try again re-runs the same failing unwrap. Nothing hints that signing out and back in is the fix. A vault that cannot be unwrapped locally is a different state from a transient load error and deserves its own copy.
  • Two sources for one x25519 identity. useVaultKeysStore holds an x25519Private recovered from the wrapped secrets, but getMyX25519Keypair ignores it and re-derives from the vault key. autoLogin populates neither, so after a switch the store is empty and only the derived one is in play.
  • ⚠️ Possible self-worsening — not observed, flagging for review. sync.ts's completeTeamLoginSetup calls getMyX25519Keypair() then updatePublicKey(publicKey) unconditionally (best-effort, errors swallowed). In the broken state that would publish a public key derived from the wrong vault key. Since getTeamVaultKey resolves the wrapper's key from the roster, the roster would then advertise a wrong key for that user. In my run A's users.public_key was still correct and a fresh sign-in healed everything, so I did not see this fire — but the call is unguarded, and it is worth confirming it cannot overwrite a good published key with a derived-from-wrong-DEK one.

Suggested fix

Namespace wrapped_user_secrets per account (or add it to SESSION_KEYS so saveCurrentAccount / switchToAccount carry it like every other per-account value). Either way autoLogin then adopts the right secrets and derives the right identity offline.

Worth pairing with: refuse to publish a public key when the DEK was not successfully adopted, rather than pushing a derived-from-fallback key.

Confidence

The observations are direct: 200 from the server, consistent DB rows, symptom only after a switch, reload no help, fresh sign-in heals, and Grant now working immediately afterwards. The mechanism above is derived from reading the code, not instrumented — a log of the derived key bytes before and after a switch would confirm it. The isolated test containers have since been removed.

🤖 Generated with Claude Code

https://claude.ai/code/session_014gY5k7XbeR5zoTXdzsuWxF

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingteam-uxTeam & collaboration UX flows (sessions, vaults, invites, presence)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions