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
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<CrashLogging>
) : ApplicationPasswordsListener {
override fun onKeystoreError(error: Throwable) {
crashLogging.get().sendReportWithTag(error, AppLog.T.MAIN)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApplicationPasswordsListener>
) {
companion object {
private const val USERNAME_PREFERENCE_KEY_PREFIX = "username_"
Expand All @@ -27,100 +30,198 @@ 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 <T> withEncryptedPrefs(default: T, block: (SharedPreferences) -> T): T {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned that we're making this crash go away in Sentry at the cost of having no visibility into future keystore issues.

The right approach if the keystore is unreadable/corrupt seems to be to invalidate the contents and spin up a new one (thus requiring the user to reauthenticate everything) – that seems like a good fix. But I'd expect to then see some baseline of users who experience a failure, have their keystore reset, and carry on.

Dropping all future keystore errors on the floor doesn't seem like a good idea though, if there's suddenly an influx of issues in this area we have no way to know until users reach out?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — you're right that the logging doesn't make it to Sentry, so as written this would have made future Keystore issues invisible. With Claude's help I've pushed a follow-up that addresses both halves of your comment:

  1. Visibility: added onKeystoreError(Throwable) to ApplicationPasswordsListener and wired a WordPress-side implementation (WPApplicationPasswordsListener) that forwards to CrashLogging.sendReportWithTag(...) as a non-fatal. Every catch site in ApplicationPasswordsStore (init-time and per-access) now notifies the listener before returning the safe default, so a sudden spike still shows up in Sentry.

  2. Recovery: on a per-access keystore failure, the store now calls invalidateEncryptedPrefs() — deletes the prefs file and the keystore alias, and nulls the cached SharedPreferences reference. The next call re-runs initEncryptedPrefs(), which gets a fresh master key. So the expected steady state matches what you described: failure → keystore reset → user re-authenticates → carries on, and we see the non-fatal in Sentry.

The init-time path already had the delete+retry, but if both attempts failed it would degrade silently forever; that's now reported too.

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.
// So instead, we use a destructive approach, if we can't decrypt the file after restoring it,
// 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('/')

Expand Down
Loading