From 5d6f1bde54761619c6dad10617afae389168844f Mon Sep 17 00:00:00 2001 From: Kyle Santiago Date: Sat, 31 Oct 2026 21:47:29 -0400 Subject: [PATCH] Pin keep v0.13.3: typed import and client-cap errors, DKG recovery over a stored share --- .../privkey/keep/nip46/BunkerClientCapTest.kt | 3 +- .../storage/DkgRecoveryOverStoredShareTest.kt | 209 ++++++++++++++++++ .../storage/DuplicateImportRefusalTest.kt | 21 +- .../kotlin/io/privkey/keep/AccountActions.kt | 63 ++++-- .../keep/approver/ApproverController.kt | 10 +- .../privkey/keep/approver/ApproverScreen.kt | 4 +- app/src/main/res/values/strings_main.xml | 2 + .../privkey/keep/ImportFailureMessageTest.kt | 27 ++- keep.version | 2 +- 9 files changed, 299 insertions(+), 42 deletions(-) create mode 100644 app/src/androidTest/kotlin/io/privkey/keep/storage/DkgRecoveryOverStoredShareTest.kt diff --git a/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerClientCapTest.kt b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerClientCapTest.kt index fc1129da..8836a552 100644 --- a/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerClientCapTest.kt +++ b/app/src/androidTest/kotlin/io/privkey/keep/nip46/BunkerClientCapTest.kt @@ -59,9 +59,10 @@ class BunkerClientCapTest { fun aListOverTheCapIsCutToTheNewestClients() = runBlocking { val kept = (1..BunkerService.MAX_AUTHORIZED_CLIENTS).map { "%064x".format(it) } storeLegacyList(OLDEST + kept) - assertThrows(KeepMobileException::class.java) { + val refused = assertThrows(KeepMobileException.TooManyAuthorizedClients::class.java) { mobile.saveBunkerConfig(BunkerConfigInfo(true, mobile.getBunkerConfig().authorizedClients)) } + assertEquals("the app's cap is the core's", BunkerService.MAX_AUTHORIZED_CLIENTS, refused.max.toInt()) BunkerService.revokeClientsOverCap(context, mobile) diff --git a/app/src/androidTest/kotlin/io/privkey/keep/storage/DkgRecoveryOverStoredShareTest.kt b/app/src/androidTest/kotlin/io/privkey/keep/storage/DkgRecoveryOverStoredShareTest.kt new file mode 100644 index 00000000..33a102f6 --- /dev/null +++ b/app/src/androidTest/kotlin/io/privkey/keep/storage/DkgRecoveryOverStoredShareTest.kt @@ -0,0 +1,209 @@ +package io.privkey.keep.storage + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.privkey.keep.AccountActions +import io.privkey.keep.R +import io.privkey.keep.uniffi.KeepMobile +import io.privkey.keep.uniffi.ShareInfo +import io.privkey.keep.uniffi.ShareMetadataInfo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.util.UUID + +/** + * Recovering a pending DKG share through the app's own flow, with the stash planted the way + * a ceremony leaves it. A share already stored under the stash's group is read rather than + * written, so the recovery hands the core the share's decrypt cipher, and the active + * account and live node stay as they were. + * + * The stash secret is wrapped under the fixed production DKG alias, so, as in + * [DkgSecretStashRoundTripTest], the alias is seeded with a twin that needs no biometric, + * and the test skips on a device that holds a real, auth-gated one. + */ +@RunWith(AndroidJUnit4::class) +class DkgRecoveryOverStoredShareTest { + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + private lateinit var storage: AndroidKeystoreStorage + private lateinit var mobile: KeepMobile + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val created = mutableSetOf() + private var ownsAlias = false + private var nodeDrops = 0 + + @Before + fun setup() { + assumeTrue("refusing to run: a real auth-gated DKG key is present on this device", !productionDkgKeyPresent()) + if (keyStore.containsAlias(DKG_ALIAS)) keyStore.deleteEntry(DKG_ALIAS) + seedNonAuthTwinAlias() + ownsAlias = true + storage = AndroidKeystoreStorage(context, requireUserAuth = false, keystoreAlias = ALIAS, prefsSuffix = SUFFIX) + mobile = KeepMobile(storage) + } + + @After + fun teardown() { + scope.cancel() + if (!ownsAlias) return + runCatching { storage.deleteShareByKey(SECRET_KEY) } + runCatching { storage.deleteShareByKey(MARKER_KEY) } + created.forEach { group -> + runCatching { mobile.deleteShareByKey(group) } + context.deleteSharedPreferences(storage.sharePrefsNameFor(group)) + } + context.deleteSharedPreferences(storage.sharePrefsNameFor(MARKER_KEY)) + context.deleteSharedPreferences(MULTI_PREFS) + mobile.destroy() + keyStore.aliases().toList().filter { it.startsWith(ALIAS) || it == DKG_ALIAS }.forEach { keyStore.deleteEntry(it) } + } + + @Test + fun aShareAlreadyStoredIsReadNotWrittenAndTheActiveAccountStays() { + val group = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-test", PASSPHRASE) + val other = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-other", PASSPHRASE) + import(group.shares[0].exportData) + import(other.shares[0].exportData) + assertEquals(other.groupPubkey, storage.getActiveShareKey()) + stash(group.groupPubkey, group.shares[0].exportData) + + val result = recover() + + assertEquals(group.groupPubkey, (result as Recovered).info?.groupPubkey) + assertEquals("the active account stays", other.groupPubkey, storage.getActiveShareKey()) + assertEquals("a recovery that wrote nothing does not drop the node", 0, nodeDrops) + assertNull("the stash is cleared", mobile.pendingDkgShare()) + } + + @Test + fun anotherShareOfTheStoredGroupIsRefusedWithItsReasonAndTheStashKept() { + val group = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-test", PASSPHRASE) + import(group.shares[0].exportData) + stash(group.groupPubkey, group.shares[1].exportData) + + val result = recover() + + assertEquals(Dismissed(context.getString(R.string.account_import_other_share_stored)), result) + assertNotNull("the stash is kept", mobile.pendingDkgShare()) + assertEquals(0, nodeDrops) + } + + @Test + fun aNewGroupIsStoredAndBecomesTheActiveAccount() { + val other = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-other", PASSPHRASE) + val group = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-test", PASSPHRASE) + import(other.shares[0].exportData) + stash(group.groupPubkey, group.shares[0].exportData) + created += group.groupPubkey + + val result = recover() + + assertEquals(group.groupPubkey, (result as Recovered).info?.groupPubkey) + assertEquals(group.groupPubkey, storage.getActiveShareKey()) + assertEquals("a store that moved the active account drops the node", 1, nodeDrops) + assertNull(mobile.pendingDkgShare()) + } + + private sealed interface Outcome + private data class Recovered(val info: ShareInfo?) : Outcome + private data class Dismissed(val reason: String?) : Outcome + + private fun recover(): Outcome { + val done = CompletableDeferred() + val actions = AccountActions( + keepMobile = mobile, + storage = storage, + coroutineScope = scope, + appContext = context, + onBiometricRequest = { _, _, cipher, onResult -> onResult(cipher) }, + onAccountSwitched = { true }, + onAccountDeleted = {}, + onLiveNodeDropped = { nodeDrops++ }, + onStateChanged = {}, + markBunkerRevokeAhead = { { _ -> } }, + ) + actions.recoverPendingDkgShare( + title = "test", + subtitle = "test", + onResult = { done.complete(Recovered(it)) }, + onDismiss = { done.complete(Dismissed(it)) }, + ) + return runBlocking { withTimeout(60_000) { done.await() } } + } + + private fun import(exportData: String) { + val requestId = "dkg-recover-test-${System.nanoTime()}" + storage.setPendingCipher(requestId, storage.getCipherForEncryption()) + storage.setRequestIdContext(requestId) + try { + created += mobile.importShare(exportData, PASSPHRASE, "dkg-recover-test").groupPubkey + } finally { + storage.clearRequestIdContext() + storage.clearPendingCipher(requestId) + } + } + + // The two records a vault-protected ceremony leaves for recovery. + private fun stash(groupPubkey: String, exportData: String) { + val marker = JSONObject().put("name", "dkg-recover-test").put("group_pubkey_hex", groupPubkey).put("vault_protected", true) + storage.storeShareByKey(MARKER_KEY, marker.toString().toByteArray(), META) + val secret = JSONObject().put("share_export", exportData).put("vault_passphrase", PASSPHRASE) + storage.storeShareByKey(SECRET_KEY, secret.toString().toByteArray(), META) + assertNotNull("the planted stash reads as pending", mobile.pendingDkgShare()) + } + + private fun productionDkgKeyPresent(): Boolean { + if (!keyStore.containsAlias(DKG_ALIAS)) return false + val key = keyStore.getKey(DKG_ALIAS, null) as? PrivateKey ?: return false + val info = runCatching { + KeyFactory.getInstance(key.algorithm, "AndroidKeyStore").getKeySpec(key, KeyInfo::class.java) as KeyInfo + }.getOrNull() ?: return false + return info.isUserAuthenticationRequired + } + + private fun seedNonAuthTwinAlias() { + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore").apply { + initialize( + KeyGenParameterSpec.Builder(DKG_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + ) + }.generateKeyPair() + } + + private companion object { + const val SUFFIX = "_dkg_recover_test" + const val ALIAS = "keep_frost_share$SUFFIX" + const val MULTI_PREFS = "keep_multi_share_prefs$SUFFIX" + const val DKG_ALIAS = "keep_dkg_secret" + const val MARKER_KEY = "__keep_dkg_pending_v1" + const val SECRET_KEY = "__keep_dkg_secret_v1" + const val PASSPHRASE = "dkg-recover-test-passphrase" + val META = ShareMetadataInfo("dkg_pending", 0u, 0u, 0u, ByteArray(0), false) + } +} diff --git a/app/src/androidTest/kotlin/io/privkey/keep/storage/DuplicateImportRefusalTest.kt b/app/src/androidTest/kotlin/io/privkey/keep/storage/DuplicateImportRefusalTest.kt index 71183383..2ab3fd07 100644 --- a/app/src/androidTest/kotlin/io/privkey/keep/storage/DuplicateImportRefusalTest.kt +++ b/app/src/androidTest/kotlin/io/privkey/keep/storage/DuplicateImportRefusalTest.kt @@ -57,10 +57,8 @@ class DuplicateImportRefusalTest { } } - private fun refusal(call: () -> ShareInfo): Int { - val e = assertThrows(KeepMobileException.InvalidShare::class.java) { stored(call) } - return importFailureMessage(e) - } + private fun refusal(type: Class, call: () -> ShareInfo): Int = + importFailureMessage(assertThrows(type) { stored(call) }) @Test fun aStoredKeyAgainIsNamedAsAlreadyStored() { @@ -69,7 +67,7 @@ class DuplicateImportRefusalTest { assertEquals( R.string.account_import_already_stored, - refusal { mobile.createAccountFromMnemonic(mnemonic.copyOf(), "", "dup-import-test") } + refusal(KeepMobileException.KeyAlreadyStored::class.java) { mobile.createAccountFromMnemonic(mnemonic.copyOf(), "", "dup-import-test") } ) } @@ -80,7 +78,18 @@ class DuplicateImportRefusalTest { assertEquals( R.string.account_import_other_share_stored, - refusal { mobile.importShare(shares[1].exportData, PASSPHRASE, "dup-import-test") } + refusal(KeepMobileException.GroupShareAlreadyStored::class.java) { mobile.importShare(shares[1].exportData, PASSPHRASE, "dup-import-test") } + ) + } + + @Test + fun theSameShareAgainIsNamedAsAlreadyStored() { + val shares = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dup-import-test", PASSPHRASE).shares + stored { mobile.importShare(shares[0].exportData, PASSPHRASE, "dup-import-test") } + + assertEquals( + R.string.account_import_share_stored, + refusal(KeepMobileException.ShareAlreadyStored::class.java) { mobile.importShare(shares[0].exportData, PASSPHRASE, "dup-import-test") } ) } diff --git a/app/src/main/kotlin/io/privkey/keep/AccountActions.kt b/app/src/main/kotlin/io/privkey/keep/AccountActions.kt index 728616e9..5ac9df9f 100644 --- a/app/src/main/kotlin/io/privkey/keep/AccountActions.kt +++ b/app/src/main/kotlin/io/privkey/keep/AccountActions.kt @@ -89,13 +89,24 @@ sealed class CreateGroupState { internal fun storeSwitchedAccount(hadShare: Boolean, activeBefore: String?, activeAfter: String?): Boolean = hadShare && (activeAfter == null || activeAfter != activeBefore) -// Matches the core's refusal text until it has a typed variant for a stored key. -internal fun importFailureMessage(e: Exception): Int = when ((e as? KeepMobileException.InvalidShare)?.msg) { - "This key is already stored" -> R.string.account_import_already_stored - "Another share of this group is already stored" -> R.string.account_import_other_share_stored +internal fun importFailureMessage(e: Exception): Int = when (e) { + is KeepMobileException.KeyAlreadyStored -> R.string.account_import_already_stored + is KeepMobileException.ShareAlreadyStored -> R.string.account_import_share_stored + is KeepMobileException.GroupShareAlreadyStored -> R.string.account_import_other_share_stored else -> R.string.account_import_failed } +/** + * Why a pending DKG share was not recovered, when the core refused it over a stored share. + * The pending share is kept, so a retry would only be refused again. + */ +internal fun dkgRecoveryRefusal(e: Exception): Int? = when (e) { + is KeepMobileException.ShareAlreadyStored -> R.string.main_pending_dkg_recover_share_stored + is KeepMobileException.GroupShareAlreadyStored -> R.string.account_import_other_share_stored + is KeepMobileException.KeyAlreadyStored -> R.string.account_import_already_stored + else -> null +} + internal class AccountActions( private val keepMobile: KeepMobile, private val storage: AndroidKeystoreStorage, @@ -189,14 +200,15 @@ internal class AccountActions( return withContext(Dispatchers.IO) { store().also { stored = true } } } finally { withContext(NonCancellable) { - // Here rather than at the caller: a cancellation that lands once the store - // has returned would skip the caller's code, and the core has already - // dropped the node. - if (stored) onLiveNodeDropped() val (hadShare, activeBefore) = before val activeAfter = withContext(Dispatchers.IO) { runCatching { storage.getActiveShareKey() }.getOrNull() } + // Here rather than at the caller: a cancellation that lands once the store + // has returned would skip the caller's code. The core drops the node when it + // moves the active pointer, and a store that found its share already there + // moves nothing. A pointer that cannot be read counts as moved. + if (stored && (activeAfter == null || activeAfter != activeBefore)) onLiveNodeDropped() val switched = storeSwitchedAccount(hadShare, activeBefore, activeAfter) if (finishMark != null) { withContext(Dispatchers.IO) { runCatching { finishMark(switched) } } @@ -807,40 +819,50 @@ internal class AccountActions( onDismiss: (String?) -> Unit ) { coroutineScope.launch { - val encryptAttempt = withContext(Dispatchers.IO) { - runCatching { storage.getCipherForShareEncryption(groupPubkeyHex) } + // A share already listed under the group is not written again: the core reads it + // to check it holds the same key, so that read's cipher is what it needs. + val shareAttempt = withContext(Dispatchers.IO) { + runCatching { + if (storage.listAllShares().any { it.toAccountInfo().groupPubkeyHex == groupPubkeyHex }) { + storage.getCipherForShareDecryption(groupPubkeyHex)?.let { it to AndroidKeystoreStorage.CipherRole.DECRYPT } + } else { + storage.getCipherForShareEncryption(groupPubkeyHex) to AndroidKeystoreStorage.CipherRole.ENCRYPT + } + } } - val encryptCipher = encryptAttempt.getOrNull() - if (encryptCipher == null) { + val (shareCipher, shareRole) = shareAttempt.getOrNull() ?: run { onDismiss( - encryptAttempt.exceptionOrNull()?.message + shareAttempt.exceptionOrNull()?.message ?: appContext.getString(R.string.main_pending_dkg_recover_unavailable) ) return@launch } - onBiometricRequest(title, subtitle, encryptCipher) { authedEncrypt -> - if (authedEncrypt == null) { + onBiometricRequest(title, subtitle, shareCipher) { authedShare -> + if (authedShare == null) { onDismiss(null) return@onBiometricRequest } - finishRecoverDkgShare(authedDecrypt, authedEncrypt, onResult) + finishRecoverDkgShare(authedDecrypt, authedShare, shareRole, onResult, onDismiss) } } } private fun finishRecoverDkgShare( authedDecrypt: Cipher, - authedEncrypt: Cipher, - onResult: (ShareInfo?) -> Unit + authedShare: Cipher, + shareRole: AndroidKeystoreStorage.CipherRole, + onResult: (ShareInfo?) -> Unit, + onDismiss: (String?) -> Unit ) { coroutineScope.launch { accountMutex.withLock { val requestId = UUID.randomUUID().toString() var pendingSet = false try { + // The stash's cipher first: the core reads the stash before the share. storage.setPendingCipher(requestId, authedDecrypt, AndroidKeystoreStorage.CipherRole.DECRYPT) pendingSet = true - storage.setPendingCipher(requestId, authedEncrypt, AndroidKeystoreStorage.CipherRole.ENCRYPT) + storage.setPendingCipher(requestId, authedShare, shareRole) val info = storingNewActiveShare { storage.setRequestIdContext(requestId) try { @@ -854,7 +876,8 @@ internal class AccountActions( onResult(info) } catch (e: Exception) { if (BuildConfig.DEBUG) Log.e("AccountActions", "DKG recovery failed: ${e::class.simpleName}") - onResult(null) + val refusal = dkgRecoveryRefusal(e) + if (refusal != null) onDismiss(appContext.getString(refusal)) else onResult(null) } finally { if (pendingSet) storage.clearPendingCipher(requestId) } diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt index 664b98f3..0bce6f08 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverController.kt @@ -94,13 +94,15 @@ class ApproverController(private val context: Context, mobile: KeepMobile) { when (event) { is ApproverEvent.Paired -> notice(R.string.approver_notice_paired) is ApproverEvent.PairingRefused -> { - // A gateway's refusal ends the pairing; a reply that fails its checks does not, - // and the key must stay for the pairing that goes on. - val status = _state.value.status - if (status != null && !status.paired && !status.pairing) ApproverKey.delete() + // Only the key of the pairing that ended goes: a pairing begun since has its own. + if (event.ended && runCatching { ApproverKey.publicKey() }.getOrNull()?.contentEquals(event.approverKey) == true) { + ApproverKey.delete() + } notice(context.getString(R.string.approver_notice_pairing_refused, event.reason)) } is ApproverEvent.Request -> notifier.requestWaiting() + // The reload above drops it from the waiting list, and a repeat after a restart is nothing new. + is ApproverEvent.RequestExpired -> Unit is ApproverEvent.RequestRefused -> notice(context.getString(R.string.approver_notice_request_refused, event.reason)) is ApproverEvent.Outcome -> diff --git a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt index 97744eea..e6660860 100644 --- a/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/approver/ApproverScreen.kt @@ -182,7 +182,9 @@ fun ApproverScreen( ) else -> { if (!status.running) { - NotRunning(state.startError, busy, onRetry = { run { controller.retryStart() } }) + NotRunning(state.startError ?: status.lastError, busy, onRetry = { run { controller.retryStart() } }) + } else { + status.lastError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } } if (!backgroundServiceEnabled) { Text(stringResource(R.string.approver_background_off), style = MaterialTheme.typography.bodySmall) diff --git a/app/src/main/res/values/strings_main.xml b/app/src/main/res/values/strings_main.xml index 26ff3bb8..b6bc9138 100644 --- a/app/src/main/res/values/strings_main.xml +++ b/app/src/main/res/values/strings_main.xml @@ -66,6 +66,7 @@ Unlock to recover your signing group share Share recovered Recovery failed. Please try again. + This device already holds a different key for this share, so it was not recovered. The pending share is kept. Discard Pending Share? This permanently deletes the pending share. This can\'t be undone, and the group will need to be created again. Discard Pending Share @@ -135,6 +136,7 @@ Import failed. Please try again. This key is already stored on this device. Another share of this group is already stored on this device. + This share is already stored on this device. View Seed Words Authenticate to view seed words No seed words available for this account diff --git a/app/src/test/kotlin/io/privkey/keep/ImportFailureMessageTest.kt b/app/src/test/kotlin/io/privkey/keep/ImportFailureMessageTest.kt index b1dfe520..8a6595f6 100644 --- a/app/src/test/kotlin/io/privkey/keep/ImportFailureMessageTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/ImportFailureMessageTest.kt @@ -2,30 +2,39 @@ package io.privkey.keep import io.privkey.keep.uniffi.KeepMobileException import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class ImportFailureMessageTest { @Test fun aStoredKeyIsNamed() { - assertEquals( - R.string.account_import_already_stored, - importFailureMessage(KeepMobileException.InvalidShare("This key is already stored")) - ) + assertEquals(R.string.account_import_already_stored, importFailureMessage(KeepMobileException.KeyAlreadyStored())) + } + + @Test + fun theSameShareIsNamed() { + assertEquals(R.string.account_import_share_stored, importFailureMessage(KeepMobileException.ShareAlreadyStored())) } @Test fun anotherShareOfTheGroupIsNamed() { - assertEquals( - R.string.account_import_other_share_stored, - importFailureMessage(KeepMobileException.InvalidShare("Another share of this group is already stored")) - ) + assertEquals(R.string.account_import_other_share_stored, importFailureMessage(KeepMobileException.GroupShareAlreadyStored())) } @Test fun anyOtherFailureStaysGeneric() { - assertEquals(R.string.account_import_failed, importFailureMessage(KeepMobileException.InvalidShare("bad checksum"))) + // The refusal is read from the variant, never from text. + assertEquals(R.string.account_import_failed, importFailureMessage(KeepMobileException.InvalidShare("This key is already stored"))) assertEquals(R.string.account_import_failed, importFailureMessage(KeepMobileException.StorageException("This key is already stored"))) assertEquals(R.string.account_import_failed, importFailureMessage(IllegalStateException())) } + + @Test + fun aDkgRecoveryRefusedOverAStoredShareSaysWhy() { + assertEquals(R.string.main_pending_dkg_recover_share_stored, dkgRecoveryRefusal(KeepMobileException.ShareAlreadyStored())) + assertEquals(R.string.account_import_other_share_stored, dkgRecoveryRefusal(KeepMobileException.GroupShareAlreadyStored())) + assertEquals(R.string.account_import_already_stored, dkgRecoveryRefusal(KeepMobileException.KeyAlreadyStored())) + assertNull(dkgRecoveryRefusal(KeepMobileException.StorageException("Failed to decrypt share"))) + } } diff --git a/keep.version b/keep.version index 73f74940..21eeb3b3 100644 --- a/keep.version +++ b/keep.version @@ -1 +1 @@ -8da09b1f869460310cc9b2b0ed55ace58b56dbaf +6513a94162f66f5a06bc3c1029b188ace41e5472