[#551] Prevent screenshots when seed phrase is displayed on the screen (#590)

* [#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:
Alex 2022-09-26 10:28:08 +02:00 committed by GitHub
parent 45d90b7706
commit a96637ec07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 337 additions and 16 deletions

View File

@ -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"

View File

@ -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()
}
}
}
}

View File

@ -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 = { }
)
}
}
}
}
}
}

View File

@ -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 = { }
)
}
}
}
}
}
}

View File

@ -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 = {}
)
}
}
}
}
}
}

View File

@ -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()
}
}
}
}

View File

@ -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() }
}
}

View File

@ -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

View File

@ -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 = {

View File

@ -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 ->