Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -677,10 +677,25 @@ export default function EnvironmentPath({
[deleteFolder, params.environment, secretPath]
)

// Which environment's keys are derived. Keyed off wrappedSeed, not `data`,
// which gets a fresh reference on every poll tick.
const derivedForSeedRef = useRef<string | null>(null)

useEffect(() => {
const initEnvKeys = async () => {
const wrappedSeed = data.environmentKeys[0].wrappedSeed
if (!data || !keyring) return

// Optional chaining: an empty environmentKeys must not throw synchronously.
const wrappedSeed = data.environmentKeys[0]?.wrappedSeed
if (!wrappedSeed) return
if (derivedForSeedRef.current === wrappedSeed) return

// `ignore` drops a derivation the user has already navigated away from.
// The ref is cleared with envKeys so a cancelled pass cannot leave it stale.
let ignore = false
derivedForSeedRef.current = null
setEnvKeys(null)
Comment thread
omlahore marked this conversation as resolved.

const initEnvKeys = async () => {
const userKxKeys = {
publicKey: await getUserKxPublicKey(keyring!.publicKey),
privateKey: await getUserKxPrivateKey(keyring!.privateKey),
Expand All @@ -694,18 +709,32 @@ export default function EnvironmentPath({
)
const { publicKey, privateKey } = await envKeyring(seed)

setEnvKeys({
publicKey,
privateKey,
salt,
})
if (!ignore) {
derivedForSeedRef.current = wrappedSeed
setEnvKeys({
publicKey,
privateKey,
salt,
})
}
}

if (data && keyring) initEnvKeys()
initEnvKeys()

return () => {
ignore = true
}
}, [data, keyring])

useEffect(() => {
if (data && envKeys) {
// envKeys can be one render behind data, so being non-null is not proof it
// belongs to this environment. Check the derived seed rather than the order.
const currentWrappedSeed = data?.environmentKeys[0]?.wrappedSeed
const envKeysAreCurrent =
currentWrappedSeed !== undefined && derivedForSeedRef.current === currentWrappedSeed

if (data && envKeys && envKeysAreCurrent) {
let ignore = false
setDecrypting(true)
const decryptSecrets = async () => {
const decryptedStaticSecrets = await Promise.all(
Expand Down Expand Up @@ -811,13 +840,26 @@ export default function EnvironmentPath({
return { decryptedStaticSecrets, decryptedDynamicSecrets }
}

decryptSecrets().then((decryptedSecrets) => {
setServerSecrets(decryptedSecrets.decryptedStaticSecrets)
setClientSecrets(decryptedSecrets.decryptedStaticSecrets)
setDynamicSecrets(decryptedSecrets.decryptedDynamicSecrets)
setDecrypting(false)
setSecretsLoaded(true)
})
decryptSecrets()
.then((decryptedSecrets) => {
if (ignore) return
setServerSecrets(decryptedSecrets.decryptedStaticSecrets)
setClientSecrets(decryptedSecrets.decryptedStaticSecrets)
setDynamicSecrets(decryptedSecrets.decryptedDynamicSecrets)
setDecrypting(false)
setSecretsLoaded(true)
})
.catch((error) => {
// Without this the mismatch rejection was unhandled and left
// `decrypting` stuck at true until a reload.
if (ignore) return
console.error('Failed to decrypt secrets:', error)
setDecrypting(false)
})

return () => {
ignore = true
}
}
}, [envKeys, data])

Expand Down
37 changes: 37 additions & 0 deletions frontend/tests/utils/crypto/environmentKeyRace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @jest-environment node
*/

/*
👆
overrides: testEnvironment: 'jsdom' in jest.config.js
to fix: ReferenceError: TextDecoder is not defined
*/

// decryptAsymmetric must reject on a mismatched keypair, not return garbage.
// That is the property the environment-switch fix relies on.

import { decryptAsymmetric, encryptAsymmetric, randomKeyPair } from '@/utils/crypto'

const toHexKeyPair = (keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }) => ({
publicKey: Buffer.from(keyPair.publicKey).toString('hex'),
privateKey: Buffer.from(keyPair.privateKey).toString('hex'),
})

describe("Environment key race (decrypting one environment's secrets with another's keys)", () => {
test('decrypting with a mismatched keypair rejects rather than returning garbage', async () => {
const envA = toHexKeyPair(await randomKeyPair())
const envB = toHexKeyPair(await randomKeyPair())

const ciphertext = await encryptAsymmetric('super-secret-value', envA.publicKey)

// What the page did when envKeys still held B's keys and data was A's.
await expect(
decryptAsymmetric(ciphertext, envB.privateKey, envB.publicKey)
).rejects.toBeDefined()

// The matching keypair still works, so the rejection is the mismatch.
const decrypted = await decryptAsymmetric(ciphertext, envA.privateKey, envA.publicKey)
expect(decrypted).toBe('super-secret-value')
})
})
Loading