* [#551] Prevent screenshots when seed phrase is displayed on the screen * Minor review changes Make ScreenSecurity object simpler by eliminating additional ways of mutating its state. * Add debug and test checks * secured screens with seed phrase * codereview fixes * added test for Seed screen * added Screen security test for backup views * test for ScreenSecurity class * added manual testing for Screen security * ktlint format * Clean up ScreenSecurityTest * Remove debug check I decided this would be annoying, especially for manual testing * ktlint * removed test logging * removed test logging Co-authored-by: Carter Jernigan <git@carterjernigan.com>
This commit is contained in:
parent
45d90b7706
commit
a96637ec07
|
@ -0,0 +1,10 @@
|
|||
# Testing screens with secure flag
|
||||
1. install the app
|
||||
1. go to screens with seed phrase:
|
||||
1. onboarding -> "Your Secret Recovery Phrase"
|
||||
1. onboarding -> "Verify Your Backup"
|
||||
1. onboarding -> "Import an Existing Wallet"
|
||||
1. settings -> "Backup wallet"
|
||||
1. make screenshot
|
||||
1. verify that the screenshot is "blacked out"
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package co.electriccoin.zcash.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.filters.MediumTest
|
||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ScreenSecurityTest : UiTestPrerequisites() {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun acquireAndReleaseScreenSecurity() = runTest {
|
||||
val testSetup = TestSetup(composeTestRule)
|
||||
|
||||
assertEquals(1, testSetup.getSecureScreenCount())
|
||||
|
||||
testSetup.mutableSecureScreenFlag.update { false }
|
||||
composeTestRule.awaitIdle()
|
||||
assertEquals(0, testSetup.getSecureScreenCount())
|
||||
}
|
||||
|
||||
private class TestSetup(composeTestRule: ComposeContentTestRule) {
|
||||
|
||||
val mutableSecureScreenFlag = MutableStateFlow(true)
|
||||
|
||||
private val screenSecurity = ScreenSecurity()
|
||||
|
||||
fun getSecureScreenCount() = screenSecurity.referenceCount.value
|
||||
|
||||
init {
|
||||
runTest {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
||||
ZcashTheme {
|
||||
val secureScreen by mutableSecureScreenFlag.collectAsState()
|
||||
|
||||
TestView(secureScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestView(secureScreen: Boolean) {
|
||||
if (secureScreen) {
|
||||
SecureScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package co.electriccoin.zcash.ui.screen.backup.view
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.filters.MediumTest
|
||||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
|
||||
import co.electriccoin.zcash.ui.common.ScreenSecurity
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.fixture.TestChoicesFixture
|
||||
import co.electriccoin.zcash.ui.screen.backup.model.BackupStage
|
||||
import co.electriccoin.zcash.ui.screen.backup.state.BackupState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BackupViewsSecuredScreenTest : UiTestPrerequisites() {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun newTestSetup(initialStage: BackupStage) =
|
||||
TestSetup(composeTestRule, initialStage).apply {
|
||||
setContentView()
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun acquireScreenSecuritySeedStage() = runTest {
|
||||
val testSetup = newTestSetup(BackupStage.Seed)
|
||||
|
||||
assertEquals(1, testSetup.getSecureScreenCount())
|
||||
}
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun acquireScreenSecurityTestStage() = runTest {
|
||||
val testSetup = newTestSetup(BackupStage.Test)
|
||||
|
||||
assertEquals(1, testSetup.getSecureScreenCount())
|
||||
}
|
||||
|
||||
private class TestSetup(private val composeTestRule: ComposeContentTestRule, initialStage: BackupStage) {
|
||||
private val screenSecurity = ScreenSecurity()
|
||||
private val state = BackupState(initialStage)
|
||||
|
||||
fun getSecureScreenCount() = screenSecurity.referenceCount.value
|
||||
|
||||
fun setContentView() {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
||||
ZcashTheme {
|
||||
BackupWallet(
|
||||
PersistableWalletFixture.new(),
|
||||
state,
|
||||
TestChoicesFixture.new(mutableMapOf()),
|
||||
onCopyToClipboard = { },
|
||||
onComplete = { },
|
||||
onChoicesChanged = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package co.electriccoin.zcash.ui.screen.restore.view
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.filters.MediumTest
|
||||
import cash.z.ecc.android.bip39.Mnemonics
|
||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
|
||||
import co.electriccoin.zcash.ui.common.ScreenSecurity
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.screen.restore.state.WordList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.Locale
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RestoreViewSecuredScreenTest : UiTestPrerequisites() {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun acquireScreenSecurity() = runTest {
|
||||
val testSetup = TestSetup(composeTestRule)
|
||||
|
||||
assertEquals(1, testSetup.getSecureScreenCount())
|
||||
}
|
||||
|
||||
private class TestSetup(composeTestRule: ComposeContentTestRule) {
|
||||
private val screenSecurity = ScreenSecurity()
|
||||
|
||||
fun getSecureScreenCount() = screenSecurity.referenceCount.value
|
||||
|
||||
init {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
||||
ZcashTheme {
|
||||
RestoreWallet(
|
||||
Mnemonics.getCachedWords(Locale.ENGLISH.language).toSortedSet(),
|
||||
WordList(emptyList()),
|
||||
onBack = { },
|
||||
paste = { "" },
|
||||
onFinished = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package co.electriccoin.zcash.ui.screen.seed.view
|
||||
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.filters.MediumTest
|
||||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
|
||||
import co.electriccoin.zcash.ui.common.ScreenSecurity
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SeedViewSecuredScreenTest : UiTestPrerequisites() {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun acquireScreenSecurity() = runTest {
|
||||
val testSetup = TestSetup(composeTestRule)
|
||||
|
||||
assertEquals(1, testSetup.getSecureScreenCount())
|
||||
}
|
||||
|
||||
private class TestSetup(composeTestRule: ComposeContentTestRule) {
|
||||
private val screenSecurity = ScreenSecurity()
|
||||
|
||||
fun getSecureScreenCount() = screenSecurity.referenceCount.value
|
||||
|
||||
init {
|
||||
composeTestRule.setContent {
|
||||
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
||||
ZcashTheme {
|
||||
Seed(
|
||||
persistableWallet = PersistableWalletFixture.new(),
|
||||
onBack = {},
|
||||
onCopyToClipboard = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,17 +2,23 @@ package co.electriccoin.zcash.ui
|
|||
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavHostController
|
||||
import co.electriccoin.zcash.spackle.EmulatorWtfUtil
|
||||
import co.electriccoin.zcash.spackle.FirebaseTestLabUtil
|
||||
import co.electriccoin.zcash.ui.common.LocalScreenSecurity
|
||||
import co.electriccoin.zcash.ui.common.ScreenSecurity
|
||||
import co.electriccoin.zcash.ui.design.compat.FontCompat
|
||||
import co.electriccoin.zcash.ui.design.component.ConfigurationOverride
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
|
@ -23,6 +29,7 @@ import co.electriccoin.zcash.ui.screen.home.viewmodel.SecretState
|
|||
import co.electriccoin.zcash.ui.screen.home.viewmodel.WalletViewModel
|
||||
import co.electriccoin.zcash.ui.screen.onboarding.WrapOnboarding
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
@ -73,6 +80,23 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
|
||||
private fun setupUiContent() {
|
||||
val screenSecurity = ScreenSecurity()
|
||||
lifecycleScope.launch {
|
||||
screenSecurity.referenceCount.map { it > 0 }.collect { isSecure ->
|
||||
val isTest = FirebaseTestLabUtil.isFirebaseTestLab(applicationContext) ||
|
||||
EmulatorWtfUtil.isEmulatorWtf(applicationContext)
|
||||
|
||||
if (isSecure && !isTest) {
|
||||
window.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
Override(configurationOverrideFlow) {
|
||||
ZcashTheme {
|
||||
|
@ -81,22 +105,24 @@ class MainActivity : ComponentActivity() {
|
|||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
when (val secretState = walletViewModel.secretState.collectAsState().value) {
|
||||
SecretState.Loading -> {
|
||||
// For now, keep displaying splash screen using condition above.
|
||||
// In the future, we might consider displaying something different here.
|
||||
}
|
||||
SecretState.None -> {
|
||||
WrapOnboarding()
|
||||
}
|
||||
is SecretState.NeedsBackup -> {
|
||||
WrapBackup(
|
||||
secretState.persistableWallet,
|
||||
onBackupComplete = { walletViewModel.persistBackupComplete() }
|
||||
)
|
||||
}
|
||||
is SecretState.Ready -> {
|
||||
Navigation()
|
||||
CompositionLocalProvider(LocalScreenSecurity provides screenSecurity) {
|
||||
when (val secretState = walletViewModel.secretState.collectAsState().value) {
|
||||
SecretState.Loading -> {
|
||||
// For now, keep displaying splash screen using condition above.
|
||||
// In the future, we might consider displaying something different here.
|
||||
}
|
||||
SecretState.None -> {
|
||||
WrapOnboarding()
|
||||
}
|
||||
is SecretState.NeedsBackup -> {
|
||||
WrapBackup(
|
||||
secretState.persistableWallet,
|
||||
onBackupComplete = { walletViewModel.persistBackupComplete() }
|
||||
)
|
||||
}
|
||||
is SecretState.Ready -> {
|
||||
Navigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
package co.electriccoin.zcash.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.updateAndGet
|
||||
|
||||
class ScreenSecurity {
|
||||
private val mutableReferenceCount: MutableStateFlow<Int> = MutableStateFlow(0)
|
||||
|
||||
val referenceCount = mutableReferenceCount.asStateFlow()
|
||||
|
||||
fun acquire() {
|
||||
mutableReferenceCount.update { it + 1 }
|
||||
}
|
||||
|
||||
fun release() {
|
||||
val after = mutableReferenceCount.updateAndGet { it - 1 }
|
||||
|
||||
if (after < 0) {
|
||||
error("Released security reference count too many times")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalScreenSecurity = compositionLocalOf { ScreenSecurity() }
|
||||
|
||||
@Composable
|
||||
fun SecureScreen() {
|
||||
val screenSecurity = LocalScreenSecurity.current
|
||||
DisposableEffect(screenSecurity) {
|
||||
screenSecurity.acquire()
|
||||
onDispose { screenSecurity.release() }
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
|||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import co.electriccoin.zcash.spackle.model.Index
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.SecureScreen
|
||||
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
|
||||
import co.electriccoin.zcash.ui.design.component.Body
|
||||
import co.electriccoin.zcash.ui.design.component.CHIP_GRID_ROW_SIZE
|
||||
|
@ -129,6 +130,7 @@ private fun EducationRecoveryPhrase(onNext: () -> Unit) {
|
|||
|
||||
@Composable
|
||||
private fun SeedPhrase(persistableWallet: PersistableWallet, onNext: () -> Unit, onCopyToClipboard: () -> Unit) {
|
||||
SecureScreen()
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Header(stringResource(R.string.new_wallet_3_header))
|
||||
Body(stringResource(R.string.new_wallet_3_body_1))
|
||||
|
@ -153,6 +155,7 @@ private fun Test(
|
|||
onNext: () -> Unit,
|
||||
onChoicesChanged: ((choicesCount: Int) -> Unit)?
|
||||
) {
|
||||
SecureScreen()
|
||||
val splitSeedPhrase = wallet.seedPhrase.split
|
||||
|
||||
val currentSelectedTestChoice = selectedTestChoices.current.collectAsState().value
|
||||
|
|
|
@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
|
|||
import cash.z.ecc.sdk.model.SeedPhraseValidation
|
||||
import co.electriccoin.zcash.spackle.model.Index
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.SecureScreen
|
||||
import co.electriccoin.zcash.ui.design.MINIMAL_WEIGHT
|
||||
import co.electriccoin.zcash.ui.design.component.Body
|
||||
import co.electriccoin.zcash.ui.design.component.CHIP_GRID_ROW_SIZE
|
||||
|
@ -114,6 +115,7 @@ fun RestoreWallet(
|
|||
paste: () -> String?,
|
||||
onFinished: () -> Unit
|
||||
) {
|
||||
SecureScreen()
|
||||
userWordList.wordValidation().collectAsState(null).value?.let { seedPhraseValidation ->
|
||||
if (seedPhraseValidation !is SeedPhraseValidation.Valid) {
|
||||
Scaffold(topBar = {
|
||||
|
|
|
@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import cash.z.ecc.sdk.fixture.PersistableWalletFixture
|
||||
import cash.z.ecc.sdk.model.PersistableWallet
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.SecureScreen
|
||||
import co.electriccoin.zcash.ui.design.component.Body
|
||||
import co.electriccoin.zcash.ui.design.component.ChipGrid
|
||||
import co.electriccoin.zcash.ui.design.component.GradientSurface
|
||||
|
@ -52,6 +53,7 @@ fun Seed(
|
|||
onBack: () -> Unit,
|
||||
onCopyToClipboard: () -> Unit
|
||||
) {
|
||||
SecureScreen()
|
||||
Scaffold(topBar = {
|
||||
SeedTopAppBar(onBack = onBack)
|
||||
}) { paddingValues ->
|
||||
|
|
Loading…
Reference in New Issue