security: encrypt credentials, caches, and harden lock screen
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
This commit is contained in:
@@ -9,6 +9,7 @@ import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
class BasedBankApp : Application() {
|
||||
|
||||
@@ -24,7 +25,7 @@ class BasedBankApp : Application() {
|
||||
val mibMutex = Mutex()
|
||||
|
||||
val mibLoginFlow by lazy {
|
||||
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
|
||||
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||
flow.onSessionRefreshed = { session, profiles ->
|
||||
mibSession = session
|
||||
mibProfiles = profiles
|
||||
|
||||
@@ -5,14 +5,23 @@ import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
class LockActivity : AppCompatActivity() {
|
||||
|
||||
@@ -22,6 +31,15 @@ class LockActivity : AppCompatActivity() {
|
||||
private lateinit var salt: String
|
||||
private lateinit var storedHash: String
|
||||
private var biometricsEnabled = false
|
||||
private var isLegacyFormat = false
|
||||
private var isVerifying = false
|
||||
|
||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
private const val MAX_ATTEMPTS = 5
|
||||
private const val LOCKOUT_MS = 30_000L
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -30,10 +48,20 @@ class LockActivity : AppCompatActivity() {
|
||||
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||
salt = prefs.getString("security_salt", "") ?: ""
|
||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
|
||||
// Try new encrypted format first; fall back to legacy SHA-256
|
||||
val stored = CredentialStore(this).loadSecurityHash()
|
||||
if (stored != null) {
|
||||
salt = stored.first
|
||||
storedHash = stored.second
|
||||
isLegacyFormat = false
|
||||
} else {
|
||||
salt = prefs.getString("security_salt", "") ?: ""
|
||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
||||
isLegacyFormat = true
|
||||
}
|
||||
|
||||
if (method == "pin") {
|
||||
binding.viewPin.visibility = View.VISIBLE
|
||||
buildNumpad()
|
||||
@@ -48,6 +76,12 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
if (biometricsEnabled) triggerBiometric()
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun buildNumpad() {
|
||||
@@ -92,6 +126,7 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun handleKey(key: String) {
|
||||
if (isVerifying) return
|
||||
when (key) {
|
||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||
@@ -105,13 +140,21 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun verifyPin() {
|
||||
if (checkAndShowLockout()) return
|
||||
val entered = pinDigits.joinToString("")
|
||||
if (verify(entered)) {
|
||||
proceed()
|
||||
} else {
|
||||
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
|
||||
pinDigits.clear()
|
||||
binding.root.postDelayed({ updateDots() }, 1200)
|
||||
pinDigits.clear()
|
||||
updateDots()
|
||||
isVerifying = true
|
||||
lifecycleScope.launch {
|
||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||
isVerifying = false
|
||||
if (ok) {
|
||||
migrateIfNeeded(entered)
|
||||
resetFailures()
|
||||
proceed()
|
||||
} else {
|
||||
showFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,18 +164,82 @@ class LockActivity : AppCompatActivity() {
|
||||
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
|
||||
return
|
||||
}
|
||||
if (verify(pattern.joinToString(""))) {
|
||||
proceed()
|
||||
} else {
|
||||
if (checkAndShowLockout()) {
|
||||
binding.lockPatternView.showError()
|
||||
binding.tvPatternHint.text = getString(R.string.unlock_failed)
|
||||
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
||||
return
|
||||
}
|
||||
val entered = pattern.joinToString("")
|
||||
isVerifying = true
|
||||
lifecycleScope.launch {
|
||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||
isVerifying = false
|
||||
if (ok) {
|
||||
migrateIfNeeded(entered)
|
||||
resetFailures()
|
||||
proceed()
|
||||
} else {
|
||||
binding.lockPatternView.showError()
|
||||
val msg = failureMessage()
|
||||
binding.tvPatternHint.text = msg
|
||||
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true and shows the lockout message if currently locked out. */
|
||||
private fun checkAndShowLockout(): Boolean {
|
||||
val remaining = lockoutRemainingMs()
|
||||
if (remaining <= 0) return false
|
||||
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||
val msg = getString(R.string.unlock_locked_out, secs)
|
||||
binding.tvLockPinDots.text = msg
|
||||
binding.root.postDelayed({ updateDots() }, remaining)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showFailure() {
|
||||
val msg = failureMessage()
|
||||
binding.tvLockPinDots.text = msg
|
||||
binding.root.postDelayed({ updateDots() }, 1200)
|
||||
}
|
||||
|
||||
private fun failureMessage(): String {
|
||||
val fails = incrementFailures()
|
||||
val left = MAX_ATTEMPTS - fails
|
||||
return if (left <= 0) {
|
||||
val secs = (LOCKOUT_MS / 1000L).toInt()
|
||||
getString(R.string.unlock_locked_out, secs)
|
||||
} else {
|
||||
getString(R.string.unlock_attempts_remaining, left)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(input: String): Boolean {
|
||||
val hash = sha256(salt + input)
|
||||
return hash == storedHash
|
||||
if (storedHash.isBlank()) return false
|
||||
return if (isLegacyFormat) {
|
||||
sha256Legacy(salt + input) == storedHash
|
||||
} else {
|
||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||
pbkdf2(input, saltBytes) == storedHash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On the first successful unlock after legacy SHA-256 format is detected,
|
||||
* transparently migrate to PBKDF2 + CredentialStore.
|
||||
*/
|
||||
private fun migrateIfNeeded(input: String) {
|
||||
if (!isLegacyFormat) return
|
||||
try {
|
||||
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val newHash = pbkdf2(input, newSalt)
|
||||
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
|
||||
CredentialStore(this).saveSecurityHash(saltB64, newHash)
|
||||
// Remove legacy plaintext fields
|
||||
getSharedPreferences("prefs", MODE_PRIVATE).edit()
|
||||
.remove("security_salt").remove("security_hash").apply()
|
||||
isLegacyFormat = false
|
||||
} catch (_: Exception) { /* migration will retry next unlock */ }
|
||||
}
|
||||
|
||||
private fun triggerBiometric() {
|
||||
@@ -145,6 +252,7 @@ class LockActivity : AppCompatActivity() {
|
||||
ContextCompat.getMainExecutor(this),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
resetFailures()
|
||||
proceed()
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
@@ -166,11 +274,43 @@ class LockActivity : AppCompatActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||
|
||||
private fun incrementFailures(): Int {
|
||||
val fails = lockPrefs.getInt("fail_count", 0) + 1
|
||||
lockPrefs.edit()
|
||||
.putInt("fail_count", fails)
|
||||
.putLong("last_fail_time", System.currentTimeMillis())
|
||||
.apply()
|
||||
return fails
|
||||
}
|
||||
|
||||
private fun resetFailures() {
|
||||
lockPrefs.edit().remove("fail_count").remove("last_fail_time").apply()
|
||||
}
|
||||
|
||||
private fun lockoutRemainingMs(): Long {
|
||||
val fails = lockPrefs.getInt("fail_count", 0)
|
||||
if (fails < MAX_ATTEMPTS) return 0L
|
||||
val lastFail = lockPrefs.getLong("last_fail_time", 0L)
|
||||
return maxOf(0L, LOCKOUT_MS - (System.currentTimeMillis() - lastFail))
|
||||
}
|
||||
|
||||
// ── Crypto ────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun pbkdf2(input: String, salt: ByteArray): String {
|
||||
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
|
||||
return try {
|
||||
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||
Base64.encodeToString(hash, Base64.NO_WRAP)
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
|
||||
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Lock screen cannot be dismissed
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -69,8 +69,11 @@ class BmlLoginFlow {
|
||||
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
|
||||
|
||||
// Step 2: POST credentials
|
||||
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val loginBody = JSONObject().apply {
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("code", "")
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val loginResp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
@@ -89,8 +92,10 @@ class BmlLoginFlow {
|
||||
|
||||
// Step 4: POST OTP
|
||||
val otp = Totp.generate(otpSeed)
|
||||
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val twoFaBody = JSONObject().apply {
|
||||
put("code", otp)
|
||||
put("channel", "authenticator")
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val twoFaResp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
|
||||
.header("X-XSRF-TOKEN", xsrf2)
|
||||
@@ -740,6 +745,4 @@ class BmlLoginFlow {
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import okhttp3.FormBody
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.random.Random
|
||||
|
||||
class SessionExpiredException : Exception("MIB session expired")
|
||||
|
||||
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
|
||||
private val TAG = "MibLoginFlow"
|
||||
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
||||
|
||||
/** The active session after a successful login, usable for subsequent WebView requests. */
|
||||
@@ -31,7 +32,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
@Volatile private var storedUsername: String? = null
|
||||
@Volatile private var storedPasswordHash: String? = null
|
||||
@Volatile private var storedOtpSeed: String? = null
|
||||
private var inRelogin = false
|
||||
private val inRelogin = AtomicBoolean(false)
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -61,11 +62,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
storedPasswordHash = passwordHash
|
||||
storedOtpSeed = otpSeed
|
||||
val appId = getOrCreateAppId()
|
||||
val key1 = prefs.getString("mib_key1_$username", null)
|
||||
val key2 = prefs.getString("mib_key2_$username", null)
|
||||
val keys = credentialStore.loadMibKeys()
|
||||
|
||||
return if (key1 != null && key2 != null) {
|
||||
regularLogin(username, passwordHash, appId, key1, key2)
|
||||
return if (keys != null) {
|
||||
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
||||
} else {
|
||||
firstTimeRegistration(username, passwordHash, otpSeed, appId)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||
val key1 = keyData.getString("key1")
|
||||
val key2 = keyData.getString("key2")
|
||||
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
|
||||
credentialStore.saveMibKeys(key1, key2)
|
||||
|
||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||
}
|
||||
@@ -189,7 +189,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
val response = try {
|
||||
sendRequest(session, data, sfunc)
|
||||
} catch (e: SessionExpiredException) {
|
||||
if (inRelogin) throw e
|
||||
if (inRelogin.get()) throw e
|
||||
"" // fall through to recovery below
|
||||
}
|
||||
|
||||
@@ -198,22 +198,27 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
(response.trimStart().startsWith("{") &&
|
||||
JSONObject(response).optString("reasonCode") == "505")
|
||||
|
||||
if (isExpired && !inRelogin) {
|
||||
val u = storedUsername ?: throw SessionExpiredException()
|
||||
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
||||
val os = storedOtpSeed ?: throw SessionExpiredException()
|
||||
inRelogin = true
|
||||
try { login(u, ph, os) } finally { inRelogin = false }
|
||||
val newSession = lastSession ?: throw SessionExpiredException()
|
||||
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
||||
// Refresh nonce/xxid in the payload for the retry
|
||||
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
||||
data.put("appId", newSession.appId)
|
||||
data.put("sodium", MibNonce.randomSodium())
|
||||
data.put("xxid", newSession.xxid)
|
||||
val retryResponse = sendRequest(newSession, data, sfunc)
|
||||
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
||||
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
||||
if (isExpired && inRelogin.compareAndSet(false, true)) {
|
||||
try {
|
||||
val u = storedUsername ?: throw SessionExpiredException()
|
||||
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
||||
val os = storedOtpSeed ?: throw SessionExpiredException()
|
||||
login(u, ph, os)
|
||||
val newSession = lastSession ?: throw SessionExpiredException()
|
||||
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
||||
// Refresh nonce/xxid in the payload for the retry
|
||||
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
||||
data.put("appId", newSession.appId)
|
||||
data.put("sodium", MibNonce.randomSodium())
|
||||
data.put("xxid", newSession.xxid)
|
||||
val retryResponse = sendRequest(newSession, data, sfunc)
|
||||
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
||||
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
||||
} finally {
|
||||
inRelogin.set(false)
|
||||
}
|
||||
} else if (isExpired) {
|
||||
throw SessionExpiredException()
|
||||
}
|
||||
|
||||
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
||||
@@ -384,11 +389,11 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||
|
||||
private fun getOrCreateAppId(): String {
|
||||
var id = prefs.getString("mib_app_id", null)
|
||||
var id = credentialStore.loadMibAppId()
|
||||
if (id == null) {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||
prefs.edit().putString("mib_app_id", id).apply()
|
||||
credentialStore.saveMibAppId(id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.mib
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.util.CacheEncryption
|
||||
import java.io.File
|
||||
|
||||
object TransactionCache {
|
||||
@@ -22,7 +23,7 @@ object TransactionCache {
|
||||
put("accountDisplayName", t.accountDisplayName)
|
||||
put("source", t.source)
|
||||
})
|
||||
File(context.cacheDir, "tx_$key.json").writeText(arr.toString())
|
||||
File(context.cacheDir, "tx_$key.json").writeText(CacheEncryption.encrypt(arr.toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ object TransactionCache {
|
||||
}
|
||||
|
||||
fun load(context: Context, key: String): List<Transaction> = try {
|
||||
val arr = JSONArray(File(context.cacheDir, "tx_$key.json").readText())
|
||||
val arr = JSONArray(CacheEncryption.decrypt(File(context.cacheDir, "tx_$key.json").readText()))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
Transaction(
|
||||
|
||||
@@ -56,6 +56,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
private val autolockHandler = Handler(Looper.getMainLooper())
|
||||
private var warningDialog: AlertDialog? = null
|
||||
private var countdownTimer: CountDownTimer? = null
|
||||
private var pauseTime = 0L
|
||||
|
||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||
|
||||
@@ -194,11 +195,24 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||
// QrScannerActivity was in the foreground), lock immediately.
|
||||
if (pauseTime > 0L) {
|
||||
val elapsed = System.currentTimeMillis() - pauseTime
|
||||
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
|
||||
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
|
||||
if (timeout > 0L && elapsed >= timeout && securitySet) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
resetAutolockTimer()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
pauseTime = System.currentTimeMillis()
|
||||
autolockHandler.removeCallbacks(autolockRunnable)
|
||||
autolockHandler.removeCallbacks(warningRunnable)
|
||||
countdownTimer?.cancel(); countdownTimer = null
|
||||
@@ -300,8 +314,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val mibJob = mibCreds?.let {
|
||||
async(Dispatchers.IO) {
|
||||
try {
|
||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
|
||||
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
|
||||
val app = application as BasedBankApp
|
||||
app.accounts = accounts
|
||||
@@ -411,8 +424,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
|
||||
if (session == null || profiles.isEmpty()) return
|
||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
val flow = MibLoginFlow(CredentialStore(this))
|
||||
val contactsClient = MibContactsClient()
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
@@ -465,8 +477,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.mibSession ?: return@withContext null
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
|
||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
||||
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
|
||||
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
|
||||
catch (_: Exception) { null }
|
||||
} ?: return@launch
|
||||
// Replace accounts from this profile only, keep everything else
|
||||
@@ -480,8 +491,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
|
||||
if (session == null || profiles.isEmpty()) return
|
||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
val flow = MibLoginFlow(CredentialStore(this))
|
||||
val client = MibFinancingClient()
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
|
||||
@@ -111,8 +111,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val passwordHash = MibLoginFlow.hashPassword(password)
|
||||
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
val flow = MibLoginFlow(CredentialStore(requireContext()))
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
|
||||
@@ -12,8 +12,10 @@ import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||
import java.security.MessageDigest
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
class SecuritySetupFragment : Fragment() {
|
||||
|
||||
@@ -231,16 +233,25 @@ class SecuritySetupFragment : Fragment() {
|
||||
private fun saveCredential(method: String, input: String) {
|
||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
||||
val hash = sha256(saltB64 + input)
|
||||
val hash = pbkdf2(input, salt)
|
||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
||||
.putString("security_method", method)
|
||||
.putString("security_salt", saltB64)
|
||||
.putString("security_hash", hash)
|
||||
// Remove legacy plaintext fields if they exist from an old install
|
||||
.remove("security_salt")
|
||||
.remove("security_hash")
|
||||
.apply()
|
||||
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
||||
}
|
||||
|
||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
private fun pbkdf2(input: String, salt: ByteArray): String {
|
||||
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
|
||||
return try {
|
||||
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||
Base64.encodeToString(hash, Base64.NO_WRAP)
|
||||
} finally {
|
||||
spec.clearPassword()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishSetup() {
|
||||
val cb = activity as? Callback
|
||||
|
||||
@@ -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)
|
||||
|
||||
55
app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt
Normal file
55
app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt
Normal file
@@ -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)
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
|
||||
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
|
||||
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
|
||||
<string name="unlock_attempts_remaining">ދިމައެއް ނުވި — %d ފަހަރު ބާކީ</string>
|
||||
<string name="unlock_locked_out">ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ.</string>
|
||||
|
||||
<!-- Security setup -->
|
||||
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
|
||||
<string name="biometric_negative_btn">Use PIN / Pattern</string>
|
||||
<string name="unlock_failed">Incorrect — try again</string>
|
||||
<string name="unlock_attempts_remaining">Incorrect — %d attempts remaining</string>
|
||||
<string name="unlock_locked_out">Too many attempts. Try again in %d seconds.</string>
|
||||
|
||||
<!-- Security setup -->
|
||||
<string name="security_setup">Secure Your App</string>
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||
<exclude domain="sharedpref" path="mib_prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="account_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="contacts_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="financing_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||
<exclude domain="cache" path="."/>
|
||||
</full-backup-content>
|
||||
@@ -5,15 +5,27 @@
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||
<exclude domain="sharedpref" path="mib_prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="account_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="contacts_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="financing_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||
<exclude domain="cache" path="."/>
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||
<exclude domain="sharedpref" path="mib_prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="prefs.xml"/>
|
||||
<exclude domain="sharedpref" path="account_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="contacts_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="financing_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||
<exclude domain="cache" path="."/>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -1,6 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">faisanet.mib.com.mv</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
<network-security-config/>
|
||||
|
||||
Reference in New Issue
Block a user