From 1886113ae7d59b40ae912a7c956c06fcab61cb2a Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Sat, 26 Sep 2026 20:43:14 +0500 Subject: [PATCH] new feature: long press OTP card in OTP codes page to export or update seed --- .../sh/sar/basedbank/api/mib/MibLoginFlow.kt | 5 + .../sh/sar/basedbank/ui/home/OtpFragment.kt | 234 +++++++++++++++++- .../basedbank/ui/login/CredentialsFragment.kt | 20 +- .../sh/sar/basedbank/util/CredentialStore.kt | 10 + .../sh/sar/basedbank/util/OtpauthParser.kt | 21 ++ .../res/layout/dialog_otp_export_seed.xml | 63 +++++ .../res/layout/dialog_otp_update_seed.xml | 98 ++++++++ .../main/res/layout/fragment_credentials.xml | 82 +----- app/src/main/res/layout/view_otp_preview.xml | 84 +++++++ docs/thijooree/10-otp-screen.md | 25 +- 10 files changed, 543 insertions(+), 99 deletions(-) create mode 100644 app/src/main/res/layout/dialog_otp_export_seed.xml create mode 100644 app/src/main/res/layout/dialog_otp_update_seed.xml create mode 100644 app/src/main/res/layout/view_otp_preview.xml 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 4979253..42afed7 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 @@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) { } .build() + /** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */ + fun updateOtpSeed(otpSeed: String) { + if (storedOtpSeed != null) storedOtpSeed = otpSeed + } + // ─── Public entry point ─────────────────────────────────────────────────── /** diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt index fd233dd..95564ca 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt @@ -1,14 +1,25 @@ package sh.sar.basedbank.ui.home +import android.app.Activity import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color import android.os.Build import android.os.Bundle +import android.os.PersistableBundle +import android.text.Editable +import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager @@ -19,14 +30,22 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.mib.MibProfileClient import sh.sar.basedbank.api.mib.MibLoginFlow +import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding +import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding import sh.sar.basedbank.databinding.FragmentOtpBinding import sh.sar.basedbank.databinding.ItemOtpCardBinding import sh.sar.basedbank.util.CredentialStore +import sh.sar.basedbank.util.OtpauthParser import sh.sar.basedbank.util.Totp class OtpFragment : Fragment() { @@ -34,7 +53,35 @@ class OtpFragment : Fragment() { private var _binding: FragmentOtpBinding? = null private val binding get() = _binding!! - private data class OtpEntry(val bank: String, val name: String?, val seed: String) + private data class OtpEntry( + val bank: String, val loginId: String, val account: String, val name: String?, val seed: String + ) + + private val entries = mutableListOf() + private var adapter: OtpAdapter? = null + + /** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */ + private var scanTarget: android.widget.EditText? = null + + 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 target = scanTarget ?: return@registerForActivityResult + val found = OtpauthParser.parse(raw) + when { + found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show() + found.size == 1 -> target.setText(found[0].secret) + else -> { + val labels = found.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 -> target.setText(found[i].secret) } + .show() + } + } + } private inner class OtpAdapter(private val entries: List) : RecyclerView.Adapter() { @@ -56,6 +103,8 @@ class OtpFragment : Fragment() { ) update(b, entry.seed) b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") } + // Long-press opens the seed menu (export / update) + b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true } b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") } b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") } } @@ -91,6 +140,170 @@ class OtpFragment : Fragment() { } } + private fun showSeedMenu(anchor: View, position: Int) { + if (position == RecyclerView.NO_POSITION) return + val popup = PopupMenu(anchor.context, anchor) + popup.menu.add(0, 1, 0, "Export seed") + popup.menu.add(0, 2, 1, "Update seed") + popup.setOnMenuItemClickListener { item -> + val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false + when (item.itemId) { + 1 -> showExportDialog(entry) + 2 -> showUpdateDialog(position) + } + true + } + popup.show() + } + + private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}" + + // ── Export ─────────────────────────────────────────────────────────────── + + private fun showExportDialog(entry: OtpEntry) { + val ctx = requireContext() + val d = DialogOtpExportSeedBinding.inflate(layoutInflater) + val uri = OtpauthParser.buildUri(entry.bank, entry.seed) + d.tvSeed.text = entry.seed.chunked(4).joinToString(" ") + renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) } + d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") } + MaterialAlertDialogBuilder(ctx) + .setTitle(entryTitle(entry)) + .setView(d.root) + .setPositiveButton("Done", null) + .show() + } + + private fun renderQr(content: String, size: Int): Bitmap? = try { + val hints = mapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M + ) + val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) + val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE } + Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888) + } catch (_: Exception) { null } + + /** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */ + private fun copySensitive(context: Context, text: String, message: String) { + val clip = ClipData.newPlainText("OTP seed", text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + // ── Update ─────────────────────────────────────────────────────────────── + + private fun showUpdateDialog(position: Int) { + val entry = entries.getOrNull(position) ?: return + val ctx = requireContext() + val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater) + d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " + + "can't be recovered. If you might still need it, export it first." + + var newSeed: String? = null + // Same behaviour as the sign-in screen's preview card + val preview = d.cardOtp + preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") } + fun refreshPreview() { + val seed = newSeed + try { + if (seed == null) throw IllegalArgumentException() + preview.tvOtpCode.text = Totp.generate(seed) + preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1) + preview.otpTimer.max = 30 + preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt() + preview.root.visibility = View.VISIBLE + } catch (_: Exception) { + preview.root.visibility = View.INVISIBLE + } + } + + val dialog = MaterialAlertDialogBuilder(ctx) + .setTitle("Update seed · ${entry.bank}") + .setView(d.root) + .setPositiveButton("Replace", null) + .setNegativeButton("Cancel", null) + .create() + + fun validate() { + val raw = d.etNewSeed.text?.toString().orEmpty() + newSeed = OtpauthParser.resolveSecret(raw) + d.tilNewSeed.error = when { + raw.isBlank() -> null + newSeed == null -> "Not a valid TOTP seed" + newSeed == entry.seed -> "This is already the current seed" + else -> null + } + if (newSeed == entry.seed) newSeed = null + dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null + refreshPreview() + } + + d.etNewSeed.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) = validate() + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) + d.btnScanNewSeed.setOnClickListener { + scanTarget = d.etNewSeed + qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java)) + } + + val ticker = viewLifecycleOwner.lifecycleScope.launch { + while (isActive) { refreshPreview(); delay(1_000) } + } + dialog.setOnDismissListener { + ticker.cancel() + if (scanTarget === d.etNewSeed) scanTarget = null + } + dialog.setOnShowListener { + val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE) + replace.isEnabled = false + replace.setOnClickListener { + val seed = newSeed ?: return@setOnClickListener + confirmReplace(entry) { + saveSeed(position, seed) + dialog.dismiss() + } + } + } + dialog.show() + } + + private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete old seed?") + .setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " + + "recovered afterwards.") + .setPositiveButton("Replace") { _, _ -> onConfirm() } + .setNegativeButton("Cancel", null) + .show() + } + + private fun saveSeed(position: Int, seed: String) { + val entry = entries.getOrNull(position) ?: return + val store = CredentialStore(requireContext()) + when (entry.bank) { + "MIB" -> { + store.updateMibOtpSeed(entry.loginId, seed) + // The live flow keeps the seed in memory for silent re-login + (requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed) + } + "BML" -> store.updateBmlOtpSeed(entry.loginId, seed) + } + entries[position] = entry.copy(seed = seed) + adapter?.notifyItemChanged(position) + Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show() + } + private fun copyCode(context: Context, text: CharSequence, message: String) { val code = text.toString().replace(" ", "") if (code.isEmpty() || code.contains('-')) return @@ -115,16 +328,19 @@ class OtpFragment : Fragment() { for (loginId in store.getMibLoginIds()) { val creds = store.loadMibCredentials(loginId) ?: continue val name = store.loadMibFullName(loginId) - tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed)) + tagged.add(CredentialStore.loginKey("mib", loginId) to + OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed)) } for (loginId in store.getBmlLoginIds()) { val creds = store.loadBmlCredentials(loginId) ?: continue val name = store.loadBmlUserProfile(loginId)?.fullName - tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed)) + tagged.add(CredentialStore.loginKey("bml", loginId) to + OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed)) } - val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList() + entries.clear() + entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second }) - val adapter = OtpAdapter(entries) + val adapter = OtpAdapter(entries).also { this.adapter = it } binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE @@ -147,8 +363,7 @@ class OtpFragment : Fragment() { mobile = profile.mobile, enrolled = profile.enrolled )) - val seed = store.loadMibCredentials(loginId)?.otpSeed - val idx = entries.indexOfFirst { it.seed == seed } + val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId } if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true } } } @@ -168,8 +383,7 @@ class OtpFragment : Fragment() { idCard = info.idCard, birthdate = info.birthdate )) - val seed = store.loadBmlCredentials(loginId)?.otpSeed - val idx = entries.indexOfFirst { it.seed == seed } + val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId } if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true } } } @@ -192,6 +406,8 @@ class OtpFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + adapter = null + scanTarget = null _binding = null } } 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 886cabd..274f226 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 @@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() { qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) } - binding.cardOtp.setOnClickListener { - val code = binding.tvOtpCode.text.toString().replace(" ", "") + binding.cardOtp.root.setOnClickListener { + val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "") if (code.isNotEmpty()) { val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) @@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() { val otpSeedRaw = binding.etOtpSeed.text.toString().trim() val seed = resolveOtpSeed(otpSeedRaw) if (seed.isEmpty()) { - binding.cardOtp.visibility = View.INVISIBLE + binding.cardOtp.root.visibility = View.INVISIBLE return } val password = binding.etPassword.text.toString() if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) { - binding.cardOtp.visibility = View.INVISIBLE + binding.cardOtp.root.visibility = View.INVISIBLE return } try { @@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() { val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt() val remaining = 30 - secondsInPeriod - binding.tvOtpCode.text = otp - binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1) - binding.otpTimer.max = 30 - binding.otpTimer.progress = remaining - binding.cardOtp.visibility = View.VISIBLE + binding.cardOtp.tvOtpCode.text = otp + binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1) + binding.cardOtp.otpTimer.max = 30 + binding.cardOtp.otpTimer.progress = remaining + binding.cardOtp.root.visibility = View.VISIBLE } catch (e: Exception) { - binding.cardOtp.visibility = View.INVISIBLE + binding.cardOtp.root.visibility = View.INVISIBLE } } diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index e3574d7..5384691 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -100,6 +100,11 @@ class CredentialStore(context: Context) { .apply() } + /** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */ + fun updateMibOtpSeed(loginId: String, otpSeed: String) { + prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply() + } + fun loadMibCredentials(loginId: String): MibCredentials? { val key = getOrCreateKey() val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null @@ -230,6 +235,11 @@ class CredentialStore(context: Context) { .apply() } + /** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */ + fun updateBmlOtpSeed(loginId: String, otpSeed: String) { + prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply() + } + fun loadBmlCredentials(loginId: String): BmlCredentials? { val key = getOrCreateKey() val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null diff --git a/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt b/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt index 6a2484c..a1574fd 100644 --- a/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt @@ -13,6 +13,27 @@ object OtpauthParser { else -> emptyList() } + /** + * Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw + * secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed. + */ + fun resolveSecret(input: String): String? { + val raw = input.trim() + val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw + val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase() + if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null + // Too short to be a real seed, e.g. a pasted 6-digit OTP code + if (clean.length < 8) return null + return clean + } + + /** + * Build the shortest otpauth://totp link authenticator apps accept: just a label and the + * secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out. + */ + fun buildUri(label: String, secret: String): String = + "otpauth://totp/" + Uri.encode(label) + "?secret=$secret" + private fun parseStandard(raw: String): OtpEntry? { val uri = Uri.parse(raw) val secret = uri.getQueryParameter("secret") ?: return null diff --git a/app/src/main/res/layout/dialog_otp_export_seed.xml b/app/src/main/res/layout/dialog_otp_export_seed.xml new file mode 100644 index 0000000..5983c6f --- /dev/null +++ b/app/src/main/res/layout/dialog_otp_export_seed.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_otp_update_seed.xml b/app/src/main/res/layout/dialog_otp_update_seed.xml new file mode 100644 index 0000000..a93f675 --- /dev/null +++ b/app/src/main/res/layout/dialog_otp_update_seed.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml index feeed55..e60c6c4 100644 --- a/app/src/main/res/layout/fragment_credentials.xml +++ b/app/src/main/res/layout/fragment_credentials.xml @@ -130,89 +130,13 @@ android:maxLength="6" /> - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_marginBottom="8dp" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/thijooree/10-otp-screen.md b/docs/thijooree/10-otp-screen.md index d117e8d..b457c5e 100644 --- a/docs/thijooree/10-otp-screen.md +++ b/docs/thijooree/10-otp-screen.md @@ -21,6 +21,29 @@ Each card shows: Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead. +--- + +## Seed Actions + +Long-pressing a card opens a menu with: + +### Export seed + +A dialog titled `{bank} · {name}` showing: +- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced. +- The Base32 seed in groups of 4 (selectable text) +- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview. + +### Update seed + +Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank. +- A red warning banner explains that the old seed is deleted permanently. +- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use. +- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**. +- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed. + +The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately. + ### Algorithm Standard RFC 6238 TOTP: @@ -58,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually ## Security -The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. +The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**. ---