aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--app/build.gradle.kts1
-rw-r--r--app/src/main/java/app/passwordstore/Application.kt2
-rw-r--r--app/src/main/java/app/passwordstore/data/crypto/PGPPassphraseCache.kt52
-rw-r--r--app/src/main/java/app/passwordstore/injection/prefs/PreferenceModule.kt22
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt6
-rw-r--r--app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt188
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt22
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt61
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/Migrations.kt36
-rw-r--r--app/src/test/java/app/passwordstore/util/crypto/KeystorePreferencesTest.kt87
-rw-r--r--gradle/libs.versions.toml1
12 files changed, 376 insertions, 108 deletions
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. <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"))
+ }
+}
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"