Skip to content

fix(frontend): stop environment switches from decrypting with stale keys - #970

Open
omlahore wants to merge 2 commits into
phasehq:mainfrom
omlahore:fix/environment-switch-key-race
Open

fix(frontend): stop environment switches from decrypting with stale keys#970
omlahore wants to merge 2 commits into
phasehq:mainfrom
omlahore:fix/environment-switch-key-race

Conversation

@omlahore

Copy link
Copy Markdown

What happened

Switching environments (the environment tabs use a plain <Link>, so the page stays mounted and only data changes) can leave the page stuck on "Decrypting..." until reloaded.

app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsx has two effects:

  1. Derives envKeys asynchronously from data.environmentKeys[0].wrappedSeed (several awaited decrypt calls).
  2. Once data and envKeys are both set, decrypts every secret in data.secrets with envKeys.

The second effect fires on the same render where data changes to the new environment, before the first effect's async derivation has any chance to resolve. At that point envKeys still holds the previous environment's keys. It decrypts the new environment's ciphertext with the old environment's keypair, which decryptAsymmetric rejects (verified in the added test, it does not silently return garbage).

That rejection went to decryptSecrets().then(...) with no .catch(). An unhandled rejection, so setDecrypting(false) was never reached and decrypting stayed true.

There's a second, narrower case: switching to a third environment before the second one's key derivation resolves could let it land after the third's, pairing envKeys with the wrong data even once the promise settles.

The fix

  • Track which environment's wrappedSeed the current envKeys were derived from (derivedForSeedRef), and skip re-deriving when it's unchanged. This matters because GetSecrets polls every 5s (pollInterval: unsavedChanges ? 0 : 5000), so keying key derivation directly off data would re-derive and flash envKeys to null on every idle poll tick, not just on real environment switches.
  • The deriving effect gets a standard ignore flag so a slower-resolving derivation for an environment the user has since left can't land after a newer one, regardless of resolution order.
  • The decrypting effect independently checks envKeys against that same tracked seed before running, rather than assuming the deriving effect's setEnvKeys(null) is visible to it in the same pass. I didn't want to build correctness on an assumption about React's effect-batching order between two separate effects that I couldn't verify without the app running end to end, so this effect verifies the pairing itself either way.
  • Added the missing .catch(), so a genuine decrypt failure surfaces to the console and clears decrypting instead of hanging forever.

Testing

tests/utils/crypto/environmentKeyRace.test.ts uses the real crypto primitives (randomKeyPair, encryptAsymmetric, decryptAsymmetric) to prove the underlying failure mode: decrypting with a mismatched keypair rejects rather than returning garbage, which is why the race above surfaces as a stuck page rather than silently wrong data.

I didn't mount the page component itself. It needs Apollo's MockedProvider, Next navigation, and the keyring context, none of which are set up in this test suite, and building that harness felt disproportionate to the fix. The existing tests in tests/utils/crypto/ follow the same pattern of testing the crypto layer directly rather than mounting pages, so I matched that.

tsc --noEmit and eslint are clean on both changed files (eslint reports one pre-existing warning at line 489 unrelated to this change, confirmed present on main before my edit too).

Note for reviewers

Five other open PRs touch this same file (#968, #956, #935, #592, #474), none of them near these two effects as far as I could tell, but flagging it since a merge conflict is likely depending on ordering. Happy to rebase.

Switching environments keeps this page mounted; only `data` changes. The
environment's decryption keys are derived asynchronously from the new
data's wrapped seed, but the secrets-decrypting effect fires on the same
render that `data` changes, before that derivation has any chance to
resolve. It ran with the new environment's ciphertext and the previous
environment's still-current envKeys, which decryptAsymmetric rejects.

That rejection went to `decryptSecrets().then(...)` with no `.catch()`,
an unhandled rejection, so `setDecrypting(false)` was never reached and
the page was stuck on "Decrypting..." until reloaded. Switching to a
third environment before the second's key derivation resolved could also
let it land after the third's, pairing envKeys with the wrong data even
once the promise settled.

The GetSecrets query above also polls every 5s, so keying the key
derivation off `data` directly would re-derive and clear envKeys on every
idle poll tick, not just on real environment switches. Track which
environment's wrapped seed the current envKeys were derived from instead,
so an unrelated poll refresh of the same environment is a no-op.

The decrypting effect independently checks envKeys against that same
tracked seed before running, rather than assuming the deriving effect's
state update is visible to it in the same pass, since the two effects'
execution order relative to a state update from one of them is not
something to build correctness on. It also gets the missing `.catch()`.

Fixes the page getting stuck on "Decrypting..." after switching
environments, and the narrower case of a secret from one environment
being decrypted with another environment's keys during a fast multi-hop
switch.
…hrow

Hoisting the wrappedSeed read out of the async function to compare it
against the ref changed what an empty environmentKeys array does: it was
a rejected promise from inside the async fn, and became a synchronous
throw from the effect body, which unmounts the page via the error
boundary. Read it with optional chaining and bail instead, so this fix
does not alter that failure mode as a side effect.
@omlahore

Copy link
Copy Markdown
Author

Self-review caught a regression in my own patch, now pushed as a follow-up commit.

Hoisting the wrappedSeed read out of initEnvKeys so it could be compared against the ref quietly changed what an empty environmentKeys array does. It used to sit inside the async function, so environmentKeys[0].wrappedSeed on an empty array became a rejected promise. Moved into the effect body it became a synchronous throw, which unmounts the page through the error boundary instead.

Every other call site in the codebase indexes environmentKeys[0] without a guard, so I don't think this array is ever actually empty in practice. But changing a failure mode as a side effect of an unrelated race fix isn't something I want in this PR, so it now reads with optional chaining and bails:

const wrappedSeed = data.environmentKeys[0]?.wrappedSeed
if (!wrappedSeed) return

The wrappedSalt read further down still uses a bare index, but it's still inside the async function where it always was, so its behaviour is unchanged by this PR and I've left it alone.

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