Implement Android TOTP and confirmed HOTP presentation (#107)
Android ARM verification / rust (push) Failing after 49m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-12 11:43:23 +02:00
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 =