diff --git a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/contentprovider/WalletContentProvider.kt b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/contentprovider/WalletContentProvider.kt index e2fad9cd..b8c319c5 100644 --- a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/contentprovider/WalletContentProvider.kt +++ b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/contentprovider/WalletContentProvider.kt @@ -54,7 +54,7 @@ class WalletContentProvider : ContentProvider() { } override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { - enforcePermission() + enforceCallerPermission() checkDependencyInjection() return when (method) { @@ -115,7 +115,7 @@ class WalletContentProvider : ContentProvider() { queryArgs: Bundle?, cancellationSignal: CancellationSignal? ): Cursor { - enforcePermission() + enforceCallerPermission() checkDependencyInjection() val match = uriMatcher.match(uri) @@ -175,7 +175,12 @@ class WalletContentProvider : ContentProvider() { projection: Array?, queryArgs: Bundle? ): Cursor { - val defaultProjection = WalletContractV1.AUTHORIZED_SEEDS_ALL_COLUMNS.toSet() + val callerIsPrivileged = + callerHasPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED) + // Only privileged wallets can retrieve the AUTHORIZED_SEEDS_IS_BACKED_UP column + val defaultProjection = WalletContractV1.AUTHORIZED_SEEDS_ALL_COLUMNS.filter { column -> + callerIsPrivileged || column != WalletContractV1.AUTHORIZED_SEEDS_IS_BACKED_UP + }.toSet() val queryParser = makeQueryParser(defaultProjection, queryArgs) val filteredProjection = projection?.intersect(defaultProjection) ?: defaultProjection val cursor = MatrixCursor(filteredProjection.toTypedArray()) @@ -184,10 +189,9 @@ class WalletContentProvider : ContentProvider() { runBlocking { seedRepository.delayUntilDataValid() - if (requireContext().checkCallingPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED) == PackageManager.PERMISSION_GRANTED) { + if (callerIsPrivileged) { seedRepository.authorizeAllSeedsForUid( - uid, - Authorization.Purpose.SIGN_SOLANA_TRANSACTIONS + uid, Authorization.Purpose.SIGN_SOLANA_TRANSACTIONS ) } } @@ -196,10 +200,11 @@ class WalletContentProvider : ContentProvider() { seed.authorizations.forEach { auth -> // Note: must be in the same order as defaultProjection val values = arrayOf( - auth.authToken, // WalletContractV1.AUTHORIZED_SEEDS_AUTH_TOKEN - auth.purpose.toWalletContractConstant(), // WalletContractV1.AUTHORIZED_SEEDS_AUTH_PURPOSE - seed.details.name ?: "" // WalletContractV1.AUTHORIZED_SEEDS_SEED_NAME - ) + auth.authToken, // WalletContractV1.AUTHORIZED_SEEDS_AUTH_TOKEN + auth.purpose.toWalletContractConstant(), // WalletContractV1.AUTHORIZED_SEEDS_AUTH_PURPOSE + seed.details.name ?: "", // WalletContractV1.AUTHORIZED_SEEDS_SEED_NAME + if (seed.details.isBackedUp) 1.toShort() else 0.toShort(), // WalletContractV1.AUTHORIZED_SEEDS_IS_BACKED_UP + ).sliceArray(defaultProjection.indices) if (auth.uid == uid && (authToken == null || auth.authToken == authToken) @@ -222,6 +227,8 @@ class WalletContentProvider : ContentProvider() { projection: Array?, queryArgs: Bundle? ): Cursor { + val callerIsPrivileged = + callerHasPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED) val purposeAsEnum = purpose?.let { Authorization.Purpose.fromWalletContractConstant(it) } val defaultProjection = WalletContractV1.UNAUTHORIZED_SEEDS_ALL_COLUMNS.toSet() val queryParser = makeQueryParser(defaultProjection, queryArgs) @@ -231,10 +238,9 @@ class WalletContentProvider : ContentProvider() { val seedRepository = dependencyContainer.seedRepository runBlocking { seedRepository.delayUntilDataValid() - if (requireContext().checkCallingPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED) == PackageManager.PERMISSION_GRANTED) { + if (callerIsPrivileged) { seedRepository.authorizeAllSeedsForUid( - uid, - Authorization.Purpose.SIGN_SOLANA_TRANSACTIONS + uid, Authorization.Purpose.SIGN_SOLANA_TRANSACTIONS ) } } @@ -378,7 +384,7 @@ class WalletContentProvider : ContentProvider() { uri: Uri, extras: Bundle? ): Int { - enforcePermission() + enforceCallerPermission() checkDependencyInjection() val match = uriMatcher.match(uri) @@ -434,7 +440,7 @@ class WalletContentProvider : ContentProvider() { values: ContentValues?, extras: Bundle? ): Int { - enforcePermission() + enforceCallerPermission() checkDependencyInjection() val match = uriMatcher.match(uri) @@ -599,9 +605,12 @@ class WalletContentProvider : ContentProvider() { } } - private fun enforcePermission() { - if (requireContext().checkCallingPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT) == PackageManager.PERMISSION_GRANTED || - requireContext().checkCallingPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED) == PackageManager.PERMISSION_GRANTED) { + private fun callerHasPermission(permission: String): Boolean = + requireContext().checkCallingPermission(permission) == PackageManager.PERMISSION_GRANTED + + private fun enforceCallerPermission() { + if (callerHasPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT) || + callerHasPermission(WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED)) { return } throw SecurityException("Permission Denial:: opening provider $TAG requires ${WalletContractV1.PERMISSION_ACCESS_SEED_VAULT} or ${WalletContractV1.PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED}") diff --git a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/data/SeedRepository.kt b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/data/SeedRepository.kt index db0dcf75..939e9299 100644 --- a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/data/SeedRepository.kt +++ b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/data/SeedRepository.kt @@ -104,7 +104,8 @@ class SeedRepository @Inject constructor( sr.seed.seedPhraseWordIndicesList, sr.seed.name.ifEmpty { null }, sr.seed.pin, - sr.seed.unlockWithBiometrics + sr.seed.unlockWithBiometrics, + sr.seed.isBackedUp ) val authorizations = sr.authorizationsList.map { ae -> Authorization(ae.uid, ae.authToken, Authorization.Purpose.entries[ae.purpose]) @@ -532,6 +533,7 @@ class SeedRepository @Inject constructor( } pin = details.pin unlockWithBiometrics = details.unlockWithBiometrics + isBackedUp = details.isBackedUp } // NOTE: should be called with mutex held diff --git a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/model/SeedDetails.kt b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/model/SeedDetails.kt index 2efc36e3..f30d9bd8 100644 --- a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/model/SeedDetails.kt +++ b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/model/SeedDetails.kt @@ -10,6 +10,7 @@ data class SeedDetails( val name: String? = null, val pin: String, val unlockWithBiometrics: Boolean = false, + val isBackedUp: Boolean = false, ) { companion object { const val ENTROPY_SHORT = 128 @@ -45,6 +46,7 @@ data class SeedDetails( if (name != other.name) return false if (pin != other.pin) return false if (unlockWithBiometrics != other.unlockWithBiometrics) return false + if (isBackedUp != other.isBackedUp) return false return true } @@ -55,6 +57,7 @@ data class SeedDetails( result = 31 * result + (name?.hashCode() ?: 0) result = 31 * result + pin.hashCode() result = 31 * result + unlockWithBiometrics.hashCode() + result = 31 * result + isBackedUp.hashCode() return result } } diff --git a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailActivity.kt b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailActivity.kt index d0a98a9d..d14272ac 100644 --- a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailActivity.kt +++ b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailActivity.kt @@ -290,7 +290,7 @@ class SeedDetailActivity : AppCompatActivity() { } Row( - modifier = Modifier.padding(vertical = Sizes.dp16), + modifier = Modifier.padding(top = Sizes.dp16), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -314,6 +314,31 @@ class SeedDetailActivity : AppCompatActivity() { ) } + Row( + modifier = Modifier.padding(top = Sizes.dp4, bottom = Sizes.dp16), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .padding(end = Sizes.dp16) + .weight(1f), + text = stringResource(id = R.string.label_is_seed_backed_up), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + modifier = Modifier.semantics { + testTag = "IsBackedUp" + }, + checked = seedDetails.isBackedUp, + onCheckedChange = { + viewModel.setSeedIsBackedUp(it) + } + ) + } + Row( modifier = Modifier .fillMaxWidth() diff --git a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailViewModel.kt b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailViewModel.kt index a50a2871..e5f7e348 100644 --- a/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailViewModel.kt +++ b/SeedVaultSimulator/src/main/java/com/solanamobile/seedvaultimpl/ui/seeddetail/SeedDetailViewModel.kt @@ -65,6 +65,7 @@ class SeedDetailViewModel @Inject constructor( }, pin = seed.details.pin, enableBiometrics = seed.details.unlockWithBiometrics, + isBackedUp = seed.details.isBackedUp, accounts = seed.accounts, authorizedApps = seed.authorizations ) @@ -109,6 +110,11 @@ class SeedDetailViewModel @Inject constructor( _seedDetailUiState.update { it.copy(enableBiometrics = en) } } + fun setSeedIsBackedUp(isBackedUp: Boolean) { + Log.d(TAG, "setSeedIsBackedUp($isBackedUp)") + _seedDetailUiState.update { it.copy(isBackedUp = isBackedUp) } + } + private fun setErrorMessage(message: String?) { _seedDetailUiState.update { it.copy(errorMessage = message) } } @@ -126,7 +132,14 @@ class SeedDetailViewModel @Inject constructor( Bip39PhraseUseCase.toIndex(w) } val seedBytes = Bip39PhraseUseCase.toSeed(phrase) - SeedDetails(seedBytes, phrase, it.name.ifBlank { null }, it.pin, it.enableBiometrics) + SeedDetails( + seedBytes, + phrase, + it.name.ifBlank { null }, + it.pin, + it.enableBiometrics, + it.isBackedUp + ) } Log.i(TAG, "Successfully created Seed $seedDetails; committing to SeedRepository") @@ -209,6 +222,7 @@ data class SeedDetailUiState( val phrase: List = List(SeedPhraseLength.SEED_PHRASE_24_WORDS.length) { "" }, val pin: String = "", val enableBiometrics: Boolean = false, + val isBackedUp: Boolean = false, val accounts: List = listOf(), val authorizedApps: List = listOf(), val errorMessage: String? = null, diff --git a/SeedVaultSimulator/src/main/proto/seed_repo.proto b/SeedVaultSimulator/src/main/proto/seed_repo.proto index 8afab9b6..01015d93 100644 --- a/SeedVaultSimulator/src/main/proto/seed_repo.proto +++ b/SeedVaultSimulator/src/main/proto/seed_repo.proto @@ -9,6 +9,7 @@ message SeedEntry { string name = 3; string pin = 4; bool unlock_with_biometrics = 5; + bool is_backed_up = 6; } message AuthorizationEntry { diff --git a/SeedVaultSimulator/src/main/res/values/strings.xml b/SeedVaultSimulator/src/main/res/values/strings.xml index e7532432..1e57d60f 100644 --- a/SeedVaultSimulator/src/main/res/values/strings.xml +++ b/SeedVaultSimulator/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ 4 to 20 characters PIN must be between 4 and 20 characters Enable biometrics for this seed + Mark seed as backed up Authorized wallet apps diff --git a/cts/src/main/java/com/solanamobile/seedvault/cts/PrivilegedSeedVaultChecker.kt b/cts/src/main/java/com/solanamobile/seedvault/cts/PrivilegedSeedVaultChecker.kt new file mode 100644 index 00000000..e594e62d --- /dev/null +++ b/cts/src/main/java/com/solanamobile/seedvault/cts/PrivilegedSeedVaultChecker.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024 Solana Mobile Inc. + */ + +package com.solanamobile.seedvault.cts + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PrivilegedSeedVaultChecker @Inject constructor() { + fun isPrivileged(): Boolean { + @Suppress("KotlinConstantConditions") + return BuildConfig.FLAVOR == "Privileged" + } +} \ No newline at end of file diff --git a/cts/src/main/java/com/solanamobile/seedvault/cts/data/SagaChecker.kt b/cts/src/main/java/com/solanamobile/seedvault/cts/data/SagaChecker.kt index 5627bc68..5ae56b8d 100644 --- a/cts/src/main/java/com/solanamobile/seedvault/cts/data/SagaChecker.kt +++ b/cts/src/main/java/com/solanamobile/seedvault/cts/data/SagaChecker.kt @@ -1,3 +1,7 @@ +/* + * Copyright (c) 2024 Solana Mobile Inc. + */ + package com.solanamobile.seedvault.cts.data import javax.inject.Inject diff --git a/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/AuthorizedSeedsContentProviderTestCase.kt b/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/AuthorizedSeedsContentProviderTestCase.kt index 586e0184..fa761c96 100644 --- a/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/AuthorizedSeedsContentProviderTestCase.kt +++ b/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/AuthorizedSeedsContentProviderTestCase.kt @@ -10,6 +10,7 @@ import android.content.Context import android.database.Cursor import com.solanamobile.seedvault.Wallet import com.solanamobile.seedvault.WalletContractV1 +import com.solanamobile.seedvault.cts.PrivilegedSeedVaultChecker import com.solanamobile.seedvault.cts.data.ConditionChecker import com.solanamobile.seedvault.cts.data.SagaChecker import com.solanamobile.seedvault.cts.data.TestCaseImpl @@ -30,6 +31,7 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( private val ctx: Context, private val logger: TestSessionLogger, private val sagaChecker: SagaChecker, + private val privilegedSeedVaultChecker: PrivilegedSeedVaultChecker, ) : TestCaseImpl( preConditions = preConditions ) { @@ -165,6 +167,7 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( action: ((index: Int, authToken: Long) -> Unit)? = null ): Boolean { if (c.count != authorizedSeeds.size) return false + if (!validateAuthorizedSeedsCursor(c)) return false val found = BooleanArray(authorizedSeeds.size) while (c.moveToNext()) { @@ -209,7 +212,8 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( expectedAuthToken: Long, expectedName: String ): Boolean { - return c.count == 1 && // Current implementations only define a single PURPOSE_* value + return validateAuthorizedSeedsCursor(c) && + c.count == 1 && // Current implementations only define a single PURPOSE_* value c.moveToNext() && c.getLong(0) == expectedAuthToken && c.getInt(1) == WalletContractV1.PURPOSE_SIGN_SOLANA_TRANSACTION && @@ -275,7 +279,8 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( } private fun validateUnknownAuthTokenFilteredCursor(c: Cursor): Boolean { - return c.count == 0 + return validateAuthorizedSeedsCursor(c) && + c.count == 0 } private fun testTableIdFilterUnknownAuthToken(): Boolean { @@ -320,6 +325,7 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( private fun validatePurposeFilteredCursor(c: Cursor): Boolean { if (c.count != authTokens.size) return false + if (!validateAuthorizedSeedsCursor(c)) return false val found = BooleanArray(authTokens.size) while (c.moveToNext()) { @@ -359,7 +365,8 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( } private fun validateUnknownPurposeFilteredCursor(c: Cursor): Boolean { - return c.count == 0 + return validateAuthorizedSeedsCursor(c) && + c.count == 0 } private fun testTableExpressionFilterUnknownPurpose(): Boolean { @@ -386,7 +393,8 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( expectedAuthToken: Long, expectedName: String ): Boolean { - return c.count == 1 && + return validateAuthorizedSeedsCursor(c) && + c.count == 1 && c.moveToFirst() && c.getLong(0) == expectedAuthToken && c.getInt(1) == WalletContractV1.PURPOSE_SIGN_SOLANA_TRANSACTION && @@ -417,7 +425,7 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( } private fun validateUnknownNameFilteredCursor(c: Cursor): Boolean { - return c.count == 0 + return validateAuthorizedSeedsCursor(c) && c.count == 0 } private fun testTableExpressionFilterUnknownName(): Boolean { @@ -438,6 +446,41 @@ internal abstract class AuthorizedSeedsContentProviderTestCase( "NoSeedShouldExistWithThisName" )?.use { c -> validateUnknownNameFilteredCursor(c) } ?: false } + + private fun validateAuthorizedSeedsCursor(c: Cursor): Boolean { + val authTokenIdx = c.getColumnIndex(WalletContractV1.AUTHORIZED_SEEDS_AUTH_TOKEN) + val authPurposeIdx = c.getColumnIndex(WalletContractV1.AUTHORIZED_SEEDS_AUTH_PURPOSE) + val seedNameIdx = c.getColumnIndex(WalletContractV1.AUTHORIZED_SEEDS_SEED_NAME) + val isBackedUpIdx = c.getColumnIndex(WalletContractV1.AUTHORIZED_SEEDS_IS_BACKED_UP) + + // Ensure all required columns are present + if (authTokenIdx == -1 || + authPurposeIdx == -1 || + seedNameIdx == -1 || + if (privilegedSeedVaultChecker.isPrivileged()) { + isBackedUpIdx == -1 + } else { + isBackedUpIdx != -1 + }) return false + + // Check the data type of every record + val originalPosition = c.position + c.moveToPosition(-1) + var result = true + while (c.moveToNext()) { + if (c.getType(authTokenIdx) != Cursor.FIELD_TYPE_INTEGER || + c.getType(authPurposeIdx) != Cursor.FIELD_TYPE_INTEGER || + c.getType(seedNameIdx) != Cursor.FIELD_TYPE_STRING || + (isBackedUpIdx != -1 && c.getType(isBackedUpIdx) != Cursor.FIELD_TYPE_INTEGER) + ) { + result = false + break + } + } + c.moveToPosition(originalPosition) + + return result + } companion object { private const val UNKNOWN_AUTH_TOKEN = 7394872231938472276L // if this random value matches, ¯\_(ツ)_/¯ @@ -449,13 +492,15 @@ internal class NoAuthorizedSeedsContentProviderTestCase @Inject constructor( noAuthorizedSeedsChecker: NoAuthorizedSeedsChecker, @ApplicationContext private val ctx: Context, logger: TestSessionLogger, - sagaChecker: SagaChecker + sagaChecker: SagaChecker, + privilegedSeedVaultChecker: PrivilegedSeedVaultChecker ) : AuthorizedSeedsContentProviderTestCase( emptyList(), preConditions = listOf(hasSeedVaultPermissionChecker, noAuthorizedSeedsChecker), ctx, logger, - sagaChecker + sagaChecker, + privilegedSeedVaultChecker ) { override val id: String = "nascp" override val description: String = "Verify content provider behavior for ${WalletContractV1.AUTHORIZED_SEEDS_TABLE} when no seeds are authorized" @@ -468,7 +513,8 @@ internal class HasAuthorizedSeedsContentProviderTestCase @Inject constructor( knownSeed24AuthorizedChecker: KnownSeed24AuthorizedChecker, @ApplicationContext private val ctx: Context, logger: TestSessionLogger, - sagaChecker: SagaChecker + sagaChecker: SagaChecker, + privilegedSeedVaultChecker: PrivilegedSeedVaultChecker ) : AuthorizedSeedsContentProviderTestCase( listOf(KnownSeed12.SEED_NAME, KnownSeed24.SEED_NAME), preConditions = listOf( @@ -478,7 +524,8 @@ internal class HasAuthorizedSeedsContentProviderTestCase @Inject constructor( ), ctx, logger, - sagaChecker + sagaChecker, + privilegedSeedVaultChecker ) { override val id: String = "hascp" override val description: String = "Verify content provider behavior for ${WalletContractV1.AUTHORIZED_SEEDS_TABLE} when two seeds are authorized" diff --git a/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/PubKeyDerivationTestCase.kt b/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/PubKeyDerivationTestCase.kt index c5c8e782..eb948933 100644 --- a/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/PubKeyDerivationTestCase.kt +++ b/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/PubKeyDerivationTestCase.kt @@ -21,6 +21,7 @@ import com.solanamobile.seedvault.Wallet import com.solanamobile.seedvault.WalletContractV1 import com.solanamobile.seedvault.WalletContractV1.AuthToken import com.solanamobile.seedvault.cts.BuildConfig +import com.solanamobile.seedvault.cts.PrivilegedSeedVaultChecker import com.solanamobile.seedvault.cts.data.ActivityLauncherTestCase import com.solanamobile.seedvault.cts.data.TestCaseImpl import com.solanamobile.seedvault.cts.data.TestResult @@ -324,6 +325,7 @@ internal class PermissionedAccountFetchPubKeysTestCase @Inject constructor( logger: TestSessionLogger, hasSeedVaultPermissionChecker: HasSeedVaultPermissionChecker, knownSeed12AuthorizedChecker: KnownSeed12AuthorizedChecker, + private val privilegedSeedVaultChecker: PrivilegedSeedVaultChecker, ) : PubKeyDerivationTestCase( context = context, knownSeed12AuthorizedChecker = knownSeed12AuthorizedChecker, @@ -387,9 +389,8 @@ internal class PermissionedAccountFetchPubKeysTestCase @Inject constructor( ) { override val id: String = "pafpktc" override val description: String = "Fetch 10 permissioned pubkeys." - @Suppress("KotlinConstantConditions") override val instructions: String get() { - return if (BuildConfig.FLAVOR == "Privileged") { + return if (privilegedSeedVaultChecker.isPrivileged()) { "This should be done without any user interaction. If an auth dialog is shown, the test fails." } else { "This should should required a user authorization. If keys are fetched without user authorization, then the test fails." diff --git a/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/TestCorpusProvider.kt b/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/TestCorpusProvider.kt index b78ec987..a6b07e2d 100644 --- a/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/TestCorpusProvider.kt +++ b/cts/src/main/java/com/solanamobile/seedvault/cts/data/tests/TestCorpusProvider.kt @@ -5,6 +5,7 @@ package com.solanamobile.seedvault.cts.data.tests import com.solanamobile.seedvault.cts.BuildConfig +import com.solanamobile.seedvault.cts.PrivilegedSeedVaultChecker import com.solanamobile.seedvault.cts.data.SagaChecker import com.solanamobile.seedvault.cts.data.TestCorpus import com.solanamobile.seedvault.cts.data.TestSessionLogger @@ -59,14 +60,13 @@ internal object TestCorpusProvider { signTransactionSignaturesExceedLimitTestCase: SignTransactionSignaturesExceedLimitTestCase, logger: TestSessionLogger, sagaChecker: SagaChecker, + privilegedSeedVaultChecker: PrivilegedSeedVaultChecker, ): TestCorpus { val isSaga = sagaChecker.isSaga() if (isSaga) { logger.warn("Running additional bypass for Saga only.") } - @Suppress("KotlinConstantConditions") - val isGenericBuild = BuildConfig.FLAVOR == "Generic" - @Suppress("KotlinConstantConditions") + val isGenericBuild = !privilegedSeedVaultChecker.isPrivileged() return listOfNotNull( noPermissionsContentProviderCheck.takeIf { isGenericBuild }, acquireSeedVaultPrivilegedPermissionTestCase.takeIf { isGenericBuild }, diff --git a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt index 34d512fb..3a6adde6 100644 --- a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt +++ b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/MainViewModel.kt @@ -95,6 +95,8 @@ class MainViewModel( val authToken = authorizedSeedsCursor.getLong(0) val authPurpose = authorizedSeedsCursor.getInt(1) val seedName = authorizedSeedsCursor.getString(2) + val isBackedUp = + if (authorizedSeedsCursor.columnCount == 4) authorizedSeedsCursor.getShort(3) else 0.toShort() val accounts = mutableListOf() val accountsCursor = withContext(Dispatchers.Default) { @@ -114,7 +116,13 @@ class MainViewModel( accountsCursor.close() seeds.add( - Seed(authToken, seedName.ifBlank { authToken.toString() }, authPurpose, accounts) + Seed( + authToken, + seedName.ifBlank { authToken.toString() }, + authPurpose, + isBackedUp == 1.toShort(), + accounts + ) ) } authorizedSeedsCursor.close() @@ -614,6 +622,7 @@ data class Seed( @WalletContractV1.AuthToken val authToken: Long, val name: String, @WalletContractV1.Purpose val purpose: Int, + val isBackedUp: Boolean, val accounts: List = listOf() ) diff --git a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/SeedDetails.kt b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/SeedDetails.kt index d6d9784e..7e663663 100644 --- a/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/SeedDetails.kt +++ b/fakewallet/src/main/java/com/solanamobile/fakewallet/ui/SeedDetails.kt @@ -4,13 +4,16 @@ package com.solanamobile.fakewallet.ui +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -69,6 +72,8 @@ fun SeedDetails( onSignMaxMessagesWithMaxSignatures: (Seed) -> Unit, onSignPermissionedAccountMessages: (Seed) -> Unit ) { + val isSeedVaultPrivileged = BuildConfig.FLAVOR == "Privileged" + Column( modifier = Modifier .fillMaxWidth() @@ -87,7 +92,7 @@ fun SeedDetails( color = MaterialTheme.colorScheme.onSurface, text = seed.name, ) - if (BuildConfig.FLAVOR != "Privileged") { + if (!isSeedVaultPrivileged) { IconButton(onClick = { onDeauthorizeSeed(seed) }) { Icon( painter = painterResource(id = android.R.drawable.ic_delete), @@ -111,6 +116,22 @@ fun SeedDetails( seed.purpose ) ) + if (isSeedVaultPrivileged) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.padding(vertical = Sizes.dp8), + text = stringResource(id = R.string.label_seed_is_backed_up) + ) + Icon( + modifier = Modifier.padding(Sizes.dp4), + painter = painterResource(id = if (seed.isBackedUp) android.R.drawable.ic_secure else android.R.drawable.ic_delete), + contentDescription = null + ) + } + } TextButton( modifier = Modifier .fillMaxWidth() @@ -126,7 +147,7 @@ fun SeedDetails( ) ) } - if (BuildConfig.FLAVOR == "Privileged") { + if (isSeedVaultPrivileged) { TextButton( modifier = Modifier .fillMaxWidth() @@ -156,7 +177,7 @@ fun SeedDetails( ) ) } - if (BuildConfig.FLAVOR == "Privileged") { + if (isSeedVaultPrivileged) { TextButton( modifier = Modifier .fillMaxWidth() @@ -188,7 +209,7 @@ fun SeedDetails( ) ) } - if (BuildConfig.FLAVOR == "Privileged") { + if (isSeedVaultPrivileged) { TextButton( modifier = Modifier .fillMaxWidth() @@ -243,8 +264,7 @@ fun AccountComposable( .fillMaxWidth() .height(Sizes.dp48) .background( - MaterialTheme.colorScheme.inverseOnSurface, - CircleShape + MaterialTheme.colorScheme.inverseOnSurface, CircleShape ), value = TextFieldValue( accountName.value, diff --git a/fakewallet/src/main/res/values/strings.xml b/fakewallet/src/main/res/values/strings.xml index 0bb6bb10..3e2e00cb 100644 --- a/fakewallet/src/main/res/values/strings.xml +++ b/fakewallet/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ FakeWalletGeneric Purpose: + Backed Up: Account: Public Key: Path: diff --git a/seedvault/src/main/java/com/solanamobile/seedvault/WalletContractV1.java b/seedvault/src/main/java/com/solanamobile/seedvault/WalletContractV1.java index 5059b69b..10c62272 100644 --- a/seedvault/src/main/java/com/solanamobile/seedvault/WalletContractV1.java +++ b/seedvault/src/main/java/com/solanamobile/seedvault/WalletContractV1.java @@ -426,9 +426,17 @@ public final class WalletContractV1 { /** Type: {@code String} (may be blank) */ public static final String AUTHORIZED_SEEDS_SEED_NAME = "AuthorizedSeeds_SeedName"; + /** + * Type: {@code short} (1 for true, 0 for false) + *

NOTE: this column will only be included in the result if the app holds the + * {@link #PERMISSION_ACCESS_SEED_VAULT_PRIVILEGED} permission

+ */ + public static final String AUTHORIZED_SEEDS_IS_BACKED_UP = "AuthorizedSeeds_IsBackedUp"; + /** All columns for the Wallet content provider authorized seeds table */ public static final String[] AUTHORIZED_SEEDS_ALL_COLUMNS = { - AUTHORIZED_SEEDS_AUTH_TOKEN, AUTHORIZED_SEEDS_AUTH_PURPOSE, AUTHORIZED_SEEDS_SEED_NAME}; + AUTHORIZED_SEEDS_AUTH_TOKEN, AUTHORIZED_SEEDS_AUTH_PURPOSE, AUTHORIZED_SEEDS_SEED_NAME, + AUTHORIZED_SEEDS_IS_BACKED_UP}; /** Wallet content provider unauthorized seeds table name */ public static final String UNAUTHORIZED_SEEDS_TABLE = "unauthorizedseeds";