diff --git a/WordPress/src/main/java/org/wordpress/android/fluxc/applicationpasswords/ApplicationPasswordsListenerModule.kt b/WordPress/src/main/java/org/wordpress/android/fluxc/applicationpasswords/ApplicationPasswordsListenerModule.kt new file mode 100644 index 000000000000..c5b6f5454b36 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/fluxc/applicationpasswords/ApplicationPasswordsListenerModule.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.applicationpasswords + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsListener + +@InstallIn(SingletonComponent::class) +@Module +interface ApplicationPasswordsListenerModule { + @Binds + fun bindApplicationPasswordsListener( + listener: WPApplicationPasswordsListener + ): ApplicationPasswordsListener +} diff --git a/WordPress/src/main/java/org/wordpress/android/fluxc/applicationpasswords/WPApplicationPasswordsListener.kt b/WordPress/src/main/java/org/wordpress/android/fluxc/applicationpasswords/WPApplicationPasswordsListener.kt new file mode 100644 index 000000000000..361788fd3edb --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/fluxc/applicationpasswords/WPApplicationPasswordsListener.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.applicationpasswords + +import com.automattic.android.tracks.crashlogging.CrashLogging +import dagger.Lazy +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsListener +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.crashlogging.sendReportWithTag +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WPApplicationPasswordsListener @Inject constructor( + private val crashLogging: Lazy +) : ApplicationPasswordsListener { + override fun onKeystoreError(error: Throwable) { + crashLogging.get().sendReportWithTag(error, AppLog.T.MAIN) + } +} diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt index 95caec60d193..6ff65e3e2130 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt @@ -7,4 +7,9 @@ interface ApplicationPasswordsListener { fun onNewPasswordCreated(isPasswordRegenerated: Boolean) {} fun onPasswordGenerationFailed(networkError: WPAPINetworkError) {} fun onFeatureUnavailable(siteModel: SiteModel, networkError: WPAPINetworkError) {} + + // Hardware-backed Keystore failure (e.g. Tink AndroidKeystoreAesGcm InvalidKeyException) + // when reading or writing the encrypted credentials store. Implementations should treat + // this as a non-fatal so an influx of failures stays visible in crash reporting. + fun onKeystoreError(error: Throwable) {} } diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt index 6c8d75e614e5..2edc4df123c5 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt @@ -8,13 +8,16 @@ import okhttp3.Credentials import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.util.AppLog import org.wordpress.android.util.UrlUtils +import java.security.GeneralSecurityException import java.security.KeyStore +import java.util.Optional import javax.inject.Inject import javax.inject.Singleton @Singleton class ApplicationPasswordsStore @Inject constructor( - private val context: Context + private val context: Context, + private val listener: Optional ) { companion object { private const val USERNAME_PREFERENCE_KEY_PREFIX = "username_" @@ -27,81 +30,154 @@ class ApplicationPasswordsStore @Inject constructor( there. Do not use directly in WCAndroid app. */ fun getApplicationPasswordAuthHeader(site: SiteModel): String = - Credentials.basic( - username = encryptedPreferences.getString(site.usernamePrefKey, null).orEmpty(), - password = encryptedPreferences.getString(site.passwordPrefKey, null).orEmpty() - ) + withEncryptedPrefs(Credentials.basic("", "")) { prefs -> + Credentials.basic( + username = prefs.getString(site.usernamePrefKey, null).orEmpty(), + password = prefs.getString(site.passwordPrefKey, null).orEmpty() + ) + } @Inject internal lateinit var configuration: ApplicationPasswordsConfiguration private val applicationName: String get() = configuration.applicationName - private val encryptedPreferences by lazy { - initEncryptedPrefs() - } + @Volatile + private var encryptedPreferences: SharedPreferences? = null - @Synchronized - internal fun getCredentials(site: SiteModel): ApplicationPasswordCredentials? { - val username = encryptedPreferences.getString(site.usernamePrefKey, null) - val password = encryptedPreferences.getString(site.passwordPrefKey, null) - val uuid = encryptedPreferences.getString(site.uuidPrefKey, null) + // Set to true once initEncryptedPrefs has failed even after the delete+retry path; cleared + // by invalidateEncryptedPrefs() since that gives the next init a fresh keystore alias to + // work with. Without this flag, every read/write after a permanent init failure would + // re-run the expensive delete+retry and emit another Sentry report — turning one broken + // device into hundreds of duplicate non-fatals. + @Volatile + private var initPermanentlyFailed: Boolean = false + @Synchronized + @Suppress("TooGenericExceptionCaught") + private fun loadEncryptedPreferences(): SharedPreferences? { + val cached = encryptedPreferences return when { - !site.isUsingWpComRestApi && site.username != username -> null - username != null && password != null -> - ApplicationPasswordCredentials( - userName = username, - password = password, - uuid = uuid + cached != null -> cached + initPermanentlyFailed -> null + else -> try { + initEncryptedPrefs().also { encryptedPreferences = it } + } catch (e: Exception) { + // Both the initial create and the post-delete retry failed; the Keystore-backed + // master key is unrecoverable on this device (Play Console reports this as + // AndroidKeystoreAesGcm.encryptInternal → InvalidKeyException). + initPermanentlyFailed = true + AppLog.e( + AppLog.T.MAIN, + "Failed to initialise application-password EncryptedSharedPreferences", + e ) - else -> null + reportKeystoreError(e) + null + } } } + @Synchronized + internal fun getCredentials(site: SiteModel): ApplicationPasswordCredentials? = + withEncryptedPrefs(null) { prefs -> + val username = prefs.getString(site.usernamePrefKey, null) + val password = prefs.getString(site.passwordPrefKey, null) + val uuid = prefs.getString(site.uuidPrefKey, null) + + when { + !site.isUsingWpComRestApi && site.username != username -> null + username != null && password != null -> + ApplicationPasswordCredentials( + userName = username, + password = password, + uuid = uuid + ) + else -> null + } + } + @Synchronized fun saveCredentials(site: SiteModel, credentials: ApplicationPasswordCredentials) { - encryptedPreferences.edit() - .putString(site.usernamePrefKey, credentials.userName) - .putString(site.passwordPrefKey, credentials.password) - .putString(site.uuidPrefKey, credentials.uuid) - .apply() + withEncryptedPrefs { prefs -> + prefs.edit() + .putString(site.usernamePrefKey, credentials.userName) + .putString(site.passwordPrefKey, credentials.password) + .putString(site.uuidPrefKey, credentials.uuid) + .apply() + } } @Synchronized fun deleteCredentials(site: SiteModel) { - encryptedPreferences.edit() - .remove(site.usernamePrefKey) - .remove(site.passwordPrefKey) - .remove(site.uuidPrefKey) - .apply() + withEncryptedPrefs { prefs -> + prefs.edit() + .remove(site.usernamePrefKey) + .remove(site.passwordPrefKey) + .remove(site.uuidPrefKey) + .apply() + } } - private fun initEncryptedPrefs(): SharedPreferences { - val keySpec = MasterKeys.AES256_GCM_SPEC - val filename = "$applicationName-encrypted-prefs" - - fun createPrefs(): SharedPreferences { - val masterKey = MasterKeys.getOrCreate(keySpec) - return EncryptedSharedPreferences.create( - filename, - masterKey, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + // Every read/write to EncryptedSharedPreferences ultimately goes through Tink's + // AndroidKeystoreAesGcm, which can fail with InvalidKeyException long after init + // succeeded (e.g. when the hardware-backed key becomes inaccessible after a system + // update or credential change). Treat any failure as "no stored credentials" so the + // caller can re-authenticate instead of crashing, and reset the cached prefs so a + // subsequent access re-initialises a fresh keystore-backed file. + private inline fun withEncryptedPrefs(block: (SharedPreferences) -> Unit) { + withEncryptedPrefs(Unit, block) + } + + @Suppress("TooGenericExceptionCaught") + private inline fun withEncryptedPrefs(default: T, block: (SharedPreferences) -> T): T { + val prefs = loadEncryptedPreferences() ?: return default + return try { + block(prefs) + } catch (e: GeneralSecurityException) { + AppLog.e( + AppLog.T.MAIN, + "Keystore failure while accessing application-password preferences", + e ) + reportKeystoreError(e) + invalidateEncryptedPrefs() + default + } catch (e: Exception) { + AppLog.e( + AppLog.T.MAIN, + "Failed to access application-password preferences", + e + ) + reportKeystoreError(e) + invalidateEncryptedPrefs() + default } + } - fun deletePrefs() { - context.deleteSharedPreferences(filename) - with(KeyStore.getInstance("AndroidKeyStore")) { - load(null) - if (containsAlias(keySpec.keystoreAlias)) { - deleteEntry(keySpec.keystoreAlias) - } - } + private fun reportKeystoreError(error: Throwable) { + listener.ifPresent { it.onKeystoreError(error) } + } + + @Synchronized + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun invalidateEncryptedPrefs() { + encryptedPreferences = null + // Files + keystore alias are about to be deleted, so the next init runs against a + // clean slate and deserves another attempt before we declare permanent failure. + initPermanentlyFailed = false + try { + deleteEncryptedPrefsFiles() + } catch (e: Exception) { + AppLog.e( + AppLog.T.MAIN, + "Failed to delete application-password preferences during recovery", + e + ) } + } + private fun initEncryptedPrefs(): SharedPreferences { // The documentation recommends excluding the file from auto backup, but since the file // is defined in an internal library, adding to the backup rules and maintaining them won't // be straightforward. @@ -109,18 +185,43 @@ class ApplicationPasswordsStore @Inject constructor( // We simply delete it and create a new one. @Suppress("TooGenericExceptionCaught", "SwallowedException") return try { - createPrefs() + createEncryptedPrefs() } catch (e: Exception) { // In case we can't decrypt the file after a backup, let's delete it AppLog.d( AppLog.T.MAIN, "Can't decrypt encrypted preferences, delete it and create new one" ) - deletePrefs() - createPrefs() + deleteEncryptedPrefsFiles() + createEncryptedPrefs() } } + private fun createEncryptedPrefs(): SharedPreferences { + val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + return EncryptedSharedPreferences.create( + encryptedPrefsFilename, + masterKey, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private fun deleteEncryptedPrefsFiles() { + context.deleteSharedPreferences(encryptedPrefsFilename) + with(KeyStore.getInstance("AndroidKeyStore")) { + load(null) + val alias = MasterKeys.AES256_GCM_SPEC.keystoreAlias + if (containsAlias(alias)) { + deleteEntry(alias) + } + } + } + + private val encryptedPrefsFilename: String + get() = "$applicationName-encrypted-prefs" + private val SiteModel.domainName get() = UrlUtils.removeScheme(url).trim('/')