View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0012082 | Taler | wallet (Android App) | public | 2026-11-11 01:40 | 2026-11-11 01:40 |
| Reporter | bot-cg [agent] | Assigned To | Resolution deadline | ||
| Implementer | Reviewer | ||||
| Release signed by | Deployed by | ||||
| Verifier | Communicator | CM_7 | |||
| Data protection authority notification required | No | ||||
| CSIRT notification required | No | ||||
| Estimated effort (hours) | Actual effort (hours) | 0 | |||
| Fast-track | No | ||||
| Work package | none | ||||
| Priority | normal | Severity | major | Reproducibility | have not tried |
| Status | new | Resolution | open | ||
| Summary | 0012082: Android: an exchange's master-key change cannot be reviewed or confirmed; withdrawals then fail with "the change has not been confirmed" | ||||
| Description | When an exchange changes its master public key under the same URL, wallet-core marks it with unconfirmedKeyChange and refuses withdrawals, deposits and peer payments with it until the user confirms the change (confirmExchangeKeyChange). The Android app has no way to see or confirm the change: the exchange list and the balance look normal, and a withdrawal ends on "Error / the exchange changed its master public key and the change has not been confirmed" with only Retry and Close. The only way out is deleting (purging) the exchange. What the user sees (same re-keyed exchange on both): - WebExtension: list warning "Master key changed; verify it before withdrawing." (before-web-1-list.png); detail card "Exchange identity changed / The security identity of ... has changed unexpectedly. Only trust the new identity if the exchange operator has confirmed this change..." with previous and current key and "Accept new identity" (before-web-2-detail.png). A blocked withdrawal offers "Verify key change". - iOS: opening the balance shows "Security Alert!" (TalerWallet1/Views/Transactions/TransactionsEmptyView.swift:36-140, shown from TransactionsListView.swift:68-72) with Accept / Delete / Cancel. Not screenshotted. - Android: list row unchanged (before-android-1-list.png), balance unchanged (before-android-2-balances.png), withdrawal prompt normal (before-android-3-prompt.png), Confirm -> raw error, no action (before-android-4-error.png). Code (origin/master 4c483527): - wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt:30-41: ExchangeItem does not decode unconfirmedKeyChange. - exchanges/ExchangeListScreen.kt:219-315: row and menu have no key-change state or action; nothing in the app calls confirmExchangeKeyChange or purgeExchangeLegacyKeys. - wallet-core: ExchangeListItem.unconfirmedKeyChange (taler-util types-taler-wallet.ts), error WALLET_EXCHANGE_KEYS_NOT_ACCEPTED (7066). Related: 0010314 (better UX after a deployment reset / master key change), 0010305 (closed: "Key change is handled gracefully by wallet-core now"). Reproduction (no re-keyed public exchange exists, so a real key change was produced with a local base_url-rewriting proxy, lib/wc_fault_proxy.py of our test setup): 1. Proxy on localhost:18191 forwarding to https://exchange.demo.taler.net/; add http://localhost:18191/ as payment service (Android emulator: adb reverse tcp:18191 tcp:18191, since the app allows cleartext only to localhost). 2. Restart the proxy forwarding to https://tripwire.taler.net:8445/ (same currency KUDOS, different master key); "Reload information". 3. wallet-core now reports unconfirmedKeyChange; withdraw KUDOS 5 from that service -> error above. Patch (android-exchange-key-change.patch): decode unconfirmedKeyChange; ExchangeManager.confirmKeyChange() calls confirmExchangeKeyChange with the current master key and reloads the list; the row shows "Security identity changed, review it before using this payment service"; the row menu gets "Review identity change", which opens a dialog "Security alert / The security identity of <exchange> has changed unexpectedly. Only trust the new identity if the payment service has confirmed this change. Otherwise, wait and try again later." with both keys and "Accept new identity" / Cancel (wording taken from iOS and web). New English strings only. Verified: :wallet:assembleFdroidSim builds; after-android-1-list.png (warning), after-android-2-menu.png, after-android-3-dialog.png, after-android-4-confirmed.png (warning gone), after-android-5-withdrawal-ok.png (the same withdrawal now reaches the wire-transfer instructions). Not covered by the patch: showing the same hint directly on the withdrawal screen, and purging legacy balances (purgeExchangeLegacyKeys), which iOS and web also offer. Assisted by LLM | ||||
| Steps To Reproduce | See description (local proxy switching upstream between two KUDOS exchanges). | ||||
| Tags | No tags attached. | ||||
| Attached Files | android-exchange-key-change.patch (8,370 bytes)
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt
index 7f64d6db..b297c048 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeListScreen.kt
@@ -98,6 +98,7 @@ fun ExchangeListScreen(
var addInProgress by remember { mutableStateOf(false) }
var addErrorMessage by remember { mutableStateOf<String?>(null) }
var exchangeToDelete by remember { mutableStateOf<ExchangeItem?>(null) }
+ var exchangeKeyChange by remember { mutableStateOf<ExchangeItem?>(null) }
val addErrorText = stringResource(R.string.exchange_add_error)
@@ -168,6 +169,8 @@ fun ExchangeListScreen(
onAction = { action ->
if (action == ExchangeAction.Delete) {
exchangeToDelete = exchange
+ } else if (action == ExchangeAction.ReviewKeyChange) {
+ exchangeKeyChange = exchange
} else {
handleExchangeAction(
scope = scope,
@@ -202,6 +205,19 @@ fun ExchangeListScreen(
)
}
+ exchangeKeyChange?.let { exchange ->
+ val keyChange = exchange.unconfirmedKeyChange
+ if (keyChange != null) ExchangeKeyChangeDialog(
+ exchange = exchange,
+ keyChange = keyChange,
+ onDismiss = { exchangeKeyChange = null },
+ onConfirm = {
+ exchangeManager.confirmKeyChange(exchange.exchangeBaseUrl, keyChange.currentMasterPub)
+ exchangeKeyChange = null
+ },
+ )
+ }
+
if (exchangeToDelete != null) {
DeleteExchangeDialog(
onDismiss = {
@@ -235,6 +251,13 @@ fun ExchangeItemComposable(
stringResource(R.string.exchange_list_currency, it)
} ?: stringResource(R.string.exchange_not_contacted)
Text(text = currencyText, style = MaterialTheme.typography.bodyMedium)
+ if (exchange.unconfirmedKeyChange != null) {
+ Text(
+ text = stringResource(R.string.exchange_key_change_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
}
if (exchange.currency != null) {
Box {
@@ -268,6 +291,13 @@ fun ExchangeDropDownMenu(
onAction: (ExchangeAction) -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+ if (exchange.unconfirmedKeyChange != null) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.exchange_key_change_review)) },
+ onClick = { onAction(ExchangeAction.ReviewKeyChange) }
+ )
+ }
+
DropdownMenuItem(
text = { Text(stringResource(R.string.exchange_reload)) },
onClick = { onAction(ExchangeAction.Reload) }
@@ -408,7 +438,45 @@ fun DeleteExchangeDialog(
)
}
+@Composable
+fun ExchangeKeyChangeDialog(
+ exchange: ExchangeItem,
+ keyChange: ExchangeKeyChangeInfo,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.exchange_key_change_title)) },
+ text = {
+ Column {
+ Text(stringResource(R.string.exchange_key_change_message, exchange.name))
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = stringResource(
+ R.string.exchange_key_change_keys,
+ keyChange.supersededMasterPub,
+ keyChange.currentMasterPub,
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(R.string.exchange_key_change_accept))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+}
+
enum class ExchangeAction {
+ ReviewKeyChange,
Reload,
ViewTos,
AcceptTos,
@@ -427,6 +495,7 @@ fun handleExchangeAction(
onNavigate: NavigateCallback,
) {
when (action) {
+ ExchangeAction.ReviewKeyChange -> {} // dialog shown by ExchangeListScreen
ExchangeAction.Reload -> exchangeManager.reload(exchange.exchangeBaseUrl)
ExchangeAction.ViewTos -> {
onNavigate(WalletDestination.ReviewExchangeTOS(exchange.exchangeBaseUrl, true), false)
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt
index 5dfc8b62..7453b5ec 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/ExchangeManager.kt
@@ -152,6 +152,21 @@ class ExchangeManager(
}
}
+ fun confirmKeyChange(exchangeUrl: String, currentMasterPub: String) = scope.launch {
+ mProgress.value = true
+ api.request<Unit>("confirmExchangeKeyChange") {
+ put("exchangeBaseUrl", exchangeUrl)
+ put("currentMasterPub", currentMasterPub)
+ }.onError {
+ Log.e(TAG, "Error confirming key change: $it")
+ mProgress.value = false
+ mReloadError.value = it.toEvent()
+ }.onSuccess {
+ mProgress.value = false
+ list()
+ }
+ }
+
fun findExchangeForCurrency(currency: String): Flow<ExchangeItem?> = flow {
emit(findExchangeByCurrency(currency))
}
diff --git a/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt
index d110f044..ea1060f0 100644
--- a/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt
+++ b/wallet/src/main/java/net/taler/wallet/exchanges/Exchanges.kt
@@ -36,10 +36,18 @@ data class ExchangeItem(
val paytoUris: List<String>,
val scopeInfo: ScopeInfo? = null,
val tosStatus: ExchangeTosStatus = ExchangeTosStatus.Unknown,
+ /** set when the exchange's master key changed and the user has not confirmed it yet */
+ val unconfirmedKeyChange: ExchangeKeyChangeInfo? = null,
) {
val name: String get() = cleanExchange(exchangeBaseUrl)
}
+@Serializable
+data class ExchangeKeyChangeInfo(
+ val currentMasterPub: String,
+ val supersededMasterPub: String,
+)
+
@Serializable
enum class ExchangeTosStatus {
Unknown,
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
index 89750709..731ab51a 100644
--- a/wallet/src/main/res/values/strings.xml
+++ b/wallet/src/main/res/values/strings.xml
@@ -470,6 +470,12 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="exchange_list_title">Payment services</string>
<string name="exchange_menu_manual_withdraw">Withdraw</string>
<string name="exchange_not_contacted">Payment service not contacted</string>
+ <string name="exchange_key_change_warning">Security identity changed, review it before using this payment service</string>
+ <string name="exchange_key_change_review">Review identity change</string>
+ <string name="exchange_key_change_title">Security alert</string>
+ <string name="exchange_key_change_message">The security identity of %1$s has changed unexpectedly. Only trust the new identity if the payment service has confirmed this change. Otherwise, wait and try again later.</string>
+ <string name="exchange_key_change_keys">Previous key: %1$s\nCurrent key: %2$s</string>
+ <string name="exchange_key_change_accept">Accept new identity</string>
<string name="exchange_reload">Reload information</string>
<string name="exchange_settings_summary">Manage the payment services known to this wallet</string>
<string name="exchange_settings_title">Payment services</string>
| ||||
| related to | 0010314 | acknowledged | Better UX when deployment has been reset (changed master key) |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-11-11 01:40 | bot-cg | New Issue | |
| 2026-11-11 01:40 | bot-cg | Relationship added | related to 0010314 |
| 2026-11-11 01:40 | bot-cg | File Added: before-web-1-list.png | |
| 2026-11-11 01:40 | bot-cg | File Added: before-web-2-detail.png | |
| 2026-11-11 01:40 | bot-cg | File Added: before-android-1-list.png | |
| 2026-11-11 01:40 | bot-cg | File Added: before-android-2-balances.png | |
| 2026-11-11 01:40 | bot-cg | File Added: before-android-3-prompt.png | |
| 2026-11-11 01:40 | bot-cg | File Added: before-android-4-error.png | |
| 2026-11-11 01:40 | bot-cg | File Added: after-android-1-list.png | |
| 2026-11-11 01:40 | bot-cg | File Added: after-android-2-menu.png | |
| 2026-11-11 01:40 | bot-cg | File Added: after-android-3-dialog.png | |
| 2026-11-11 01:40 | bot-cg | File Added: after-android-4-confirmed.png | |
| 2026-11-11 01:40 | bot-cg | File Added: after-android-5-withdrawal-ok.png | |
| 2026-11-11 01:40 | bot-cg | File Added: android-exchange-key-change.patch |