Implement Android TOTP and confirmed HOTP presentation (#107)
This commit is contained in:
1 parent
d466b170ab
commit
6a864a8d7f
26 files changed
+1938
-94
No files matched your search
@@ -74,7 +74,7 @@ class AdaptiveShellTest {
|
||||
original.search,
|
||||
original.authentication,
|
||||
original.editor,
|
||||
original.settings,
|
||||
original.settings, original.totp,
|
||||
saved
|
||||
)
|
||||
compose.activity.runOnUiThread {
|
||||
@@ -159,7 +159,7 @@ class AdaptiveShellTest {
|
||||
original.search,
|
||||
original.authentication,
|
||||
original.editor,
|
||||
original.settings,
|
||||
original.settings, original.totp,
|
||||
SavedStateHandle(
|
||||
saved.keys().associateWith {
|
||||
saved.get<Any>(it)
|
||||
@@ -199,7 +199,7 @@ class AdaptiveShellTest {
|
||||
original.search,
|
||||
original.authentication,
|
||||
original.editor,
|
||||
original.settings,
|
||||
original.settings, original.totp,
|
||||
saved
|
||||
)
|
||||
compose.activity.runOnUiThread {
|
||||
@@ -242,7 +242,7 @@ class AdaptiveShellTest {
|
||||
original.search,
|
||||
original.authentication,
|
||||
original.editor,
|
||||
original.settings,
|
||||
original.settings, original.totp,
|
||||
SavedStateHandle(mapOf("shell.tab" to "Passwords"))
|
||||
)
|
||||
compose.activity.runOnUiThread {
|
||||
|
||||
@@ -35,11 +35,12 @@ import de.rfc1437.ironstorage.core.PasswordField
|
||||
import de.rfc1437.ironstorage.core.PasswordKind
|
||||
import de.rfc1437.ironstorage.core.PasswordMutation
|
||||
import de.rfc1437.ironstorage.core.PasswordMutationPlan
|
||||
import de.rfc1437.ironstorage.core.PasswordOtp
|
||||
import de.rfc1437.ironstorage.core.PasswordOtpKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRow
|
||||
import de.rfc1437.ironstorage.core.PasswordSection
|
||||
import de.rfc1437.ironstorage.core.PasswordState
|
||||
import de.rfc1437.ironstorage.core.PasswordTotp
|
||||
import de.rfc1437.ironstorage.core.ShellIcon
|
||||
import de.rfc1437.ironstorage.features.PasswordScreen
|
||||
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
|
||||
@@ -70,7 +71,7 @@ class EntryViewerTest {
|
||||
compose.onNodeWithText("654321").assertDoesNotExist()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithText("123456").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithText("123 456").performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithContentDescription("Reveal PIN").performScrollTo()
|
||||
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
|
||||
assertEquals(2uL, repository.lastId)
|
||||
@@ -97,7 +98,7 @@ class EntryViewerTest {
|
||||
}
|
||||
compose.runOnIdle { repository.lease.value = AuthenticationState() }
|
||||
compose.onNode(hasSetTextAction()).assertDoesNotExist()
|
||||
compose.onNodeWithText("123456").assertDoesNotExist()
|
||||
compose.onNodeWithText("123 456").assertDoesNotExist()
|
||||
compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist()
|
||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||
}
|
||||
@@ -174,7 +175,7 @@ class EntryViewerTest {
|
||||
org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
|
||||
}
|
||||
}
|
||||
private class Fixture : PasswordRepository {
|
||||
internal class Fixture : PasswordRepository {
|
||||
private val row =
|
||||
PasswordRow(
|
||||
"row",
|
||||
@@ -275,12 +276,18 @@ class EntryViewerTest {
|
||||
override fun unlock(passphrase: String) {
|
||||
lease.value = AuthenticationState(unlocked = true)
|
||||
state.value =
|
||||
state.value.copy(sections = sections, totp = PasswordTotp("123456", "fixture", 17u))
|
||||
state.value.copy(
|
||||
sections = sections,
|
||||
otp = PasswordOtp(
|
||||
PasswordOtpKind.Totp, "123456", "123 456", null, "fixture",
|
||||
17u, 30u, 30u, null, null
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun lock() {
|
||||
authentication.lock()
|
||||
state.value =
|
||||
state.value.copy(sections = null, totp = null, edit = null)
|
||||
state.value.copy(sections = null, otp = null, edit = null)
|
||||
}
|
||||
private fun field(id: ULong, reveal: Boolean) {
|
||||
lastId = id
|
||||
@@ -309,7 +316,9 @@ class EntryViewerTest {
|
||||
lastId = id
|
||||
android.util.Log.i("IronStorage99", "copy-field-$id")
|
||||
}
|
||||
override fun copyTotp() = Unit
|
||||
override fun refreshOtp() = Unit
|
||||
override fun advanceHotp() = error("TOTP fixture cannot consume a HOTP counter")
|
||||
override fun copyOtp() = Unit
|
||||
override fun edit(id: ULong) {
|
||||
lastId = id
|
||||
state.value =
|
||||
|
||||
@@ -209,7 +209,9 @@ class PasswordScreenTest {
|
||||
override fun reveal(id: ULong) = Unit
|
||||
override fun hide(id: ULong) = Unit
|
||||
override fun copy(id: ULong) = Unit
|
||||
override fun copyTotp() = Unit
|
||||
override fun copyOtp() = Unit
|
||||
override fun refreshOtp() = Unit
|
||||
override fun advanceHotp() = Unit
|
||||
override fun edit(id: ULong) = Unit
|
||||
override fun dismissEdit() = Unit
|
||||
override fun saveEdit(value: String) = Unit
|
||||
|
||||
@@ -54,7 +54,10 @@ class Tier2WindowTest {
|
||||
val model = ShellViewModel(
|
||||
AdaptiveShellTest.Fixture(), original.onboarding, original.passwords,
|
||||
original.home, original.search, authentication, original.editor,
|
||||
SettingsScreenTest.Fixture(), SavedStateHandle(mapOf("shell.tab" to "Preferences"))
|
||||
SettingsScreenTest.Fixture(), original.totp,
|
||||
SavedStateHandle(
|
||||
mapOf("shell.tab" to "Preferences")
|
||||
)
|
||||
)
|
||||
compose.runOnIdle { compose.activity.viewModelStore.put("tier2-window-fixture", model) }
|
||||
lateinit var inputMode: InputModeManager
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package de.rfc1437.ironstorage
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onLast
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.core.PasswordOtp
|
||||
import de.rfc1437.ironstorage.core.PasswordOtpKind
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.TotpRepository
|
||||
import de.rfc1437.ironstorage.core.TotpState
|
||||
import de.rfc1437.ironstorage.features.TotpScreen
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TotpScreenTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private fun display(fixture: Fixture, fontScale: Float = 1f) {
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(density.density, fontScale)
|
||||
) {
|
||||
MaterialTheme { TotpScreen(fixture, fixture.base.authentication) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun catalogRequiresExplicitUnlockAndShowsSharedLargeCode() {
|
||||
val fixture = Fixture()
|
||||
display(fixture)
|
||||
compose.onNodeWithTag("otp-code").assertDoesNotExist()
|
||||
compose.onNodeWithText("Viewer").performClick()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithTag("otp-code").assertTextEquals("123 456")
|
||||
compose.onNodeWithText("17 seconds remaining").assertIsDisplayed()
|
||||
compose.onNodeWithText("Copy code").performClick()
|
||||
assertEquals(1, fixture.copies)
|
||||
compose.onNodeWithText("Refresh code").performClick()
|
||||
assertEquals(1, fixture.refreshes)
|
||||
compose.onNodeWithText("Lock").performClick()
|
||||
compose.onNodeWithTag("otp-code").assertDoesNotExist()
|
||||
compose.onNodeWithText("Unlock").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun ticksStopWhenTheCodeComponentLeaves() {
|
||||
val fixture = Fixture()
|
||||
display(fixture)
|
||||
compose.onNodeWithText("Viewer").performClick()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.waitUntil(5000) { fixture.ticks > 0 }
|
||||
compose.onNodeWithText("Back to TOTP entries").performClick()
|
||||
val stopped = fixture.ticks
|
||||
Thread.sleep(1200)
|
||||
compose.waitForIdle()
|
||||
assertEquals(stopped, fixture.ticks)
|
||||
}
|
||||
|
||||
@Test fun largeTextShowsEveryDigitWithoutClipping() {
|
||||
val fixture = Fixture()
|
||||
display(fixture, 3f)
|
||||
compose.onNodeWithText("Viewer").performClick()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
val results = mutableListOf<TextLayoutResult>()
|
||||
compose.onNodeWithTag(
|
||||
"otp-code"
|
||||
).performSemanticsAction(SemanticsActions.GetTextLayoutResult) {
|
||||
org.junit.Assert.assertTrue(it(results))
|
||||
}
|
||||
org.junit.Assert.assertTrue(results.isNotEmpty())
|
||||
org.junit.Assert.assertFalse(results.single().hasVisualOverflow)
|
||||
}
|
||||
|
||||
@Test fun hotpGenerationRequiresConfirmationAndCancelDoesNotConsume() {
|
||||
val fixture = Fixture(hotp = true)
|
||||
display(fixture)
|
||||
compose.onNodeWithText("Viewer").performClick()
|
||||
compose.onNodeWithText("Unlock").performClick()
|
||||
compose.onNodeWithTag("otp-code").assertDoesNotExist()
|
||||
compose.onNodeWithText("Generate next HOTP code").performClick()
|
||||
compose.onNodeWithText("Cancel").performClick()
|
||||
assertEquals(0, fixture.generations)
|
||||
compose.onNodeWithText("Generate next HOTP code").performClick()
|
||||
compose.onAllNodesWithText("Generate next HOTP code").onLast().performClick()
|
||||
assertEquals(1, fixture.generations)
|
||||
compose.onNodeWithTag("otp-code").assertTextEquals("287 082")
|
||||
compose.onNodeWithText("Copy code").performClick()
|
||||
compose.onNodeWithText("Refresh code").performClick()
|
||||
assertEquals(1, fixture.generations)
|
||||
compose.runOnIdle { fixture.base.authentication.lock() }
|
||||
compose.onNodeWithTag("otp-code").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun inspectDisposableTotpWithComputerUse() {
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
assumeTrue(arguments.getString("otpInspect") == "true")
|
||||
val fixture = Fixture(hotp = arguments.getString("otpKind") == "hotp")
|
||||
val done = CountDownLatch(1)
|
||||
compose.activity.runOnUiThread {
|
||||
compose.activity.setProtectedTestContent {
|
||||
MaterialTheme {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TextButton(onClick = { done.countDown() }) { Text("Finish OTP inspection") }
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides
|
||||
Density(
|
||||
density.density,
|
||||
arguments.getString("otpFont")?.toFloat() ?: 1f
|
||||
)
|
||||
) {
|
||||
TotpScreen(fixture, fixture.base.authentication)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.waitForIdle()
|
||||
while (!done.await(100, TimeUnit.MILLISECONDS)) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
compose.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private class Fixture(private val hotp: Boolean = false) : TotpRepository {
|
||||
val base = EntryViewerTest.Fixture()
|
||||
var copies = 0
|
||||
var refreshes = 0
|
||||
var generations = 0
|
||||
var ticks = 0
|
||||
override val state = MutableStateFlow(
|
||||
TotpState(
|
||||
rows = base.state.value.rows,
|
||||
hasCatalog = true
|
||||
)
|
||||
)
|
||||
override val viewer = object : PasswordRepository by base {
|
||||
override fun unlock(passphrase: String) {
|
||||
base.unlock(passphrase)
|
||||
if (hotp) {
|
||||
base.state.value = base.state.value.copy(
|
||||
otp = PasswordOtp(
|
||||
PasswordOtpKind.Hotp, null, null, "RFC", "alice",
|
||||
null, null, null, null, 1u
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun copyOtp() {
|
||||
copies += 1
|
||||
}
|
||||
override fun tickOtp() {
|
||||
ticks += 1
|
||||
}
|
||||
override fun refreshOtp() {
|
||||
refreshes += 1
|
||||
}
|
||||
override fun advanceHotp() {
|
||||
generations += 1
|
||||
base.state.value = base.state.value.copy(
|
||||
otp = PasswordOtp(
|
||||
PasswordOtpKind.Hotp, "287082", "287 082", "RFC", "alice",
|
||||
null, null, null, 1u, 2u
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun enter(selected: String?, revision: String?) = Unit
|
||||
override fun reload() = Unit
|
||||
override fun open(id: String, revision: String) {
|
||||
state.value =
|
||||
state.value.copy(detail = true)
|
||||
}
|
||||
override fun back() {
|
||||
viewer.leave()
|
||||
state.value = state.value.copy(detail = false)
|
||||
}
|
||||
override fun discover(passphrase: String) = Unit
|
||||
override fun cancel() = Unit
|
||||
override fun leave() = viewer.leave()
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,14 @@ import de.rfc1437.ironstorage.core.NativePasswordRepository
|
||||
import de.rfc1437.ironstorage.core.NativePrivateSession
|
||||
import de.rfc1437.ironstorage.core.NativeSearchRepository
|
||||
import de.rfc1437.ironstorage.core.NativeSettingsRepository
|
||||
import de.rfc1437.ironstorage.core.NativeTotpRepository
|
||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||
import de.rfc1437.ironstorage.core.SearchRepository
|
||||
import de.rfc1437.ironstorage.core.SettingsPlatform
|
||||
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||
import de.rfc1437.ironstorage.core.ShellRepository
|
||||
import de.rfc1437.ironstorage.core.TotpRepository
|
||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
|
||||
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
|
||||
@@ -108,6 +110,25 @@ object ShellModule {
|
||||
AndroidSecretClipboard(context)
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun totpRepository(
|
||||
session: NativePrivateSession,
|
||||
authentication: NativeAuthenticationRepository,
|
||||
@ApplicationContext context: Context
|
||||
): TotpRepository {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val viewer =
|
||||
NativePasswordRepository(
|
||||
session,
|
||||
scope,
|
||||
authentication,
|
||||
Dispatchers.IO,
|
||||
AndroidSecretClipboard(context)
|
||||
)
|
||||
return NativeTotpRepository(session, authentication, viewer, scope)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository =
|
||||
|
||||
Reference in new issue
Block a user