fix(frontend): stop environment switches from decrypting with stale keys - #970
fix(frontend): stop environment switches from decrypting with stale keys#970omlahore wants to merge 2 commits into
Conversation
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.
|
Self-review caught a regression in my own patch, now pushed as a follow-up commit. Hoisting the Every other call site in the codebase indexes const wrappedSeed = data.environmentKeys[0]?.wrappedSeed
if (!wrappedSeed) returnThe |
What happened
Switching environments (the environment tabs use a plain
<Link>, so the page stays mounted and onlydatachanges) can leave the page stuck on "Decrypting..." until reloaded.app/[team]/apps/[app]/environments/[environment]/[[...path]]/page.tsxhas two effects:envKeysasynchronously fromdata.environmentKeys[0].wrappedSeed(severalawaited decrypt calls).dataandenvKeysare both set, decrypts every secret indata.secretswithenvKeys.The second effect fires on the same render where
datachanges to the new environment, before the first effect's async derivation has any chance to resolve. At that pointenvKeysstill holds the previous environment's keys. It decrypts the new environment's ciphertext with the old environment's keypair, whichdecryptAsymmetricrejects (verified in the added test, it does not silently return garbage).That rejection went to
decryptSecrets().then(...)with no.catch(). An unhandled rejection, sosetDecrypting(false)was never reached anddecryptingstayedtrue.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
envKeyswith the wrongdataeven once the promise settles.The fix
wrappedSeedthe currentenvKeyswere derived from (derivedForSeedRef), and skip re-deriving when it's unchanged. This matters becauseGetSecretspolls every 5s (pollInterval: unsavedChanges ? 0 : 5000), so keying key derivation directly offdatawould re-derive and flashenvKeystonullon every idle poll tick, not just on real environment switches.ignoreflag so a slower-resolving derivation for an environment the user has since left can't land after a newer one, regardless of resolution order.envKeysagainst that same tracked seed before running, rather than assuming the deriving effect'ssetEnvKeys(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..catch(), so a genuine decrypt failure surfaces to the console and clearsdecryptinginstead of hanging forever.Testing
tests/utils/crypto/environmentKeyRace.test.tsuses 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 intests/utils/crypto/follow the same pattern of testing the crypto layer directly rather than mounting pages, so I matched that.tsc --noEmitandeslintare clean on both changed files (eslint reports one pre-existing warning at line 489 unrelated to this change, confirmed present onmainbefore 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.