security: encrypt credentials, caches, and harden lock screen
Auto Tag on Version Change / check-version (push) Successful in 6s

This commit is contained in:
2026-05-15 18:35:14 +05:00
parent 106004421e
commit fc031f1f2a
20 changed files with 506 additions and 149 deletions
@@ -32,7 +32,7 @@ object AccountCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB, arr.toString()).apply()
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
}
fun saveBml(context: Context, accounts: List<MibAccount>) {
@@ -55,13 +55,14 @@ object AccountCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML, arr.toString()).apply()
.edit().putString(KEY_BML, CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(context: Context): List<MibAccount> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -90,9 +91,10 @@ object AccountCache {
}
fun load(context: Context): List<MibAccount> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -0,0 +1,55 @@
package sh.sar.basedbank.util
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Shared AES-256/GCM encryption for cache SharedPreferences and files.
* Uses the same AndroidKeyStore key as CredentialStore so no extra key material is created.
*/
internal object CacheEncryption {
private const val KEY_ALIAS = "basedbank_credential_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
fun encrypt(plaintext: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
}
fun decrypt(encoded: String): String {
val colon = encoded.indexOf(':')
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
val spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.also { it.init(spec) }
.generateKey()
}
}
@@ -37,7 +37,7 @@ object ContactsCache {
put("profileId", c.profileId)
})
}
prefs.putString(KEY_CONTACTS, contactsArr.toString())
prefs.putString(KEY_CONTACTS, CacheEncryption.encrypt(contactsArr.toString()))
val catArr = JSONArray()
for (cat in categories) {
@@ -47,7 +47,7 @@ object ContactsCache {
put("numBenef", cat.numBenef)
})
}
prefs.putString(KEY_CATEGORIES, catArr.toString())
prefs.putString(KEY_CATEGORIES, CacheEncryption.encrypt(catArr.toString()))
prefs.apply()
}
@@ -56,9 +56,10 @@ object ContactsCache {
}
fun loadContacts(context: Context): List<MibBeneficiary> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CONTACTS, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -101,13 +102,14 @@ object ContactsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString("bml_contacts", arr.toString()).apply()
.edit().putString("bml_contacts", CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(context: Context): List<MibBeneficiary> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("bml_contacts", null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -130,9 +132,10 @@ object ContactsCache {
}
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -4,6 +4,7 @@ import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -19,6 +20,8 @@ class CredentialStore(context: Context) {
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
// ── MIB login credentials ─────────────────────────────────────────────────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
@@ -42,9 +45,7 @@ class CredentialStore(context: Context) {
decrypt(encHash, key),
decrypt(encSeed, key)
)
} catch (e: Exception) {
null
}
} catch (_: Exception) { null }
}
fun clearMibCredentials() {
@@ -52,9 +53,44 @@ class CredentialStore(context: Context) {
.remove("mib_enc_username")
.remove("mib_enc_password_hash")
.remove("mib_enc_otp_seed")
.remove("mib_enc_key1")
.remove("mib_enc_key2")
.remove("mib_enc_app_id")
.apply()
}
// ── MIB session keys (key1/key2) and app ID ───────────────────────────────
fun saveMibKeys(key1: String, key2: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("mib_enc_key1", encrypt(key1, key))
.putString("mib_enc_key2", encrypt(key2, key))
.apply()
}
fun loadMibKeys(): Pair<String, String>? {
val key = getOrCreateKey()
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null
return try {
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
} catch (_: Exception) { null }
}
fun saveMibAppId(id: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply()
}
fun loadMibAppId(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_app_id", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
// ── BML login credentials ─────────────────────────────────────────────────
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
val key = getOrCreateKey()
prefs.edit()
@@ -71,7 +107,7 @@ class CredentialStore(context: Context) {
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
return try {
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
} catch (e: Exception) { null }
} catch (_: Exception) { null }
}
fun clearBmlCredentials() {
@@ -82,6 +118,8 @@ class CredentialStore(context: Context) {
.apply()
}
// ── BML session token ─────────────────────────────────────────────────────
fun saveBmlSession(accessToken: String, deviceId: String) {
val key = getOrCreateKey()
prefs.edit()
@@ -106,6 +144,39 @@ class CredentialStore(context: Context) {
.apply()
}
// ── Security credential (PIN / pattern hash) ──────────────────────────────
/**
* Stores the PBKDF2-derived hash and its salt, encrypted with the AndroidKeyStore key.
* The lock method ("pin"/"pattern") remains in the app's "prefs" SharedPreferences and
* is not stored here.
*/
fun saveSecurityHash(salt: String, hash: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("security_enc_salt", encrypt(salt, key))
.putString("security_enc_hash", encrypt(hash, key))
.apply()
}
fun loadSecurityHash(): Pair<String, String>? {
val key = getOrCreateKey()
val encSalt = prefs.getString("security_enc_salt", null) ?: return null
val encHash = prefs.getString("security_enc_hash", null) ?: return null
return try {
Pair(decrypt(encSalt, key), decrypt(encHash, key))
} catch (_: Exception) { null }
}
fun clearSecurityHash() {
prefs.edit()
.remove("security_enc_salt")
.remove("security_enc_hash")
.apply()
}
// ── User profile (PII) ────────────────────────────────────────────────────
data class MibUserProfile(
val fullName: String,
val username: String,
@@ -123,54 +194,89 @@ class CredentialStore(context: Context) {
val birthdate: String
)
fun saveMibFullName(name: String) = prefs.edit().putString("mib_full_name", name).apply()
fun loadMibFullName(): String? = prefs.getString("mib_full_name", null)
fun saveMibFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
}
fun saveBmlFullName(name: String) = prefs.edit().putString("bml_full_name", name).apply()
fun loadBmlFullName(): String? = prefs.getString("bml_full_name", null)
fun loadMibFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveBmlFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("bml_enc_full_name", encrypt(name, key)).apply()
}
fun loadBmlFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveMibUserProfile(p: MibUserProfile) {
prefs.edit().putString("mib_full_name", p.fullName)
.putString("mib_profile_username", p.username)
.putString("mib_profile_email", p.email)
.putString("mib_profile_mobile", p.mobile)
.putString("mib_profile_enrolled", p.enrolled)
.apply()
val json = JSONObject().apply {
put("fullName", p.fullName)
put("username", p.username)
put("email", p.email)
put("mobile", p.mobile)
put("enrolled", p.enrolled)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply()
// Keep the name in sync with the fast-path field
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
}
fun loadMibUserProfile(): MibUserProfile? {
val name = prefs.getString("mib_full_name", null) ?: return null
return MibUserProfile(
fullName = name,
username = prefs.getString("mib_profile_username", "") ?: "",
email = prefs.getString("mib_profile_email", "") ?: "",
mobile = prefs.getString("mib_profile_mobile", "") ?: "",
enrolled = prefs.getString("mib_profile_enrolled", "") ?: ""
)
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_profile", null) ?: return null
return try {
val o = JSONObject(decrypt(enc, key))
MibUserProfile(
fullName = o.optString("fullName"),
username = o.optString("username"),
email = o.optString("email"),
mobile = o.optString("mobile"),
enrolled = o.optString("enrolled")
)
} catch (_: Exception) { null }
}
fun saveBmlUserProfile(p: BmlUserProfile) {
prefs.edit().putString("bml_full_name", p.fullName)
.putString("bml_profile_email", p.email)
.putString("bml_profile_mobile", p.mobile)
.putString("bml_profile_customer_id", p.customerId)
.putString("bml_profile_idcard", p.idCard)
.putString("bml_profile_birthdate", p.birthdate)
.apply()
val json = JSONObject().apply {
put("fullName", p.fullName)
put("email", p.email)
put("mobile", p.mobile)
put("customerId", p.customerId)
put("idCard", p.idCard)
put("birthdate", p.birthdate)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("bml_enc_profile", encrypt(json, key)).apply()
prefs.edit().putString("bml_enc_full_name", encrypt(p.fullName, key)).apply()
}
fun loadBmlUserProfile(): BmlUserProfile? {
val name = prefs.getString("bml_full_name", null) ?: return null
return BmlUserProfile(
fullName = name,
email = prefs.getString("bml_profile_email", "") ?: "",
mobile = prefs.getString("bml_profile_mobile", "") ?: "",
customerId = prefs.getString("bml_profile_customer_id", "") ?: "",
idCard = prefs.getString("bml_profile_idcard", "") ?: "",
birthdate = prefs.getString("bml_profile_birthdate", "") ?: ""
)
val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_profile", null) ?: return null
return try {
val o = JSONObject(decrypt(enc, key))
BmlUserProfile(
fullName = o.optString("fullName"),
email = o.optString("email"),
mobile = o.optString("mobile"),
customerId = o.optString("customerId"),
idCard = o.optString("idCard"),
birthdate = o.optString("birthdate")
)
} catch (_: Exception) { null }
}
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
@@ -193,16 +299,16 @@ class CredentialStore(context: Context) {
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray())
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
}
private fun decrypt(encoded: String, key: SecretKey): String {
val parts = encoded.split(":")
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
val colon = encoded.indexOf(':')
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
}
@@ -31,7 +31,7 @@ object FinancingCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB, arr.toString()).apply()
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -39,9 +39,10 @@ object FinancingCache {
}
fun load(context: Context): List<MibFinanceDeal> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
@@ -39,7 +39,7 @@ object ForeignLimitsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -47,9 +47,10 @@ object ForeignLimitsCache {
}
fun load(context: Context): List<HomeViewModel.BmlLimitsData> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val entry = arr.getJSONObject(i)
@@ -37,7 +37,7 @@ object RecentsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun remove(context: Context, accountNumber: String) {
@@ -54,7 +54,7 @@ object RecentsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -62,9 +62,10 @@ object RecentsCache {
}
fun load(context: Context): List<RecentPick> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)