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 4 commits into
Conversation
|
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 |
|
@rohan-chaturvedi all four of these are parked at If someone can approve the runs I will act on whatever they turn up. Separately I am closing #971 myself, the premise I gave for it did not survive my own recheck. |
Sorry I haven't gotten the chance to review these yet. I should hopefully have some bandwidth in the coming week. The CI runs just run the test suites and builds, all of which can be run locally as well. Maybe rebase the branches against main, and I'll take a look at them as soon as I can. |
87f065c to
3a106d0
Compare
|
Rebased all three on main, they were 29 commits behind: #970, #972, #973. Clean, no conflicts. Ran the CI test jobs locally against each rebased head. |
3a106d0 to
853d68e
Compare
|
Rebased all three onto main, they sit on c27c290 now. Since CI has never run on any of them, I ran the frontend suite locally: #970 is 18 suites / 386 tests passing, #973 is 17 / 385. #972 is backend only, four files under One note on #973. The account page commit in main also touched |
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.
Switching A -> B and back to A before B's derivation resolved left the ref holding A's seed while envKeys was null, so the effect early-returned on the seed match and never re-derived. Also trims the comments in this file and the test suite.
853d68e to
4ca41e1
Compare
|
Applied the suggestion. The ref is cleared alongside Comments trimmed in both the page and the test file. Frontend suite passes locally: 18 suites, 386 tests. Rebased on main. |
|
The red X is the image build, not the code. |
1 similar comment
|
The red X is the image build, not the code. |
The comments on the two effects and on the decrypt catch had grown to four and six lines each, restating the reasoning the code already shows. Cut each to the part that is not obvious from reading it: why the ref is cleared with envKeys, why being non-null is not proof envKeys matches data, and what the catch is for. Same for the block comment on the race test.
|
Both points are in. The ref reset landed in 4ca41e1: Comments are trimmed in a3b882a. The two effect comments were four and six lines and the decrypt catch was another four, all of them restating what the code shows. Each is down to the part that is not obvious from reading it: why the ref is cleared alongside envKeys, why envKeys being non-null is not proof it belongs to the current environment, and what the catch prevents. Same treatment on the block comment in the race test. Note the CI red is not from this branch. Every job fails at |
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.