From 81a2be150fba95a2e39b196c4085766a82344d0a Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 12 May 2026 09:53:43 +0500 Subject: [PATCH] add app lock secruity and support for saving mib credentials --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 6 + .../java/sh/sar/basedbank/LockActivity.kt | 168 ++++++++++ .../java/sh/sar/basedbank/MainActivity.kt | 7 +- .../sh/sar/basedbank/api/mib/MibLoginFlow.kt | 39 ++- .../basedbank/ui/login/CredentialsFragment.kt | 5 +- .../sar/basedbank/ui/login/LoginActivity.kt | 37 ++- .../ui/onboarding/OnboardingActivity.kt | 54 ++- .../ui/onboarding/OnboardingPagerAdapter.kt | 6 +- .../basedbank/ui/onboarding/PatternView.kt | 120 +++++++ .../ui/onboarding/SecuritySetupFragment.kt | 233 +++++++++++++ .../sh/sar/basedbank/util/CredentialStore.kt | 90 +++++ app/src/main/res/layout/activity_lock.xml | 122 +++++++ app/src/main/res/layout/activity_login.xml | 25 ++ .../res/layout/fragment_security_setup.xml | 311 ++++++++++++++++++ app/src/main/res/values-b+dv/strings.xml | 34 ++ app/src/main/res/values/strings.xml | 34 ++ 17 files changed, 1259 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/LockActivity.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt create mode 100644 app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt create mode 100644 app/src/main/res/layout/activity_lock.xml create mode 100644 app/src/main/res/layout/fragment_security_setup.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 24ce419..a9f712a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,9 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + // Biometric authentication + implementation("androidx.biometric:biometric:1.1.0") + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8235bda..822d33e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + + + diff --git a/app/src/main/java/sh/sar/basedbank/LockActivity.kt b/app/src/main/java/sh/sar/basedbank/LockActivity.kt new file mode 100644 index 0000000..ecd0151 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/LockActivity.kt @@ -0,0 +1,168 @@ +package sh.sar.basedbank + +import android.content.Intent +import android.os.Bundle +import android.util.Base64 +import android.view.View +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import sh.sar.basedbank.databinding.ActivityLockBinding +import sh.sar.basedbank.ui.login.LoginActivity +import java.security.MessageDigest + +class LockActivity : AppCompatActivity() { + + private lateinit var binding: ActivityLockBinding + private val pinDigits = mutableListOf() + private lateinit var method: String + private lateinit var salt: String + private lateinit var storedHash: String + private var biometricsEnabled = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityLockBinding.inflate(layoutInflater) + setContentView(binding.root) + + 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) + + if (method == "pin") { + binding.viewPin.visibility = View.VISIBLE + buildNumpad() + updateDots() + if (biometricsEnabled) binding.btnLockBiometricFromPin.visibility = View.VISIBLE + binding.btnLockBiometricFromPin.setOnClickListener { triggerBiometric() } + } else { + binding.viewPattern.visibility = View.VISIBLE + if (biometricsEnabled) binding.btnLockBiometricFromPattern.visibility = View.VISIBLE + binding.btnLockBiometricFromPattern.setOnClickListener { triggerBiometric() } + binding.lockPatternView.onPatternComplete = { pattern -> verifyPattern(pattern) } + } + + if (biometricsEnabled) triggerBiometric() + } + + private fun buildNumpad() { + val rows = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf("⌫", "0", "✓") + ) + rows.forEach { keys -> + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f + ) + } + keys.forEach { key -> + val style = if (key == "✓") + com.google.android.material.R.attr.materialButtonStyle + else + com.google.android.material.R.attr.materialButtonOutlinedStyle + val btn = MaterialButton(this, null, style).apply { + text = key + textSize = 20f + insetTop = 0; insetBottom = 0 + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f) + .also { it.setMargins(4, 4, 4, 4) } + } + btn.setOnClickListener { handleKey(key) } + row.addView(btn) + } + binding.lockNumpadContainer.addView(row) + } + } + + private fun handleKey(key: String) { + when (key) { + "⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() } + "✓" -> if (pinDigits.size >= 4) verifyPin() + else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() } + } + } + + private fun updateDots() { + val n = pinDigits.size + binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(4 - n, 0)) + } + + private fun verifyPin() { + val entered = pinDigits.joinToString("") + if (verify(entered)) { + proceed() + } else { + binding.tvLockPinDots.text = getString(R.string.unlock_failed) + pinDigits.clear() + binding.root.postDelayed({ updateDots() }, 1200) + } + } + + private fun verifyPattern(pattern: List) { + if (pattern.size < 4) { + binding.lockPatternView.showError() + binding.tvPatternHint.text = getString(R.string.pattern_min_dots) + return + } + if (verify(pattern.joinToString(""))) { + proceed() + } else { + binding.lockPatternView.showError() + binding.tvPatternHint.text = getString(R.string.unlock_failed) + binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500) + } + } + + private fun verify(input: String): Boolean { + val hash = sha256(salt + input) + return hash == storedHash + } + + private fun triggerBiometric() { + val canAuth = BiometricManager.from(this) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) return + + val prompt = BiometricPrompt( + this, + ContextCompat.getMainExecutor(this), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + proceed() + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + // User cancelled or error — fall back to PIN/pattern (already visible) + } + } + ) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.unlock_app)) + .setSubtitle(getString(R.string.biometric_prompt_subtitle)) + .setNegativeButtonText(getString(R.string.biometric_negative_btn)) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK) + .build() + prompt.authenticate(info) + } + + private fun proceed() { + startActivity(Intent(this, LoginActivity::class.java)) + finish() + } + + private fun sha256(input: String) = MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray()).joinToString("") { "%02x".format(it) } + + override fun onBackPressed() { + // Lock screen cannot be dismissed + moveTaskToBack(true) + } +} diff --git a/app/src/main/java/sh/sar/basedbank/MainActivity.kt b/app/src/main/java/sh/sar/basedbank/MainActivity.kt index 18ced06..37b17e8 100644 --- a/app/src/main/java/sh/sar/basedbank/MainActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/MainActivity.kt @@ -12,7 +12,12 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val onboardingDone = prefs.getBoolean("onboarding_done", false) - val target = if (onboardingDone) LoginActivity::class.java else OnboardingActivity::class.java + val securitySet = prefs.getString("security_method", null) != null + val target = when { + !onboardingDone -> OnboardingActivity::class.java + securitySet -> LockActivity::class.java + else -> LoginActivity::class.java + } startActivity(Intent(this, target)) finish() } diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index d1b29a5..19b0625 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -35,9 +35,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { * Full login flow. Automatically handles first-time device registration * vs. subsequent logins using stored key1/key2. * + * @param passwordHash SHA-256(password) uppercase hex — use [hashPassword] to compute. * Returns list of accounts from all profiles on success. */ - fun login(username: String, password: String, otpSeed: String): List { + fun login(username: String, passwordHash: String, otpSeed: String): List { val appId = getOrCreateAppId() Log.d(TAG, "login: appId=$appId") val key1 = prefs.getString("mib_key1_$username", null) @@ -46,17 +47,17 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { return if (key1 != null && key2 != null) { Log.d(TAG, "login: taking regular login path") - regularLogin(username, password, appId, key1, key2) + regularLogin(username, passwordHash, appId, key1, key2) } else { Log.d(TAG, "login: taking first-time registration path") - firstTimeRegistration(username, password, otpSeed, appId) + firstTimeRegistration(username, passwordHash, otpSeed, appId) } } // ─── First-time registration ────────────────────────────────────────────── private fun firstTimeRegistration( - username: String, password: String, otpSeed: String, appId: String + username: String, passwordHash: String, otpSeed: String, appId: String ): List { Log.d(TAG, "[reg] step 0: key exchange (sfunc=r)") val (session1, _) = initialKeyExchange(appId, MibCrypto.DEFAULT_KEY, "r") @@ -67,9 +68,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { Log.d(TAG, "[reg] step 1 done: userSalt length=${userSalt.length}") Log.d(TAG, "[reg] step 2: registration init (C41)") - Log.d(TAG, "[reg] username='$username' password='$password' userSalt='$userSalt'") + Log.d(TAG, "[reg] username='$username' userSalt='$userSalt'") val clientSalt = randomAlpha(32) - val pgf03 = computePgf03(password, userSalt, clientSalt) + val pgf03 = computePgf03(passwordHash, userSalt, clientSalt) Log.d(TAG, "[reg] pgf03=$pgf03") val regInitPayload = baseData(session1, "C41").apply { put("uname", username) @@ -102,13 +103,13 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { Log.d(TAG, "[reg] stored key1/key2 for user=$username") prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply() - return regularLogin(username, password, appId, key1, key2) + return regularLogin(username, passwordHash, appId, key1, key2) } // ─── Regular login ──────────────────────────────────────────────────────── private fun regularLogin( - username: String, password: String, + username: String, passwordHash: String, appId: String, key1: String, key2: String ): List { Log.d(TAG, "[login] step 4: key exchange (sfunc=i)") @@ -121,7 +122,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { Log.d(TAG, "[login] step 6: login init (A41)") val clientSalt = randomAlpha(32) - val pgf03 = computePgf03(password, userSalt, clientSalt) + val pgf03 = computePgf03(passwordHash, userSalt, clientSalt) Log.d(TAG, "[login] pgf03 length=${pgf03.length}") val loginPayload = baseData(session2, "A41").apply { put("uname", username) @@ -273,16 +274,24 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { return response.body?.string() ?: throw IllegalStateException("Empty response body") } - private fun computePgf03(password: String, userSalt: String, clientSalt: String): String { - fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256") - .digest(input.toByteArray()) - .joinToString("") { "%02X".format(it) } - - val h1 = sha256Upper(password) + /** @param h1 SHA-256(password) uppercase hex, as returned by [hashPassword] */ + private fun computePgf03(h1: String, userSalt: String, clientSalt: String): String { val h2 = sha256Upper(h1 + userSalt) return sha256Upper(clientSalt + h2) } + companion object { + /** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */ + fun hashPassword(password: String): String = + MessageDigest.getInstance("SHA-256") + .digest(password.toByteArray()) + .joinToString("") { "%02X".format(it) } + } + + private fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray()) + .joinToString("") { "%02X".format(it) } + private fun generateOtp(seed: String): String = Totp.generate(seed) private fun getOrCreateAppId(): String { diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index 3ac24c8..0528a8b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.util.Totp import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.api.mib.MibLoginFlow +import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.ui.home.HomeActivity @@ -98,6 +99,7 @@ class CredentialsFragment : Fragment() { binding.progressBar.visibility = View.VISIBLE binding.btnLogin.isEnabled = false + val passwordHash = MibLoginFlow.hashPassword(password) val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE) val flow = MibLoginFlow(prefs) @@ -105,9 +107,10 @@ class CredentialsFragment : Fragment() { try { Log.d(TAG, "Starting login flow on IO dispatcher") val accounts = withContext(Dispatchers.IO) { - flow.login(username, password, otpSeed) + flow.login(username, passwordHash, otpSeed) } Log.d(TAG, "Login succeeded, got ${accounts.size} accounts") + CredentialStore(requireContext()).saveMibCredentials(username, passwordHash, otpSeed) (requireActivity().application as BasedBankApp).accounts = accounts startActivity(Intent(requireContext(), HomeActivity::class.java)) requireActivity().finish() diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt index eda7912..0bbffcb 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt @@ -1,10 +1,19 @@ package sh.sar.basedbank.ui.login +import android.content.Intent import android.os.Bundle +import android.view.View import androidx.appcompat.app.AppCompatActivity -import androidx.navigation.fragment.NavHostFragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.databinding.ActivityLoginBinding +import sh.sar.basedbank.ui.home.HomeActivity +import sh.sar.basedbank.util.CredentialStore class LoginActivity : AppCompatActivity() { @@ -14,5 +23,31 @@ class LoginActivity : AppCompatActivity() { super.onCreate(savedInstanceState) binding = ActivityLoginBinding.inflate(layoutInflater) setContentView(binding.root) + + val creds = CredentialStore(this).loadMibCredentials() + if (creds != null) { + binding.navHostFragment.visibility = View.GONE + binding.autoLoginGroup.visibility = View.VISIBLE + autoLogin(creds) + } + } + + private fun autoLogin(creds: CredentialStore.MibCredentials) { + val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) + val flow = MibLoginFlow(prefs) + lifecycleScope.launch { + try { + val accounts = withContext(Dispatchers.IO) { + flow.login(creds.username, creds.passwordHash, creds.otpSeed) + } + (application as BasedBankApp).accounts = accounts + startActivity(Intent(this@LoginActivity, HomeActivity::class.java)) + finish() + } catch (e: Exception) { + // Auto-login failed — fall back to manual login form + binding.autoLoginGroup.visibility = View.GONE + binding.navHostFragment.visibility = View.VISIBLE + } + } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt index 9d8d20a..440992f 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt @@ -1,6 +1,7 @@ package sh.sar.basedbank.ui.onboarding import android.content.Intent +import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity @@ -12,22 +13,24 @@ import sh.sar.basedbank.R import sh.sar.basedbank.databinding.ActivityOnboardingBinding import sh.sar.basedbank.ui.login.LoginActivity -class OnboardingActivity : AppCompatActivity() { +class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback { private lateinit var binding: ActivityOnboardingBinding + private lateinit var prefs: SharedPreferences override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityOnboardingBinding.inflate(layoutInflater) setContentView(binding.root) + prefs = getSharedPreferences("prefs", MODE_PRIVATE) val adapter = OnboardingPagerAdapter(this) binding.viewPager.adapter = adapter TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach() - // Pre-select the chip for the saved language without triggering the listener - val savedLang = getSharedPreferences("prefs", MODE_PRIVATE).getString("language", null) + // Pre-select language chip without triggering the listener + val savedLang = prefs.getString("language", null) binding.languageChipGroup.setOnCheckedStateChangeListener(null) when (savedLang) { "en" -> binding.chipEnglish.isChecked = true @@ -35,18 +38,24 @@ class OnboardingActivity : AppCompatActivity() { } binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds -> if (checkedIds.isNotEmpty()) { - val lang = if (checkedIds[0] == R.id.chipEnglish) "en" else "dv" - selectLanguage(lang) + selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv") } } binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE + // Block forward swipe on slide 1 until security is set up + if (position == 1) { + binding.viewPager.isUserInputEnabled = + prefs.getString("security_method", null) != null + } else { + binding.viewPager.isUserInputEnabled = true + } updateButtons(position, adapter.itemCount) } }) - // Show chips and set initial button state for page 0 + binding.languageChipGroup.visibility = View.VISIBLE updateButtons(0, adapter.itemCount) @@ -56,26 +65,41 @@ class OnboardingActivity : AppCompatActivity() { } binding.btnGetStarted.setOnClickListener { - getSharedPreferences("prefs", MODE_PRIVATE) - .edit().putBoolean("onboarding_done", true).apply() + prefs.edit().putBoolean("onboarding_done", true).apply() startActivity(Intent(this, LoginActivity::class.java)) finish() } } + // Called by SecuritySetupFragment when setup is complete + override fun onSecuritySetupComplete() { + binding.viewPager.isUserInputEnabled = true + updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) + } + private fun selectLanguage(lang: String) { - getSharedPreferences("prefs", MODE_PRIVATE).edit().putString("language", lang).apply() - val locales = LocaleListCompat.forLanguageTags(lang) - AppCompatDelegate.setApplicationLocales(locales) - // Update buttons immediately in case locale didn't change (no recreation) + prefs.edit().putString("language", lang).apply() + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(lang)) updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) } private fun updateButtons(position: Int, count: Int) { - val langSelected = getSharedPreferences("prefs", MODE_PRIVATE).getString("language", null) != null + val langSelected = prefs.getString("language", null) != null + val securityDone = prefs.getString("security_method", null) != null val isLast = position == count - 1 - binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE + binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE - binding.btnNext.isEnabled = position > 0 || langSelected + + // Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation) + binding.btnNext.visibility = when { + isLast -> View.GONE + position == 1 && !securityDone -> View.GONE + else -> View.VISIBLE + } + binding.btnNext.isEnabled = when (position) { + 0 -> langSelected + 1 -> securityDone + else -> true + } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt index e6c5170..1a81217 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt @@ -30,8 +30,10 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter( override fun getItemCount() = slides.size - override fun createFragment(position: Int): Fragment = - OnboardingFragment.newInstance(slides[position]) + override fun createFragment(position: Int): Fragment = when (position) { + 1 -> SecuritySetupFragment() + else -> OnboardingFragment.newInstance(slides[position]) + } } data class OnboardingSlide( diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt new file mode 100644 index 0000000..dd887f1 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt @@ -0,0 +1,120 @@ +package sh.sar.basedbank.ui.onboarding + +import android.content.Context +import android.graphics.* +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import com.google.android.material.color.MaterialColors +import kotlin.math.min +import kotlin.math.sqrt + +class PatternView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : View(context, attrs) { + + private data class Cell(val index: Int, var cx: Float = 0f, var cy: Float = 0f) + + private val cells = List(9) { Cell(it) } + private val selected = mutableListOf() + private var touchX = 0f + private var touchY = 0f + private var recording = false + private var errorState = false + + private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + + var onPatternComplete: ((List) -> Unit)? = null + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + val sz = min(w, h).toFloat() + val cell = sz / 3f + cells.forEachIndexed { i, c -> + c.cx = (i % 3) * cell + cell / 2f + c.cy = (i / 3) * cell + cell / 2f + } + linePaint.strokeWidth = cell * 0.07f + ringPaint.strokeWidth = cell * 0.05f + } + + override fun onMeasure(ws: Int, hs: Int) { + val s = min(MeasureSpec.getSize(ws), MeasureSpec.getSize(hs)) + setMeasuredDimension(s, s) + } + + override fun onDraw(canvas: Canvas) { + val sz = min(width, height).toFloat() + val cell = sz / 3f + val dotR = cell * 0.13f + val ringR = cell * 0.26f + val activeColor = if (errorState) + MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, Color.RED) + else + MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.BLUE) + val normalColor = + MaterialColors.getColor(this, com.google.android.material.R.attr.colorOutlineVariant, Color.GRAY) + + // Lines between selected cells + linePaint.color = activeColor; linePaint.alpha = 160 + for (i in 0 until selected.size - 1) + canvas.drawLine(selected[i].cx, selected[i].cy, selected[i + 1].cx, selected[i + 1].cy, linePaint) + + // Trailing line to finger position + if (recording && selected.isNotEmpty()) { + linePaint.alpha = 80 + canvas.drawLine(selected.last().cx, selected.last().cy, touchX, touchY, linePaint) + } + + // Dots + cells.forEach { c -> + val isSelected = selected.contains(c) + dotPaint.color = if (isSelected) activeColor else normalColor + canvas.drawCircle(c.cx, c.cy, dotR, dotPaint) + if (isSelected) { + ringPaint.color = activeColor; ringPaint.alpha = 60 + canvas.drawCircle(c.cx, c.cy, ringR, ringPaint) + } + } + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (errorState) return false + when (event.action) { + MotionEvent.ACTION_DOWN -> { + recording = true; selected.clear() + hit(event.x, event.y) + } + MotionEvent.ACTION_MOVE -> { + touchX = event.x; touchY = event.y + hit(event.x, event.y) + } + MotionEvent.ACTION_UP -> { + recording = false + invalidate() + onPatternComplete?.invoke(selected.map { it.index }) + } + } + invalidate() + return true + } + + private fun hit(x: Float, y: Float) { + val hitR = min(width, height) / 3f * 0.40f + cells.forEach { c -> + val dx = x - c.cx; val dy = y - c.cy + if (sqrt(dx * dx + dy * dy) <= hitR && !selected.contains(c)) selected.add(c) + } + } + + fun reset() { selected.clear(); recording = false; errorState = false; invalidate() } + + fun showError() { + errorState = true; invalidate() + postDelayed({ reset() }, 700) + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt new file mode 100644 index 0000000..1b76154 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt @@ -0,0 +1,233 @@ +package sh.sar.basedbank.ui.onboarding + +import android.content.Context +import android.os.Bundle +import android.util.Base64 +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.biometric.BiometricManager +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 java.security.SecureRandom + +class SecuritySetupFragment : Fragment() { + + interface Callback { + fun onSecuritySetupComplete() + } + + private var _b: FragmentSecuritySetupBinding? = null + private val b get() = _b!! + + private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC } + + private var step = Step.CHOOSE + private val pinDigits = mutableListOf() + private var firstPin = "" + private var firstPattern: List = emptyList() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _b = FragmentSecuritySetupBinding.inflate(inflater, container, false) + return b.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) + if (prefs.getString("security_method", null) != null) { + (activity as? Callback)?.onSecuritySetupComplete() + } + + b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) } + b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) } + + b.btnPinBack.setOnClickListener { + when (step) { + Step.PIN_CONFIRM -> goTo(Step.PIN_ENTER) + else -> goTo(Step.CHOOSE) + } + } + b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) } + + b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) } + + b.btnEnableBiometrics.setOnClickListener { + prefs.edit().putBoolean("biometrics_enabled", true).apply() + finishSetup() + } + b.btnSkipBiometrics.setOnClickListener { + prefs.edit().putBoolean("biometrics_enabled", false).apply() + finishSetup() + } + + buildNumpad() + goTo(Step.CHOOSE) + } + + private fun buildNumpad() { + val rows = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9"), + listOf("⌫", "0", "✓") + ) + rows.forEach { keys -> + val row = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f + ) + } + keys.forEach { key -> + val style = if (key == "✓") + com.google.android.material.R.attr.materialButtonStyle + else + com.google.android.material.R.attr.materialButtonOutlinedStyle + val btn = MaterialButton(requireContext(), null, style).apply { + text = key + textSize = 20f + insetTop = 0 + insetBottom = 0 + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f) + .also { it.setMargins(4, 4, 4, 4) } + } + btn.setOnClickListener { handleKey(key) } + row.addView(btn) + } + b.numpadContainer.addView(row) + } + } + + private fun handleKey(key: String) { + when (key) { + "⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() } + "✓" -> if (pinDigits.size >= 4) submitPin() + else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() } + } + } + + private fun updateDots() { + val n = pinDigits.size + val total = maxOf(n, 4) + b.tvPinDots.text = "●".repeat(n) + "○".repeat(total - n) + } + + private fun submitPin() { + val entered = pinDigits.joinToString("") + when (step) { + Step.PIN_ENTER -> { + firstPin = entered + pinDigits.clear() + goTo(Step.PIN_CONFIRM) + } + Step.PIN_CONFIRM -> { + if (entered == firstPin) { + saveCredential("pin", entered) + goToBiometricOrFinish() + } else { + b.tvPinDots.text = getString(R.string.pin_no_match) + pinDigits.clear() + b.root.postDelayed({ updateDots() }, 1200) + } + } + else -> {} + } + } + + private fun handlePattern(pattern: List) { + if (pattern.size < 4) { + b.patternView.showError() + b.tvPatternStatus.text = getString(R.string.pattern_min_dots) + return + } + when (step) { + Step.PATTERN_ENTER -> { + firstPattern = pattern + step = Step.PATTERN_CONFIRM + b.tvPatternTitle.text = getString(R.string.confirm_pattern) + b.tvPatternStatus.text = getString(R.string.pattern_draw_again) + b.patternView.reset() + } + Step.PATTERN_CONFIRM -> { + if (pattern == firstPattern) { + saveCredential("pattern", pattern.joinToString("")) + goToBiometricOrFinish() + } else { + b.patternView.showError() + b.tvPatternStatus.text = getString(R.string.pattern_no_match) + step = Step.PATTERN_ENTER + b.tvPatternTitle.text = getString(R.string.draw_pattern) + b.root.postDelayed({ + b.tvPatternStatus.text = getString(R.string.pattern_min_dots) + }, 1200) + } + } + else -> {} + } + } + + private fun goToBiometricOrFinish() { + val canAuth = BiometricManager.from(requireContext()) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) { + goTo(Step.BIOMETRIC) + } else { + requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) + .edit().putBoolean("biometrics_enabled", false).apply() + finishSetup() + } + } + + private fun goTo(s: Step) { + step = s + b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE + b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE + b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE + b.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE + + when (s) { + Step.PIN_ENTER -> { + pinDigits.clear() + b.tvPinTitle.text = getString(R.string.enter_pin) + updateDots() + } + Step.PIN_CONFIRM -> { + b.tvPinTitle.text = getString(R.string.confirm_pin) + updateDots() + } + Step.PATTERN_ENTER -> { + b.tvPatternTitle.text = getString(R.string.draw_pattern) + b.tvPatternStatus.text = getString(R.string.pattern_min_dots) + b.patternView.reset() + } + else -> {} + } + } + + 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) + requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit() + .putString("security_method", method) + .putString("security_salt", saltB64) + .putString("security_hash", hash) + .apply() + } + + private fun sha256(input: String) = MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray()).joinToString("") { "%02x".format(it) } + + private fun finishSetup() { + (activity as? Callback)?.onSecuritySetupComplete() + } + + override fun onDestroyView() { + super.onDestroyView() + _b = null + } +} diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt new file mode 100644 index 0000000..d6db1fb --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -0,0 +1,90 @@ +package sh.sar.basedbank.util + +import android.content.Context +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 + +class CredentialStore(context: Context) { + + private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE) + private val keyAlias = "basedbank_credential_key" + private val transformation = "AES/GCM/NoPadding" + + data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String) + + fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") + + fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { + val key = getOrCreateKey() + prefs.edit() + .putString("mib_enc_username", encrypt(username, key)) + .putString("mib_enc_password_hash", encrypt(passwordHash, key)) + .putString("mib_enc_otp_seed", encrypt(otpSeed, key)) + .apply() + } + + fun loadMibCredentials(): MibCredentials? { + val key = getOrCreateKey() + val encUsername = prefs.getString("mib_enc_username", null) ?: return null + val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null + val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null + return try { + MibCredentials( + decrypt(encUsername, key), + decrypt(encHash, key), + decrypt(encSeed, key) + ) + } catch (e: Exception) { + null + } + } + + fun clearMibCredentials() { + prefs.edit() + .remove("mib_enc_username") + .remove("mib_enc_password_hash") + .remove("mib_enc_otp_seed") + .apply() + } + + private fun getOrCreateKey(): SecretKey { + val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } + ks.getKey(keyAlias, null)?.let { return it as SecretKey } + + val spec = KeyGenParameterSpec.Builder( + keyAlias, + 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() + } + + private fun encrypt(plaintext: String, key: SecretKey): String { + val cipher = Cipher.getInstance(transformation) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ct = cipher.doFinal(plaintext.toByteArray()) + 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 cipher = Cipher.getInstance(transformation) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + return String(cipher.doFinal(ct)) + } +} diff --git a/app/src/main/res/layout/activity_lock.xml b/app/src/main/res/layout/activity_lock.xml new file mode 100644 index 0000000..1360160 --- /dev/null +++ b/app/src/main/res/layout/activity_lock.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 6551d2d..08d3039 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -18,4 +18,29 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" /> + + + + + + + + diff --git a/app/src/main/res/layout/fragment_security_setup.xml b/app/src/main/res/layout/fragment_security_setup.xml new file mode 100644 index 0000000..15802ad --- /dev/null +++ b/app/src/main/res/layout/fragment_security_setup.xml @@ -0,0 +1,311 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-b+dv/strings.xml b/app/src/main/res/values-b+dv/strings.xml index ffa23d7..f7b0b38 100644 --- a/app/src/main/res/values-b+dv/strings.xml +++ b/app/src/main/res/values-b+dv/strings.xml @@ -28,6 +28,40 @@ ތިޔަ އޮތެންޓިކޭޓަ ދިން Base32 ސިއްރު ލޮގިން + + BasedBank ހުޅުވާ + PIN ޖަހާ + ހުޅުވާ ޕެޓަން ކަހާ + ބަޔޮމެޓްރިކް ބޭނުން ކުރޭ + ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ + PIN / ޕެޓަން ބޭނުން ކުރޭ + ދިމައެއް ނުވި — އަލުން ކަނޑޭ + + + އެޕް ރައްކާތެރި ކުރޭ + BasedBank ހުޅުވަން ބޭނުންވާ ގޮތެއް ހިޔާރު ކުރޭ. + PIN ކޯޑް + 4–8 ރިޔަލެއްގެ ނަންބަރު PIN + ޕެޓަން ކަހާ + 4 ނުވަތަ އެއަށްވުރެ ގިނަ ތިކި ގުޅުވާ + PIN ޖަހާ + PIN ކަށަވަރު ކުރޭ + މަދުވެގެން 4 ރިޔަލ، ގިނަވެގެން 8 + PIN ދިމައެއް ނުވި — އަލުން ކަނޑޭ + ޕެޓަން ކަހާ + ޕެޓަން ކަށަވަރު ކުރޭ + މަދުވެގެން 4 ތިކި ގުޅުވާ + އެ ޕެޓަން އަލުން ކަހާ + ޕެޓަން ދިމައެއް ނުވި — އަލުން ކަހާ + ބަޔޮމެޓްރިކް ބޭނުން ކުރަންތަ؟ + ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވޭ. + ބަޔޮމެޓްރިކް ފަސޭހަ ނަމަވެސް PIN ނުވަތަ ޕެޓަނަށްވުރެ ތަންކޮޅެއް ކަށަވަރެއް ނޫން. ރައްކާތެރިކަން ބޭނުން ނަމަ PIN ނުވަތަ ޕެޓަން ހިޔާރު ކުރޭ. + ބަޔޮމެޓްރިކް ހިންގާ + ސްކިޕް — PIN/ޕެޓަން ބޭނުން ކުރޭ + ފަހަތަށް + + ލޮގިން ވަނީ… + އެކައުންޓްތައް ލިބެން ހުރި ބެލެންސް diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1568e4b..4cc0f66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,40 @@ The Base32 secret from your authenticator setup Login + + Unlock BasedBank + Enter your PIN + Draw your unlock pattern + Use Biometrics + Use your fingerprint or face to unlock + Use PIN / Pattern + Incorrect — try again + + + Secure Your App + Choose how you want to lock BasedBank when you\'re away. + PIN Code + 4–8 digit numeric PIN + Draw Pattern + Connect 4 or more dots in a pattern + Set Your PIN + Confirm Your PIN + Minimum 4 digits, maximum 8 + PINs don\'t match — try again + Draw Your Pattern + Confirm Your Pattern + Connect at least 4 dots + Draw the same pattern again + Patterns don\'t match — try again + Use Biometrics? + Unlock with your fingerprint or face scan instead of your PIN or pattern. + Biometrics is convenient but slightly less secure than a PIN or pattern. For maximum security, use PIN or pattern only. + Enable Biometrics + Skip — use PIN/Pattern only + Back + + Signing in… + Accounts Available Balance