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 089e853..e1d1fd9 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 @@ -1,5 +1,6 @@ package sh.sar.basedbank.ui.login +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -13,11 +14,13 @@ import android.os.Looper import android.text.Editable import android.text.TextWatcher import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import sh.sar.basedbank.util.OtpauthParser import sh.sar.basedbank.util.Totp import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R @@ -34,6 +37,7 @@ import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.ui.home.HomeActivity +import sh.sar.basedbank.ui.home.QrScannerActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder class CredentialsFragment : Fragment() { @@ -60,6 +64,25 @@ class CredentialsFragment : Fragment() { private var bmlLoginId: String = "" private var bmlAccumulatedAccounts = mutableListOf() + private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult + val entries = OtpauthParser.parse(raw) + when { + entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show() + entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret) + else -> { + val labels = entries.map { e -> + if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" } + }.toTypedArray() + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Choose account") + .setItems(labels) { _, i -> binding.etOtpSeed.setText(entries[i].secret) } + .show() + } + } + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentCredentialsBinding.inflate(inflater, container, false) return binding.root @@ -75,7 +98,7 @@ class CredentialsFragment : Fragment() { binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long) binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc) binding.tilUsername.hint = getString(R.string.fahipay_id_card) - binding.tilOtpSeed.visibility = android.view.View.GONE + binding.rowOtpSeed.visibility = android.view.View.GONE binding.etOtpSeed.isEnabled = false binding.etOtpSeed.isFocusable = false } @@ -83,6 +106,9 @@ class CredentialsFragment : Fragment() { binding.btnLogin.isEnabled = false binding.btnLogin.setOnClickListener { attemptLogin() } + binding.btnScanOtpSeed.setOnClickListener { + qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) + } binding.cardOtp.setOnClickListener { val code = binding.tvOtpCode.text.toString().replace(" ", "") diff --git a/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt b/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt new file mode 100644 index 0000000..6a2484c --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt @@ -0,0 +1,116 @@ +package sh.sar.basedbank.util + +import android.net.Uri +import android.util.Base64 + +data class OtpEntry(val name: String, val issuer: String, val secret: String) + +object OtpauthParser { + + fun parse(raw: String): List = when { + raw.startsWith("otpauth-migration://") -> parseMigration(raw) + raw.startsWith("otpauth://") -> parseStandard(raw)?.let { listOf(it) } ?: emptyList() + else -> emptyList() + } + + private fun parseStandard(raw: String): OtpEntry? { + val uri = Uri.parse(raw) + val secret = uri.getQueryParameter("secret") ?: return null + val issuer = uri.getQueryParameter("issuer") ?: "" + val label = uri.path?.trimStart('/') ?: "" + val name = if (':' in label) label.substringAfter(':').trim() else label + return OtpEntry(name, issuer, secret.uppercase()) + } + + private fun parseMigration(raw: String): List { + val data = Uri.parse(raw).getQueryParameter("data") ?: return emptyList() + val bytes = try { Base64.decode(data, Base64.DEFAULT) } catch (_: Exception) { return emptyList() } + val reader = ProtobufReader(bytes) + val entries = mutableListOf() + while (reader.hasMore()) { + val tag = reader.readVarint().toInt() + val fieldNum = tag ushr 3 + val wireType = tag and 0x7 + if (fieldNum == 1 && wireType == 2) { + parseOtpParameters(reader.readBytes())?.let { entries.add(it) } + } else { + reader.skip(wireType) + } + } + return entries + } + + private fun parseOtpParameters(bytes: ByteArray): OtpEntry? { + val reader = ProtobufReader(bytes) + var secret: ByteArray? = null + var name = "" + var issuer = "" + var type = 2 // default to TOTP + while (reader.hasMore()) { + val tag = reader.readVarint().toInt() + val fieldNum = tag ushr 3 + val wireType = tag and 0x7 + when (fieldNum) { + 1 -> secret = reader.readBytes() + 2 -> name = String(reader.readBytes(), Charsets.UTF_8) + 3 -> issuer = String(reader.readBytes(), Charsets.UTF_8) + 6 -> type = reader.readVarint().toInt() + else -> reader.skip(wireType) + } + } + if (type == 1) return null // skip HOTP + val secretBase32 = base32Encode(secret ?: return null) + return OtpEntry(name, issuer, secretBase32) + } + + private fun base32Encode(bytes: ByteArray): String { + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + val sb = StringBuilder() + var buffer = 0 + var bitsLeft = 0 + for (b in bytes) { + buffer = (buffer shl 8) or (b.toInt() and 0xFF) + bitsLeft += 8 + while (bitsLeft >= 5) { + bitsLeft -= 5 + sb.append(alphabet[(buffer ushr bitsLeft) and 0x1F]) + } + } + if (bitsLeft > 0) sb.append(alphabet[(buffer shl (5 - bitsLeft)) and 0x1F]) + return sb.toString() + } + + private class ProtobufReader(private val bytes: ByteArray) { + private var pos = 0 + + fun hasMore() = pos < bytes.size + + fun readVarint(): Long { + var result = 0L + var shift = 0 + while (pos < bytes.size) { + val b = bytes[pos++].toInt() and 0xFF + result = result or ((b and 0x7F).toLong() shl shift) + if (b and 0x80 == 0) break + shift += 7 + } + return result + } + + fun readBytes(): ByteArray { + val len = readVarint().toInt() + val data = bytes.copyOfRange(pos, pos + len) + pos += len + return data + } + + fun skip(wireType: Int) { + when (wireType) { + 0 -> readVarint() + 1 -> pos += 8 + 2 -> readBytes() + 5 -> pos += 4 + } + } + } +} diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml index db06192..d0b4ba6 100644 --- a/app/src/main/res/layout/fragment_credentials.xml +++ b/app/src/main/res/layout/fragment_credentials.xml @@ -73,22 +73,42 @@ android:singleLine="true" /> - - + + - + android:layout_weight="1" + android:hint="@string/otp_seed" + app:endIconMode="password_toggle" + style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> + + + + + + Password OTP Seed (TOTP Secret) The Base32 secret from your authenticator setup + Scan OTP QR Login