From 52b19a8c77af7adcb661865cebf8532e605b8d77 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 18:57:26 +0100 Subject: feat(crypto): replace androidx security-crypto with Keystore AES-GCM security-crypto is deprecated in 1.1.0 and the app was stuck on 1.1.0-alpha06, since the stable release fails the warnings-as-errors build. It also pulled in Tink for what is a single AES-GCM operation. KeystorePreferences is a SharedPreferences whose values are AES-GCM sealed under an Android Keystore key (random IV, entry name as AAD so values cannot be swapped between entries); a value that fails to decrypt reads as absent. It backs the Git credentials (git_secrets), the proxy credentials (proxy_secrets) and the PGP passphrase cache, whose key needs the screen lock or a strong biometric within 60s and is recreated if a new biometric enrollment invalidates it. Wrapped Ed25519 SSH keys are sealed the same way into the private key file. Password generator settings are not secret and move to plain preferences. Nothing is migrated: removeSecurityCryptoData deletes the old encrypted preference files, Tink keysets, their Keystore master keys and a security-crypto wrapped Ed25519 key once on upgrade. Only 0.1.x installs from the last few hours have such data. Hardware SSH keys and imported keys are untouched. Tests: KeystorePreferencesTest (round trip, defaults, no plaintext, random IVs, AAD binding, tamper rejection, remove/clear) with a software AES key. Verified on the emulator by upgrading a 0.1.2 debug build that held a wrapped Ed25519 key, Tink keysets and a cached passphrase: the old data was deleted, a new Ed25519 key authenticated over SSH after a fingerprint unlock, and a passphrase cached on first decrypt was reused on the second with only the fingerprint. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + app/build.gradle.kts | 1 - app/src/main/java/app/passwordstore/Application.kt | 2 + .../data/crypto/PGPPassphraseCache.kt | 52 +++--- .../injection/prefs/PreferenceModule.kt | 22 +-- .../app/passwordstore/ui/settings/PGPSettings.kt | 6 +- .../util/crypto/KeystorePreferences.kt | 188 +++++++++++++++++++++ .../util/extensions/AndroidExtensions.kt | 22 +-- .../java/app/passwordstore/util/git/sshj/SshKey.kt | 61 +++---- .../app/passwordstore/util/settings/Migrations.kt | 36 ++++ .../util/crypto/KeystorePreferencesTest.kt | 87 ++++++++++ gradle/libs.versions.toml | 1 - 12 files changed, 376 insertions(+), 108 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt create mode 100644 app/src/test/java/app/passwordstore/util/crypto/KeystorePreferencesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index da463832..9fd427b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Changed + +- Credentials, the passphrase cache and generated Ed25519 SSH keys are now encrypted with Android Keystore AES-GCM directly, replacing the deprecated androidx security-crypto library (and Tink). **Upgrading from 0.1.x deletes the old data once**: re-enter Git HTTPS and proxy credentials and SSH key passphrases, retype cached PGP passphrases, and regenerate a generated Ed25519 SSH key (hardware RSA/ECDSA keys and imported keys are kept). Password generator settings reset to defaults + ## [0.1.2] - 2026-11-02 ### Fixed 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. + * 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?): Set? = + throw UnsupportedOperationException() + + override fun getAll(): Map = 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?) = + 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() -/** 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. + * 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")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56a83a3d..7a7398a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,6 @@ androidx-material = "com.google.android.material:material:1.12.0" androidx-preference = "androidx.preference:preference:1.2.1" androidx-recyclerview = "androidx.recyclerview:recyclerview:1.4.0" androidx-recyclerviewSelection = "androidx.recyclerview:recyclerview-selection:1.2.0" -androidx-security = "androidx.security:security-crypto-ktx:1.1.0-alpha06" androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.3.4" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" -- cgit v1.2.3