From 3764acb7ec710cfd46761f994b4e8c4c2cd34346 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Sun, 23 Aug 2026 22:25:21 +0800 Subject: [PATCH] fix: isolate login credentials across providers --- .../abox/manager/ui/login/LoginScreenTest.kt | 23 +++++++++++++++++++ .../abox/manager/ui/login/LoginScreen.kt | 10 ++++++-- .../abox/manager/ui/login/LoginViewModel.kt | 15 ++++++++++-- .../AndroidKeystoreCredentialCipherTest.kt | 7 ++++++ .../AndroidKeystoreCredentialCipher.kt | 2 ++ .../manager/session/CredentialStoreTest.kt | 23 +++++++++++++++++++ 6 files changed, 76 insertions(+), 4 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt index 49f928c..451585b 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt @@ -53,6 +53,29 @@ class LoginScreenTest { assertEquals(2, switches) } + @Test + fun systemBackIsConsumedWhileSubmitting() { + var switches = 0 + composeRule.setContent { + ABoxTheme { + LoginScreen( + profile = profile, + state = LoginUiState(isSubmitting = true), + onUsernameChange = {}, + onPasswordChange = {}, + onRememberUsernameChange = {}, + onAutoLoginChange = {}, + onSubmit = {}, + onSwitchProvider = { switches++ }, + ) + } + } + + composeRule.activity.onBackPressedDispatcher.onBackPressed() + composeRule.waitForIdle() + assertEquals(0, switches) + } + @Test fun backControlsAreDisabledWhileSubmitting() { composeRule.setContent { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index b65ae1d..8981b99 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -48,6 +48,10 @@ fun LoginRoute( ) { LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } val state = viewModel.uiState.collectAsStateWithLifecycle().value + val switchProvider = { + viewModel.clearSensitiveState() + onSwitchProvider() + } LoginScreen( profile = profile, state = state.copy( @@ -58,7 +62,7 @@ fun LoginRoute( onRememberUsernameChange = viewModel::setRememberUsername, onAutoLoginChange = viewModel::setAutoLogin, onSubmit = viewModel::submit, - onSwitchProvider = onSwitchProvider, + onSwitchProvider = switchProvider, switchingProvider = actionInProgress, ) } @@ -77,7 +81,9 @@ fun LoginScreen( switchingProvider: Boolean = false, ) { val navigationEnabled = !state.isSubmitting && !switchingProvider - BackHandler(enabled = navigationEnabled, onBack = onSwitchProvider) + BackHandler { + if (navigationEnabled) onSwitchProvider() + } Scaffold( topBar = { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index eecd8ab..9ebef78 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -29,11 +29,15 @@ class LoginViewModel @Inject constructor( private var loadingJob: Job? = null fun loadProfile(value: ProviderProfile) { - if (profile?.id == value.id && loadingJob != null) return + val profileChanged = profile?.id != value.id + if (!profileChanged && loadingJob != null) return + loadingJob?.cancel() profile = value + _uiState.value = LoginUiState() loadingJob = viewModelScope.launch { val preferences = profileStore.loginPreferences(value.id) - _uiState.value = _uiState.value.copy( + if (profile?.id != value.id) return@launch + _uiState.value = LoginUiState( username = preferences.rememberedUsername?.value.orEmpty(), rememberUsername = preferences.rememberUsername, autoLogin = preferences.autoLoginEnabled, @@ -41,6 +45,13 @@ class LoginViewModel @Inject constructor( } } + fun clearSensitiveState() { + loadingJob?.cancel() + loadingJob = null + profile = null + _uiState.value = LoginUiState() + } + fun setUsername(value: String) { _uiState.value = _uiState.value.copy(username = value, errorMessage = null) } diff --git a/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt index e67e936..2b92890 100644 --- a/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt @@ -24,4 +24,11 @@ class AndroidKeystoreCredentialCipherTest { assertArrayEquals(plaintext, cipher.decrypt(profile, first)) assertArrayEquals(plaintext, cipher.decrypt(profile, second)) } + + @Test(expected = IllegalArgumentException::class) + fun rejectsTruncatedPayloadBeforeReadingHeader() { + AndroidKeystoreCredentialCipher( + keyAlias = "abox_manager_test_${System.nanoTime()}", + ).decrypt(ProviderProfileId("profile"), byteArrayOf(1)) + } } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt index e865be9..13b4f7c 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt @@ -27,6 +27,7 @@ class AndroidKeystoreCredentialCipher( } override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { + require(encrypted.size >= HEADER_SIZE) { "Credential payload is truncated" } val buffer = ByteBuffer.wrap(encrypted) require(buffer.get() == VERSION) { "Unsupported credential format" } val ivLength = buffer.get().toInt() and 0xFF @@ -63,6 +64,7 @@ class AndroidKeystoreCredentialCipher( private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val KEY_SIZE_BITS = 256 private const val GCM_TAG_BITS = 128 + private const val HEADER_SIZE = 2 private const val VERSION: Byte = 1 } } diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index b185166..dd9a204 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -33,6 +33,29 @@ class CredentialStoreTest { assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value) } + @Test + fun malformedCredentialIsRemoved() = runTest { + val file = File.createTempFile("malformed-credentials", ".preferences_pb").apply { delete() } + val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file } + val profile = ProviderProfileId("profile") + val store = KeystoreCredentialStore(dataStore, RejectingCipher()) + store.saveCredentials( + profile, + LoginCredentials(Username("demo"), Password("secret")), + ) + + assertNull((store.loadCredentials(profile) as DomainResult.Success).value) + assertNull((store.loadCredentials(profile) as DomainResult.Success).value) + } + + private class RejectingCipher : CredentialCipher { + override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = byteArrayOf(1) + + override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray { + throw IllegalArgumentException("Credential payload is truncated") + } + } + private class ReversingCipher : CredentialCipher { override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray = (profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()