diff options
Diffstat (limited to 'app')
10 files changed, 370 insertions, 107 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cd79be78..e04126cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,7 +69,6 @@ dependencies { implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerviewSelection) - implementation(libs.androidx.security) implementation(libs.androidx.swiperefreshlayout) implementation(libs.compose.ui.tooling) implementation(libs.dagger.hilt.android) diff --git a/app/src/main/java/app/passwordstore/Application.kt b/app/src/main/java/app/passwordstore/Application.kt index 8eb421f4..bda61265 100644 --- a/app/src/main/java/app/passwordstore/Application.kt +++ b/app/src/main/java/app/passwordstore/Application.kt @@ -22,6 +22,7 @@ import app.passwordstore.util.git.sshj.setUpBouncyCastleForSshj import app.passwordstore.util.proxy.ProxyUtils import app.passwordstore.util.settings.GitSettings import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.settings.removeSecurityCryptoData import app.passwordstore.util.settings.runMigrations import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp @@ -61,6 +62,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere prefs.registerOnSharedPreferenceChangeListener(this) setNightMode() setUpBouncyCastleForSshj() + removeSecurityCryptoData(this, prefs) runMigrations(filesDirPath, prefs, gitSettings) proxyUtils.setDefaultProxy() DynamicColors.applyToActivitiesIfAvailable(this) diff --git a/app/src/main/java/app/passwordstore/data/crypto/PGPPassphraseCache.kt b/app/src/main/java/app/passwordstore/data/crypto/PGPPassphraseCache.kt index d49b1584..bba4e1db 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/PGPPassphraseCache.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/PGPPassphraseCache.kt @@ -6,21 +6,37 @@ package app.passwordstore.data.crypto import android.content.Context +import android.security.keystore.KeyPermanentlyInvalidatedException import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.crypto.KeystorePreferences +import app.passwordstore.util.crypto.keystoreAesKey +import app.passwordstore.util.crypto.requireUserAuthentication import app.passwordstore.util.extensions.getString +import java.security.KeyStore import javax.inject.Inject import kotlinx.coroutines.withContext -/** Implements a rudimentary [EncryptedSharedPreferences]-backed cache for GPG passphrases. */ +/** + * Caches PGP passphrases in [KeystorePreferences] under a key that needs the screen lock or a + * strong biometric within the last 60 seconds. + */ class PGPPassphraseCache @Inject constructor(private val dispatcherProvider: DispatcherProvider) { suspend fun cachePassphrase(context: Context, identifier: PGPIdentifier, passphrase: String) { withContext(dispatcherProvider.io()) { - getPreferences(context).edit { putString(identifier.toString(), passphrase) } + try { + getPreferences(context).edit { putString(identifier.toString(), passphrase) } + } catch (_: KeyPermanentlyInvalidatedException) { + // Enrolling a new biometric or removing the screen lock invalidates the key for good; + // start over with a fresh key, dropping passphrases that can no longer be decrypted. + KeyStore.getInstance("AndroidKeyStore").apply { load(null) }.deleteEntry(PREFS_NAME) + getPreferences(context).edit { + clear() + putString(identifier.toString(), passphrase) + } + } } } @@ -40,31 +56,13 @@ class PGPPassphraseCache @Inject constructor(private val dispatcherProvider: Dis withContext(dispatcherProvider.io()) { getPreferences(context).edit { clear() } } } - private suspend fun getPreferences(context: Context) = - withContext(dispatcherProvider.io()) { - EncryptedSharedPreferences.create( - context, - ANDROIDX_SECURITY_KEYSET_PREF_NAME, - getOrCreateWrappingMasterKey(context), - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - - private suspend fun getOrCreateWrappingMasterKey(context: Context) = - withContext(dispatcherProvider.io()) { - MasterKey.Builder(context, "passphrase") - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .setRequestStrongBoxBacked(true) - .setUserAuthenticationRequired( - /* authenticationRequired = */ true, - /* userAuthenticationValidityDurationSeconds = */ 60, - ) - .build() + private fun getPreferences(context: Context) = + KeystorePreferences(context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)) { + keystoreAesKey(PREFS_NAME, strongBox = true) { requireUserAuthentication(60) } } - private companion object { + companion object { - private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_passphrase_keyset_prefs" + const val PREFS_NAME = "passphrase_cache" } } diff --git a/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt b/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt index 39728594..b050888b 100644 --- a/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt +++ b/app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt @@ -8,9 +8,9 @@ package app.passwordstore.injection.prefs import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import app.passwordstore.BuildConfig +import app.passwordstore.util.crypto.keystorePreferences +import app.passwordstore.util.extensions.getEncryptedGitPrefs import dagger.Module import dagger.Provides import dagger.Reusable @@ -22,21 +22,9 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) class PreferenceModule { - private fun createEncryptedPreferences(context: Context, fileName: String): SharedPreferences { - val masterKeyAlias = - MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( - context, - fileName, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - @[Provides PasswordGeneratorPreferences Reusable] fun providePwgenPreferences(@ApplicationContext context: Context): SharedPreferences { - return createEncryptedPreferences(context, "pwgen_preferences") + return context.getSharedPreferences("pwgen", MODE_PRIVATE) } @[Provides SettingsPreferences Reusable] @@ -46,11 +34,11 @@ class PreferenceModule { @[Provides GitPreferences Reusable] fun provideGitPreferences(@ApplicationContext context: Context): SharedPreferences { - return createEncryptedPreferences(context, "git_operation") + return context.getEncryptedGitPrefs() } @[Provides ProxyPreferences Reusable] fun provideProxyPreferences(@ApplicationContext context: Context): SharedPreferences { - return createEncryptedPreferences(context, "http_proxy") + return context.keystorePreferences("proxy_secrets") } } diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt index 3bdf6f57..d2e63779 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -94,10 +94,8 @@ class PGPSettings( defaultValue = true /* Clear the cache once when unchecking; this is to prevent a malicious user (someone * knowing the screen-lock pin, but not knowing the PGP passphrase) from bypassing cache - * clearing via the settings. However, clearing EncryptedSharedPreferences requires - * authentication, otherwise the app crashes. Thus, the bad user could still bypass cache - * clearing by dismissing the auhentication dialog. To prevent this, we enforce cache - * clearing to stay enabled in case of any authentication failure. */ + * clearing via the settings. Disabling it requires authentication, and cache clearing + * stays enabled in case of any authentication failure. */ onClick { if (!checked) { if ( diff --git a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt new file mode 100644 index 00000000..f4d43e95 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.crypto + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import logcat.asLog +import logcat.logcat + +private const val TRANSFORMATION = "AES/GCM/NoPadding" +private const val IV_SIZE = 12 +private const val TAG_BITS = 128 + +/** Encrypts [plaintext] with AES-GCM under [key]; the output is the random IV then ciphertext. */ +fun seal(key: SecretKey, plaintext: ByteArray, aad: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + // The provider picks a random IV; Android Keystore refuses caller-chosen ones. + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(aad) + return cipher.iv + cipher.doFinal(plaintext) +} + +/** Reverses [seal]; throws [GeneralSecurityException] if [blob] or [aad] was altered. */ +fun unseal(key: SecretKey, blob: ByteArray, aad: ByteArray): ByteArray { + if (blob.size < IV_SIZE) throw GeneralSecurityException("Ciphertext too short") + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, blob, 0, IV_SIZE)) + cipher.updateAAD(aad) + return cipher.doFinal(blob, IV_SIZE, blob.size - IV_SIZE) +} + +/** + * Returns the AES-256 key stored in Android Keystore under [alias], generating it with [configure] + * applied if it does not exist. [configure] has no effect on an existing key. + */ +fun keystoreAesKey( + alias: String, + strongBox: Boolean = false, + configure: KeyGenParameterSpec.Builder.() -> Unit = {}, +): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(alias, null) as? SecretKey)?.let { + return it + } + fun generate(useStrongBox: Boolean): SecretKey { + val spec = + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(256) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .apply(configure) + .apply { + if (useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(true) + } + } + .build() + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init(spec) + generateKey() + } + } + if (!strongBox || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return generate(false) + return try { + generate(true) + } catch (_: StrongBoxUnavailableException) { + generate(false) + } +} + +/** + * Requires the device credential or a Class 3 biometric within the last [seconds] before the key + * can be used, the only two factors Android Keystore accepts for authentication-bound keys. + */ +fun KeyGenParameterSpec.Builder.requireUserAuthentication(seconds: Int) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters( + seconds, + KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, + ) + } else { + @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(seconds) + } +} + +/** + * [SharedPreferences] whose values are encrypted with AES-GCM under [key], each bound to its entry + * name so values cannot be swapped between entries. A value that fails to decrypt (tampered, or its + * key was invalidated or needs authentication) reads as absent. + * + * ponytail: entry names are stored in plaintext, and string sets and [getAll] are unsupported; the + * app stores only scalar values under fixed names. Add them if a caller needs them. + */ +class KeystorePreferences( + private val backing: SharedPreferences, + private val key: () -> SecretKey, +) : SharedPreferences by backing { + + private fun read(name: String): String? { + val stored = backing.getString(name, null) ?: return null + return try { + unseal(key(), Base64.getDecoder().decode(stored), name.encodeToByteArray()).decodeToString() + } catch (e: GeneralSecurityException) { + logcat { e.asLog() } + null + } catch (e: IllegalArgumentException) { + logcat { e.asLog() } + null + } + } + + override fun getString(name: String, defValue: String?) = read(name) ?: defValue + + override fun getInt(name: String, defValue: Int) = read(name)?.toIntOrNull() ?: defValue + + override fun getLong(name: String, defValue: Long) = read(name)?.toLongOrNull() ?: defValue + + override fun getFloat(name: String, defValue: Float) = read(name)?.toFloatOrNull() ?: defValue + + override fun getBoolean(name: String, defValue: Boolean) = + read(name)?.toBooleanStrictOrNull() ?: defValue + + override fun getStringSet(name: String, defValues: Set<String>?): Set<String>? = + throw UnsupportedOperationException() + + override fun getAll(): Map<String, *> = throw UnsupportedOperationException() + + override fun edit(): SharedPreferences.Editor = Editor(backing.edit()) + + private inner class Editor(private val editor: SharedPreferences.Editor) : + SharedPreferences.Editor by editor { + + private fun put(name: String, value: Any?) = apply { + if (value == null) { + editor.remove(name) + } else { + val blob = seal(key(), value.toString().encodeToByteArray(), name.encodeToByteArray()) + editor.putString(name, Base64.getEncoder().encodeToString(blob)) + } + } + + override fun putString(name: String, value: String?) = put(name, value) + + override fun putInt(name: String, value: Int) = put(name, value) + + override fun putLong(name: String, value: Long) = put(name, value) + + override fun putFloat(name: String, value: Float) = put(name, value) + + override fun putBoolean(name: String, value: Boolean) = put(name, value) + + override fun putStringSet(name: String, values: Set<String>?) = + throw UnsupportedOperationException() + + override fun remove(name: String) = apply { editor.remove(name) } + + override fun clear() = apply { editor.clear() } + } +} + +/** + * [KeystorePreferences] in the private preferences file [name], under a Keystore key of the same + * alias. + */ +fun Context.keystorePreferences(name: String): SharedPreferences { + val key by lazy { keystoreAesKey(name) } + return KeystorePreferences(applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE)) { + key + } +} diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt index 415b02a9..a13484c6 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -25,11 +25,10 @@ import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.crypto.keystorePreferences import app.passwordstore.util.git.operation.GitOperation import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result @@ -44,21 +43,10 @@ val Context.autofillManager: AutofillManager? val Context.clipboard get() = getSystemService<ClipboardManager>() -/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */ -fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") - -/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */ -private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { - val masterKeyAlias = - MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - return EncryptedSharedPreferences.create( - applicationContext, - fileName, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) -} +/** The Keystore-encrypted preferences holding Git credentials. */ +fun Context.getEncryptedGitPrefs() = keystorePreferences(GIT_SECRETS_PREFS) + +const val GIT_SECRETS_PREFS = "git_secrets" /** Get an instance of [KeyguardManager] */ val Context.keyguardManager: KeyguardManager diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 99e600bf..63975cc5 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -14,10 +14,12 @@ import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties import android.util.Base64 import androidx.core.content.edit -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.MasterKey import app.passwordstore.Application import app.passwordstore.R +import app.passwordstore.util.crypto.keystoreAesKey +import app.passwordstore.util.crypto.requireUserAuthentication +import app.passwordstore.util.crypto.seal +import app.passwordstore.util.crypto.unseal import app.passwordstore.util.extensions.getEncryptedGitPrefs import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.sharedPrefs @@ -35,7 +37,6 @@ import java.security.PublicKey import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat @@ -49,7 +50,7 @@ import net.schmizz.sshj.userauth.keyprovider.KeyProvider private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" private const val KEYSTORE_ALIAS = "sshkey" -private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" +private val WRAPPED_KEY_AAD = "ssh_key".encodeToByteArray() private val androidKeystore: KeyStore by unsafeLazy { KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } @@ -174,10 +175,6 @@ object SshKey { private fun delete() { androidKeystore.deleteEntry(KEYSTORE_ALIAS) - // Remove Tink key set used by AndroidX's EncryptedFile. - context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { - clear() - } if (privateKeyFile.isFile) { privateKeyFile.delete() } @@ -229,42 +226,30 @@ object SshKey { type = Type.Imported } + /** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */ + @Deprecated("To be used only in Migrations.kt") + fun deleteSecurityCryptoWrappedKey() { + if (type == Type.KeystoreWrappedEd25519) delete() + } + @Deprecated("To be used only in Migrations.kt") fun useLegacyKey(isGenerated: Boolean) { type = if (isGenerated) Type.LegacyGenerated else Type.Imported } - private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = - withContext(Dispatchers.IO) { - MasterKey.Builder(context, KEYSTORE_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .setRequestStrongBoxBacked(true) - .setUserAuthenticationRequired(requireAuthentication, 15) - .build() - } - - private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = - withContext(Dispatchers.IO) { - EncryptedFile.Builder( - context, - privateKeyFile, - getOrCreateWrappingMasterKey(requireAuthentication), - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB, - ) - .setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) - .build() - } - suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { delete() - val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) + val wrappingKey = + keystoreAesKey(KEYSTORE_ALIAS, strongBox = true) { + if (requireAuthentication) requireUserAuthentication(15) + } // Generate the ed25519 key pair and encrypt the private key. val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() - encryptedPrivateKeyFile.openFileOutput().use { os -> - os.write((keyPair.private as EdDSAPrivateKey).seed) - } + privateKeyFile.writeBytes( + seal(wrappingKey, (keyPair.private as EdDSAPrivateKey).seed, WRAPPED_KEY_AAD) + ) // Write public key in SSH format to .ssh_key.pub. publicKeyFile.writeText(toSshPublicKey(keyPair.public)) @@ -349,14 +334,8 @@ object SshKey { override fun getPrivate(): PrivateKey = runCatching { - // The current MasterKey API does not allow getting a reference to an existing - // one - // without specifying the KeySpec for a new one. However, the value for passed - // here - // for `requireAuthentication` is not used as the key already exists at this - // point. - val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) } - val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + val rawPrivateKey = + unseal(keystoreAesKey(KEYSTORE_ALIAS), privateKeyFile.readBytes(), WRAPPED_KEY_AAD) EdDSAPrivateKey( EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC) ) diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 29582d5f..06075655 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -6,16 +6,20 @@ package app.passwordstore.util.settings +import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import app.passwordstore.util.extensions.getString import app.passwordstore.util.git.sshj.SshKey import com.github.michaelbull.result.get +import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching import java.io.File import java.net.URI +import java.security.KeyStore import logcat.LogPriority.ERROR import logcat.LogPriority.INFO +import logcat.asLog import logcat.logcat private const val TAG = "Migrations" @@ -30,6 +34,38 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti removeCurrentBranchValue(sharedPrefs) } +/** + * Deletes everything androidx security-crypto stored, which this app can no longer read since it + * moved to its own Keystore AES-GCM storage: Git and proxy credentials, password generator + * settings, cached PGP passphrases, the Tink keysets and their Keystore master keys, and a + * generated Ed25519 SSH key. Users re-enter credentials and regenerate an Ed25519 key once. + */ +fun removeSecurityCryptoData(context: Context, sharedPrefs: SharedPreferences) { + if (sharedPrefs.getBoolean(SECURITY_CRYPTO_REMOVED, false)) return + logcat(TAG, INFO) { "Removing data stored by androidx security-crypto" } + runCatching { + SshKey.deleteSecurityCryptoWrappedKey() + listOf( + "git_operation", + "http_proxy", + "pwgen_preferences", + "androidx_passphrase_keyset_prefs", + "androidx_sshkey_keyset_prefs", + "__androidx_security_crypto_encrypted_file_pref__", + ) + .forEach { context.deleteSharedPreferences(it) } + KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + deleteEntry("_androidx_security_master_key_") + deleteEntry("passphrase") + } + } + .onFailure { e -> logcat(TAG, ERROR) { e.asLog() } } + sharedPrefs.edit { putBoolean(SECURITY_CRYPTO_REMOVED, true) } +} + +private const val SECURITY_CRYPTO_REMOVED = "security_crypto_removed" + fun removeCurrentBranchValue(sharedPrefs: SharedPreferences) { if (!sharedPrefs.contains(PreferenceKeys.GIT_BRANCH_NAME)) { return diff --git a/app/src/test/java/app/passwordstore/util/crypto/KeystorePreferencesTest.kt b/app/src/test/java/app/passwordstore/util/crypto/KeystorePreferencesTest.kt new file mode 100644 index 00000000..86681064 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/crypto/KeystorePreferencesTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.crypto + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import java.util.Base64 +import javax.crypto.KeyGenerator +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KeystorePreferencesTest { + + private lateinit var backing: SharedPreferences + private lateinit var prefs: SharedPreferences + private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + @BeforeTest + fun setup() { + backing = SPMockBuilder().createSharedPreferences() + prefs = KeystorePreferences(backing) { key } + } + + @Test + fun roundTripsStringsAndInts() { + prefs.edit { + putString("password", "hunter2") + putInt("port", 8080) + } + assertEquals("hunter2", prefs.getString("password", null)) + assertEquals(8080, prefs.getInt("port", -1)) + } + + @Test + fun returnsDefaultsWhenAbsent() { + assertNull(prefs.getString("password", null)) + assertEquals(-1, prefs.getInt("port", -1)) + assertFalse(prefs.contains("password")) + } + + @Test + fun storesNoPlaintext() { + prefs.edit { putString("password", "hunter2") } + val stored = backing.getString("password", null)!! + assertFalse(stored.contains("hunter2")) + prefs.edit { putString("other", "hunter2") } + assertNotEquals(stored, backing.getString("other", null), "IVs must be random") + } + + @Test + fun rejectsValueMovedToAnotherName() { + prefs.edit { putString("password", "hunter2") } + backing.edit { putString("username", backing.getString("password", null)) } + assertNull(prefs.getString("username", null)) + } + + @Test + fun rejectsTamperedValue() { + prefs.edit { putString("password", "hunter2") } + val bytes = Base64.getDecoder().decode(backing.getString("password", null)) + bytes[bytes.size - 1] = (bytes.last().toInt() xor 1).toByte() + backing.edit { putString("password", Base64.getEncoder().encodeToString(bytes)) } + assertNull(prefs.getString("password", null)) + } + + @Test + fun removeAndClear() { + prefs.edit { + putString("a", "1") + putString("b", "2") + } + prefs.edit { remove("a") } + assertFalse(prefs.contains("a")) + assertTrue(prefs.contains("b")) + prefs.edit { clear() } + assertFalse(prefs.contains("b")) + } +} |
