Skip to content

fix(teams): keep the team identity across password changes and linked devices - #640

Merged
kipavy merged 12 commits into
devfrom
fix/identity-off-vault-key
Oct 14, 2026
Merged

kipavy merged 12 commits into
devfrom
fix/identity-off-vault-key

Conversation

@kipavy

@kipavy kipavy commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the password-change bugs left open after #368, ahead of #629.

Problem

The team X25519 identity was derive_x25519_keypair(getVaultKey()), so it depended on which key a device used for its vault:

  • A cloud-created account uses the DEK everywhere, so it was fine.
  • An account that started local and was linked later used the KEK on the linking device and the DEK everywhere else.

That caused three bugs:

  1. A password change moved the vault to the DEK, so the identity changed and team vault keys stopped unwrapping.
  2. After a password change made elsewhere (or on the portal), the linking device's vault was still on the old KEK, so it landed on the recovery screen.
  3. Other devices of a linked or migrated account never had the published identity (identityUnproven from the first sign-in).

Change

  • src/services/identity.ts, ensureIdentity().
    • The identity is the x25519_private stored in the wrapped secrets once its public key matches the roster.
    • Otherwise it is whichever derived candidate (vault key, KEK, DEK) matches. The stored copy is then repaired with a compare-and-swap upload.
    • Repairs only happen on servers that advertise wrapped_secrets_cas (VoltiusApp/server feat/wrapped-secrets-cas).
    • The expected value is read before the roster fetch. A session check runs before every side effect, so a lookup that outlives an account switch writes nothing.
    • Team crypto (getMyX25519Keypair) goes through it. The Switching accounts breaks the team vault key: wrapped_user_secrets is a global keychain slot, not per-account #228 publish guard is unchanged.
  • settleVaultOnDek().
    • Once the identity is stored, a vault still on the KEK is moved to the DEK.
    • It runs at the start of every sign-in sync, including non-Pro accounts without teams and devices with sync paused.
    • A paused device does not upload.
  • linkToCloud now creates the account like a cloud-created one: identity HKDF(DEK), vault on the DEK.
  • changeMasterPassword wraps the proven identity under the new key.
  • login. When neither new key opens the vault, it tries the password this device stored before the sign-in. A sealed secret is read only then, with one biometric prompt. If that works, the vault moves to the DEK. Any failure ends in the usual VaultUnreadableError.
  • Bound devices after a password change elsewhere. If the typed password does not unwrap this device's cached secrets, the sign-in seals it anew once the vault is open (one biometric prompt). Until now rememberPassword kept the old sealed password, so the next biometric unlock failed. Cancelling that prompt stores the new password plain, which is the existing vault_secret_set behaviour shared with changeMasterPassword.
  • New Tauri command x25519_public_from_private.
  • Shared helpers. deriveX25519Keypair, wrapUserSecrets and base64ToByteArray now have one definition each.

Verification

  • Full vitest sweep: 757 files, 6219 tests. tsc, cargo fmt and clippy are clean.
  • End to end against a server with the CAS, using accounts created and linked on the previous client (d02a81b) and then upgraded:
    • Linking device upgraded first. One repair PUT; public_key unchanged; secrets.enc moved to the DEK (the DEK opens it, the KEK no longer does); host password intact; team vault opens. A second device signed in afterwards also opens the team vault.
    • Password changed with the typed path on another device first. That device keeps team access and public_key is unchanged. The old linking device, upgraded and signed in with the new password, opened its vault with no recovery screen, kept its host password and opened the team vault. Its stale repair attempt was refused with 409.
    • Not covered. An old client that is running while the password changes elsewhere was not tested; the device was off. The token-revoked path was forced by invalidating the access token, because refresh revocation only bites after the 1 h access token expires.

Known limits

  • Old client changing the password during rollout. If a device still on an older client changes the password after the linking device has settled onto the DEK, that client wraps its random stored key. The linked account then loses its team identity, which is the same outcome as today's bug 1. A possible follow-up: keep a vault-local copy of the proven key as an extra candidate.
  • Rollback. Once a linking device has settled onto the DEK, a client below this branch on that device derives HKDF(DEK), which is not the published key. Team features fail there until it is upgraded again.
  • Deferred:
    • re-reading /auth/me after a 409;
    • a pre-existing race in secrets_rekey, which reads the file outside its lock;
    • no timeout on settle;
    • team-vault key errors are labelled key_mismatch when the roster fetch fails;
    • the recovery's biometric prompt reuses the app-lock reason string.

Server: VoltiusApp/server#111 must be deployed first for repairs to happen. Without it the client works but stays on today's behaviour.

kipavy added 11 commits October 14, 2026 15:05
The identity was derived from whatever key opened this device's vault, so
linked and migrated accounts had a different one on every device that used
the DEK. The stored x25519 key now wins once the roster confirms it, and a
device that finds the right derived key writes it back.
Snapshot the session before the roster lookup and re-check it around the
write, never write without a captured wrapped value, keep a stored key when
the roster has none, log repair failures, and share the derive/wrap commands
with account.ts.
A vault on a password-derived key became unopenable after a password
change made on another device. With the identity stored in the wrapped
secrets, the vault can follow the dek like every cloud-created account.
…nt switch

Settling ran only from the team login setup, which non-Pro accounts and
paused devices never reach. It now runs first in the login syncs and on a
paused launch, and gives up when the session changed while it waited.
The device that linked an account kept its vault on the old password's
key, so a change made on another device or on the portal left it on the
recovery screen. The password this device stored before the sign-in still
derives that key.
… unpublished

- A stranded-device recovery that fails at any step (derive, read error,
  rekey) restores the session key it found and ends in VaultUnreadableError.
- pushUnderNewVaultKey skips the upload while sync is paused; resuming pushes.
- An unpublished roster resolves to HKDF(DEK), stored unless already held.
- A stored private key the backend rejects is passed over, not fatal.
- The identity store write skips the in-memory keys after an account switch
  during the keychain write.
- base64ToByteArray moves to utils/base64 and replaces the inline copies;
  the identity import in account.ts goes through one helper.
A bound device kept the old password sealed after signing in with one
changed on another device: rememberPassword leaves a sealed secret alone,
so the next biometric unlock failed and the device-verified password
change read a stale password. When the typed password does not unwrap
this device's cached secrets, it is sealed anew once the vault is open.
@kipavy
kipavy merged commit 3cb83b0 into dev Oct 14, 2026
5 checks passed
@kipavy
kipavy deleted the fix/identity-off-vault-key branch October 14, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant