From f4a6edbc6809854244174462d6fec84ed67a84e2 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 12 Oct 2026 20:38:33 +0200 Subject: [PATCH] Implement Android TOTP cached discovery and search (#110) --- android/README.md | 2 + .../de/rfc1437/ironstorage/EntryViewerTest.kt | 21 +- .../ironstorage/TotpDiscoveryFixtureTest.kt | 59 +++++ .../de/rfc1437/ironstorage/TotpScreenTest.kt | 34 ++- .../ironstorage/ProviderFixtureAccess.kt | 4 + .../core/NativeTotpDiscoveryTest.kt | 142 +++++++++++ .../core/NativeAuthenticationRepository.kt | 2 + .../ironstorage/core/NativeTotpRepository.kt | 229 ++++++++++++------ .../ironstorage/core/TotpRepository.kt | 18 +- .../ironstorage/features/TotpScreen.kt | 155 +++++++++--- .../features/src/main/res/values/strings.xml | 8 + crates/mobile-bridge/src/lib.rs | 59 ++++- crates/storage/src/mobile_authentication.rs | 130 +++++++++- crates/storage/src/mobile_totp.rs | 191 ++++++++++++--- crates/storage/tests/mobile_totp.rs | 116 +++++++++ docs/android/otp.md | 7 +- docs/android/parity.md | 2 +- docs/android/totp-discovery.md | 56 +++++ 18 files changed, 1070 insertions(+), 165 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpDiscoveryFixtureTest.kt create mode 100644 android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTotpDiscoveryTest.kt create mode 100644 docs/android/totp-discovery.md diff --git a/android/README.md b/android/README.md index 29e86ca..a84ba8a 100644 --- a/android/README.md +++ b/android/README.md @@ -10,6 +10,8 @@ Home renders Rust-owned [Git activity and HTTPS synchronization](../docs/android Passwords and Preferences share [Rust-owned biometric authentication](../docs/android/authentication.md). CameraX GPG scanning and QR export are described in [key transfer](../docs/android/key-transfer.md). +The TOTP destination uses [cached discovery and search](../docs/android/totp-discovery.md) +while locked, with authenticated incremental refresh for new or changed entries. Remaining feature screens retain their milestone issues. The full [CI and emulator workflow](../docs/android/ci.md) documents the ARM-only runner/toolchain policy and artifact audits. diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt index 7afe1b3..47c6a25 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt @@ -13,6 +13,7 @@ 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.hasContentDescription import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks @@ -70,19 +71,27 @@ class EntryViewerTest { } compose.onNodeWithText("654321").assertDoesNotExist() compose.onNodeWithText("Unlock").performClick() - compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed() - compose.onNodeWithText("123 456").performScrollTo().assertIsDisplayed() - compose.onNodeWithContentDescription("Reveal PIN").performScrollTo() + compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("alice 日本語")) + compose.onNodeWithText("alice 日本語").assertIsDisplayed() + compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("123 456")) + compose.onNodeWithText("123 456").assertIsDisplayed() + compose.onNodeWithTag( + "entry-detail" + ).performScrollToNode(hasContentDescription("Reveal PIN")) + compose.onNodeWithContentDescription("Reveal PIN") .performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) } assertEquals(2uL, repository.lastId) compose.onNodeWithTag("entry-detail").performScrollToNode(hasText("654321")) compose.onNodeWithText("654321").assertIsDisplayed() - compose.onNodeWithContentDescription("Hide PIN").performScrollTo().performClick() + compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Hide PIN")) + compose.onNodeWithContentDescription("Hide PIN").performClick() compose.onNodeWithText("654321").assertDoesNotExist() - compose.onNodeWithContentDescription("Copy PIN").performScrollTo() + compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Copy PIN")) + compose.onNodeWithContentDescription("Copy PIN") .performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) } assertEquals(2uL, repository.lastId) - compose.onNodeWithContentDescription("Edit PIN").performScrollTo().performClick() + compose.onNodeWithTag("entry-detail").performScrollToNode(hasContentDescription("Edit PIN")) + compose.onNodeWithContentDescription("Edit PIN").performClick() compose.onNode(hasSetTextAction()).performTextReplacement("987654") compose.onNodeWithText("Save").performClick() assertEquals("987654", repository.saved) diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpDiscoveryFixtureTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpDiscoveryFixtureTest.kt new file mode 100644 index 0000000..ed3d3de --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpDiscoveryFixtureTest.kt @@ -0,0 +1,59 @@ +package de.rfc1437.ironstorage + +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.EntryPoints +import de.rfc1437.ironstorage.core.EditorFieldKind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assume.assumeTrue +import org.junit.Test + +class TotpDiscoveryFixtureTest { + /** Public test entry only, guarded by the retained fixture's exact key identity. */ + @Test fun addPublicTotpForComputerUse(): Unit = runBlocking { + assumeTrue(InstrumentationRegistry.getArguments().getString("totpFixtureSetup") == "true") + val context = InstrumentationRegistry.getInstrumentation().targetContext + val access = EntryPoints.get(context.applicationContext, ProviderFixtureAccess::class.java) + val setup = access.onboarding() + withContext(Dispatchers.Main.immediate) { setup.refresh() } + val before = withTimeout(30000) { setup.setup.first { !it.busy } } + check( + before.configured && + before.keys.single().fingerprint == "0C1ABFD707B2103E495B42ACF34D59DF60521FE3" + ) + val catalog = access.totp() + withContext(Dispatchers.Main.immediate) { catalog.enter(null, null) } + val cached = withTimeout(30000) { catalog.state.first { !it.loading } } + check(cached.error == null) + if (cached.rows.any { it.path == "discovery-fixture" }) { + withContext(Dispatchers.Main.immediate) { access.authentication().lock() } + return@runBlocking + } + val editor = access.editor() + withContext(Dispatchers.Main.immediate) { + editor.create("", "discovery-fixture", "public-provider-fixture-passphrase") + } + val created = withTimeout(30000) { editor.state.first { !it.busy } } + check(created.open && !created.locked && created.error == null) { created.error.orEmpty() } + withContext(Dispatchers.Main.immediate) { + editor.add( + EditorFieldKind.OtpUri, + null, + "otpauth://totp/Public:discovery?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Public" + ) + } + withTimeout(30000) { + editor.state.first { + !it.busy && + it.fields.any { field -> field.kind == EditorFieldKind.OtpUri } + } + } + withContext(Dispatchers.Main.immediate) { editor.save() } + val saved = withTimeout(30000) { editor.state.first { !it.busy && !it.open } } + check(saved.error == null) + withContext(Dispatchers.Main.immediate) { access.authentication().lock() } + } +} diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt index c33dc7a..f405399 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt @@ -19,12 +19,15 @@ 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.test.performTextInput 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.TotpDiscoveryPhase +import de.rfc1437.ironstorage.core.TotpDiscoveryProgress import de.rfc1437.ironstorage.core.TotpRepository import de.rfc1437.ironstorage.core.TotpState import de.rfc1437.ironstorage.features.TotpScreen @@ -118,6 +121,27 @@ class TotpScreenTest { compose.onNodeWithTag("otp-code").assertDoesNotExist() } + @Test fun discoveryShowsTypedProgressCancellationAndCachedQuery() { + val fixture = Fixture() + fixture.state.value = fixture.state.value.copy( + refreshing = true, + progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Inspecting, 10, 4, 3, 2, 1) + ) + display(fixture) + compose.onNodeWithText("Inspecting entries").assertIsDisplayed() + compose.onNodeWithText( + "Inspected: 4 / 10 · Cache hits: 3 · Matches: 2 · Unavailable: 1" + ).assertIsDisplayed() + compose.onNodeWithTag("totp-progress").assertIsDisplayed() + compose.onNodeWithTag("totp-query").performTextInput("cached") + assertEquals("cached", fixture.state.value.query) + compose.onNodeWithText("Cancel").performClick() + compose.onNodeWithText( + "Cancelled. Completed checkpoints are retained; refresh to resume." + ).assertIsDisplayed() + compose.onNodeWithText("Apple Watch").assertDoesNotExist() + } + @Test fun inspectDisposableTotpWithComputerUse() { val arguments = InstrumentationRegistry.getArguments() assumeTrue(arguments.getString("otpInspect") == "true") @@ -194,6 +218,9 @@ class TotpScreenTest { } override fun enter(selected: String?, revision: String?) = Unit override fun reload() = Unit + override fun query(value: String) { + state.value = state.value.copy(query = value) + } override fun open(id: String, revision: String) { state.value = state.value.copy(detail = true) @@ -203,7 +230,12 @@ class TotpScreenTest { state.value = state.value.copy(detail = false) } override fun discover(passphrase: String) = Unit - override fun cancel() = Unit + override fun cancel() { + state.value = state.value.copy( + refreshing = false, + progress = state.value.progress?.copy(phase = TotpDiscoveryPhase.Cancelled) + ) + } override fun leave() = viewer.leave() } } diff --git a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt index 0bc002f..fcb4b73 100644 --- a/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt +++ b/android/app/src/debug/kotlin/de/rfc1437/ironstorage/ProviderFixtureAccess.kt @@ -3,9 +3,11 @@ package de.rfc1437.ironstorage import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import de.rfc1437.ironstorage.core.EditorRepository import de.rfc1437.ironstorage.core.NativeAuthenticationRepository import de.rfc1437.ironstorage.core.NativeCredentialRepository import de.rfc1437.ironstorage.core.OnboardingRepository +import de.rfc1437.ironstorage.core.TotpRepository @EntryPoint @InstallIn(SingletonComponent::class) @@ -13,4 +15,6 @@ interface ProviderFixtureAccess { fun onboarding(): OnboardingRepository fun authentication(): NativeAuthenticationRepository fun credentials(): NativeCredentialRepository + fun editor(): EditorRepository + fun totp(): TotpRepository } diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTotpDiscoveryTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTotpDiscoveryTest.kt new file mode 100644 index 0000000..ee0f67b --- /dev/null +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeTotpDiscoveryTest.kt @@ -0,0 +1,142 @@ +package de.rfc1437.ironstorage.core + +import androidx.test.platform.app.InstrumentationRegistry +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind +import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeTotpDiscoveryTest { + @Test fun cachedSearchRefreshRecoveryAndProcessRecreationUseRust(): Unit = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { + File(it, NativeEntryViewerTest.rootName).apply { mkdirs() } + } + val directories = + PrivateDirectories( + roots[0].canonicalPath, + roots[1].canonicalPath, + roots[2].canonicalPath + ) + val keystore = NativeEntryViewerTest.MemoryKeystore() + val session = NativePrivateSession(context, { directories }, keystore) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val auth = NativeAuthenticationRepository(session, scope, Dispatchers.IO) + val viewer = + NativePasswordRepository( + session, + scope, + auth, + Dispatchers.IO, + NativeEntryViewerTest.MemoryClipboard() + ) + val catalog = NativeTotpRepository(session, auth, viewer, scope) + val passphrase = "public-discovery-fixture" + suspend fun catalogAction(action: () -> Unit): TotpState { + withContext(Dispatchers.Main.immediate) { action() } + return withTimeout(30000) { catalog.state.first { !it.loading } } + } + try { + withContext(Dispatchers.IO) { + val setup = session.storage().androidOnboarding(session.backend()) + val key = + requireNotNull(setup.generate("Discovery fixture", passphrase).selectedKey) + setup.setupLocal(key, passphrase) + } + auth.authenticated { native -> + for (name in listOf("first", "second", "ordinary")) { + val editor = native.beginCreateEntry("", name, passphrase) + val page = if (name != "ordinary") { + native.addEntryEditorField( + editor.id, + MobileEntryEditorFieldKind.OTP_URI, + null, + "otpauth://totp/Public:$name?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + ) + } else { + editor.page + } + native.saveEntryEditor( + editor.id, + page.fields.map { + MobileEntryEditorInput( + it.id, + it.name, + if (it.kind == MobileEntryEditorFieldKind.PASSWORD) { + "public-discovery-password" + } else { + it.value.orEmpty() + } + ) + } + ) + } + } + withContext(Dispatchers.Main.immediate) { auth.lock() } + assertEquals(2, catalogAction { catalog.enter(null, null) }.rows.size) + assertFalse(auth.state.value.unlocked) + val warm = catalogAction { catalog.discover("") } + assertNull(warm.error) + assertEquals(TotpDiscoveryPhase.Complete, warm.progress!!.phase) + assertEquals(3L, warm.progress.cacheHits) + assertFalse(auth.state.value.unlocked) + val latest = catalogAction { + catalog.query("first") + catalog.query("second") + catalog.query("missing") + } + assertEquals("missing", latest.query) + assertTrue(latest.rows.isEmpty()) + assertEquals("first", catalogAction { catalog.query(" FIRST ") }.rows.single().title) + assertFalse(auth.state.value.unlocked) + val cache = File(roots[1], "ironstorage/totp-catalog.toml") + assertTrue(cache.isFile) + assertFalse(cache.readText().contains("otpauth://")) + // Only this isolated test catalog is damaged; production state is never reset. + cache.writeText("damaged fixture catalog") + assertFalse(catalogAction { catalog.query("") }.hasCatalog) + withContext(Dispatchers.Main.immediate) { + catalog.discover(passphrase) + catalog.cancel() + } + assertEquals(TotpDiscoveryPhase.Cancelled, catalog.state.value.progress!!.phase) + val recovered = catalogAction { catalog.discover(passphrase) } + assertNull(recovered.error) + assertEquals(2, recovered.rows.size) + assertEquals(0L, recovered.progress!!.cacheHits) + assertEquals(3L, recovered.progress.inspected) + withContext(Dispatchers.Main.immediate) { auth.lock() } + val reopened = NativePrivateSession(context, { directories }, keystore) + val recreatedAuth = NativeAuthenticationRepository(reopened, scope, Dispatchers.IO) + val recreatedViewer = + NativePasswordRepository( + reopened, + scope, + recreatedAuth, + Dispatchers.IO, + NativeEntryViewerTest.MemoryClipboard() + ) + val recreated = NativeTotpRepository(reopened, recreatedAuth, recreatedViewer, scope) + withContext(Dispatchers.Main.immediate) { recreated.enter(null, null) } + val retained = withTimeout(30000) { recreated.state.first { !it.loading } } + assertNull(retained.error) + assertEquals(2, retained.rows.size) + assertFalse(recreatedAuth.state.value.unlocked) + } finally { + withContext(Dispatchers.Main.immediate) { scope.cancel() } + roots.forEach { it.deleteRecursively() } + } + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt index e30a169..cd0fa5d 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -76,6 +76,8 @@ class NativeAuthenticationRepository( internal suspend fun authenticated(block: (MobileAuthentication) -> T): T = access(true, block) + internal suspend fun metadata(block: (MobileAuthentication) -> T): T = access(false, block) + internal suspend fun observe(block: (MobileAuthentication) -> T): T? = withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { if (mutable.value.busy) null else access(false, block) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt index 50de608..12992ca 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt @@ -3,11 +3,16 @@ package de.rfc1437.ironstorage.core import de.rfc1437.ironstorage.bridge.NativeBridgeException import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException +import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryPhase +import de.rfc1437.ironstorage.bridge.generated.MobileTotpDiscoveryProgress import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -24,28 +29,68 @@ class NativeTotpRepository( private val mutable = MutableStateFlow(TotpState()) override val state: StateFlow = mutable private val serial = Mutex() + private var searchEpoch = 0L + private var refreshEpoch = 0L + private var debounce: Job? = null - @Volatile private var epoch = 0L + @Volatile private var search: MobileTotpOperation? = null @Volatile private var discovery: MobileTotpOperation? = null init { - val cleanup = authentication.presentation.register(::cancel) - scope.coroutineContext[Job]?.invokeOnCompletion { cleanup.close() } + val cleanup = authentication.presentation.register(::cancelDiscovery) + val metadata = authentication.presentation.registerMetadata(::cancel) + scope.coroutineContext[Job]?.invokeOnCompletion { + cleanup.close() + metadata.close() + } } override fun enter(selected: String?, revision: String?) = load(selected, revision) override fun reload() = load(null, null) - private fun load(selected: String?, revision: String?) = run { ticket -> - val rows = authentication.authenticated { it.cachedTotpNavigation() } - if (ticket == epoch) { - mutable.value = - mutable.value.copy( - rows = rows.orEmpty().map { it.toFacade() }, - hasCatalog = - rows != null - ) - if (selected != null && revision != null) open(selected, revision) + override fun query(value: String) { + mutable.value = mutable.value.copy(query = value) + load(null, null, true) + } + private fun load(selected: String?, revision: String?, wait: Boolean = false) { + val ticket = ++searchEpoch + search?.cancel() + debounce?.cancel() + val query = mutable.value.query + mutable.value = mutable.value.copy(searching = true, error = null) + // Debounce only queues work; native calls finish under the serial gate after cancellation. + debounce = scope.launch { + if (wait) delay(200) + scope.launch { + serial.withLock { + if (ticket != searchEpoch) return@withLock + val active = withContext(Dispatchers.IO) { mobileTotpOperation() } + search = active + if (ticket != searchEpoch) active.cancel() + try { + val rows = authentication.metadata { + it.searchCachedTotpNavigation(query, active) + } + if (ticket == searchEpoch) { + mutable.value = mutable.value.copy( + rows = rows.orEmpty().map { it.toFacade() }, + hasCatalog = rows != null + ) + if (selected != null && revision != null) open(selected, revision) + } + } catch (error: Exception) { + if (ticket == searchEpoch) failure(error) + } finally { + if (search === active) search = null + active.close() + if (ticket == + searchEpoch + ) { + mutable.value = mutable.value.copy(searching = false) + } + } + } + } } } override fun open(id: String, revision: String) { @@ -58,74 +103,116 @@ class NativeTotpRepository( mutable.value = mutable.value.copy(detail = false) reload() } - override fun discover(passphrase: String) = run { ticket -> - val active = withContext(Dispatchers.IO) { mobileTotpOperation() } - discovery = active - if (ticket != epoch) active.cancel() - try { - val rows = authentication.authenticated { - it.unlockTotp(passphrase.ifEmpty { null }) - it.totpPage(active) - it.cachedTotpNavigation() + override fun discover(passphrase: String) { + if (mutable.value.refreshing) return + val ticket = ++refreshEpoch + mutable.value = mutable.value.copy( + refreshing = true, + error = null, + notice = null, + progress = TotpDiscoveryProgress(TotpDiscoveryPhase.Preparing, 0, 0, 0, 0, 0) + ) + scope.launch { + serial.withLock { + if (ticket != refreshEpoch) return@withLock + val active = withContext(Dispatchers.IO) { mobileTotpOperation() } + discovery = active + if (ticket != refreshEpoch) active.cancel() + try { + coroutineScope { + val polling = launch { + while (true) { + val progress = + withContext(Dispatchers.IO) { active.progress().facade() } + if (ticket == + refreshEpoch + ) { + mutable.value = + mutable.value.copy(progress = progress) + } + delay(100) + } + } + try { + val result = authentication.authenticated { + it.refreshTotpCatalog(active, passphrase.ifEmpty { null }) + } + if (ticket == + refreshEpoch + ) { + mutable.value = + mutable.value.copy( + progress = result.progress.facade(), + notice = result.cacheNotice + ) + } + } finally { + polling.cancelAndJoin() + } + } + } catch (error: Exception) { + if (ticket == refreshEpoch) failure(error) + } finally { + if (discovery === active) discovery = null + active.close() + if (ticket == refreshEpoch) { + // Keep loading true until the refreshed rows have been published. + if (mutable.value.error == null) reload() + mutable.value = mutable.value.copy(refreshing = false) + } + } } - if (ticket == - epoch - ) { - mutable.value = - mutable.value.copy( - rows = rows.orEmpty().map { it.toFacade() }, - hasCatalog = - rows != null - ) - } - } finally { - discovery = null - active.close() } } override fun cancel() { - epoch += 1 + searchEpoch++ + debounce?.cancel() + search?.cancel() + cancelDiscovery() + mutable.value = mutable.value.copy(searching = false) + } + private fun cancelDiscovery() { + refreshEpoch++ discovery?.cancel() - mutable.value = mutable.value.copy(loading = false) + mutable.value = mutable.value.copy( + refreshing = false, + progress = if (mutable.value.refreshing) { + mutable.value.progress?.copy( + phase = TotpDiscoveryPhase.Cancelled + ) + } else { + mutable.value.progress + } + ) } override fun leave() { cancel() viewer.leave() } - private fun run(action: suspend (Long) -> Unit) { - if (mutable.value.loading) return - val ticket = epoch - mutable.value = mutable.value.copy(loading = true, error = null) - scope.launch { - serial.withLock { - if (ticket != epoch) return@withLock - try { - action(ticket) - } catch (error: MobileAuthenticationFfiException.Failed) { - if (ticket == - epoch - ) { - mutable.value = - mutable.value.copy(error = error.title + "\n" + error.detail) - } - } catch (_: MobilePrivateStorageFfiException) { - if (ticket == - epoch - ) { - mutable.value = - mutable.value.copy(error = session.text(R.string.core_private_recovery)) - } - } catch (_: NativeBridgeException) { - if (ticket == - epoch - ) { - mutable.value = - mutable.value.copy(error = session.text(R.string.core_update_retry)) - } - } finally { - if (ticket == epoch) mutable.value = mutable.value.copy(loading = false) - } - } + private fun failure(error: Exception) { + val text = when (error) { + is MobileAuthenticationFfiException.Failed -> error.title + "\n" + error.detail + is MobilePrivateStorageFfiException -> session.text(R.string.core_private_recovery) + is NativeBridgeException -> session.text(R.string.core_update_retry) + is java.io.IOException -> session.text(R.string.core_private_recovery) + is kotlinx.coroutines.CancellationException -> throw error + else -> throw error } + mutable.value = mutable.value.copy(error = text) } } + +private fun MobileTotpDiscoveryProgress.facade() = TotpDiscoveryProgress( + when (phase) { + MobileTotpDiscoveryPhase.PREPARING -> TotpDiscoveryPhase.Preparing + MobileTotpDiscoveryPhase.INSPECTING -> TotpDiscoveryPhase.Inspecting + MobileTotpDiscoveryPhase.SAVING -> TotpDiscoveryPhase.Saving + MobileTotpDiscoveryPhase.COMPLETE -> TotpDiscoveryPhase.Complete + MobileTotpDiscoveryPhase.CANCELLED -> TotpDiscoveryPhase.Cancelled + }, + total.toLong(), + inspected.toLong(), + cacheHits.toLong(), + matches.toLong(), + unavailable.toLong() +) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt index 2452e38..09aaaae 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt @@ -2,13 +2,28 @@ package de.rfc1437.ironstorage.core import kotlinx.coroutines.flow.StateFlow +enum class TotpDiscoveryPhase { Preparing, Inspecting, Saving, Complete, Cancelled } +data class TotpDiscoveryProgress( + val phase: TotpDiscoveryPhase, + val total: Long, + val inspected: Long, + val cacheHits: Long, + val matches: Long, + val unavailable: Long +) + data class TotpState( val rows: List = emptyList(), - val loading: Boolean = false, + val refreshing: Boolean = false, + val searching: Boolean = false, + val query: String = "", + val progress: TotpDiscoveryProgress? = null, val hasCatalog: Boolean = false, val detail: Boolean = false, + val notice: String? = null, val error: String? = null ) { + val loading get() = refreshing || searching override fun toString() = "TotpState(loading=$loading, [REDACTED])" } interface TotpRepository { @@ -16,6 +31,7 @@ interface TotpRepository { val viewer: PasswordRepository fun enter(selected: String?, revision: String?) fun reload() + fun query(value: String) fun open(id: String, revision: String) fun back() fun discover(passphrase: String) diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt index db81002..3e1fc5e 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt @@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,15 +28,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.rfc1437.ironstorage.core.AuthenticationFailureKind import de.rfc1437.ironstorage.core.AuthenticationRepository +import de.rfc1437.ironstorage.core.TotpDiscoveryPhase import de.rfc1437.ironstorage.core.TotpRepository +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TotpScreen(repository: TotpRepository, authentication: AuthenticationRepository) { val state by repository.state.collectAsStateWithLifecycle() @@ -67,46 +76,118 @@ fun TotpScreen(repository: TotpRepository, authentication: AuthenticationReposit val expanded = maxWidth >= 600.dp Row(Modifier.fillMaxSize()) { if (expanded || !state.detail) { - LazyColumn( - Modifier.weight(1f).padding(16.dp).testTag("totp-list"), - verticalArrangement = Arrangement.spacedBy(12.dp) + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = requestCatalog, + modifier = Modifier.weight(1f) ) { - item { - Text( - stringResource(R.string.entry_totp), - style = MaterialTheme.typography.headlineMedium - ) - TextButton( - enabled = !state.loading && !lease.busy, - onClick = requestCatalog - ) { - Text(stringResource(R.string.totp_refresh_catalog)) - } - if (state.loading) { - CircularProgressIndicator() - TextButton(onClick = repository::cancel) { - Text(stringResource(R.string.auth_cancel)) + LazyColumn( + Modifier.fillMaxSize().padding(16.dp).testTag("totp-list"), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text( + stringResource(R.string.entry_totp), + style = MaterialTheme.typography.headlineMedium + ) + TextButton( + enabled = !state.loading && !lease.busy, + onClick = requestCatalog + ) { + Text(stringResource(R.string.totp_refresh_catalog)) + } + OutlinedTextField( + value = state.query, + onValueChange = repository::query, + label = { Text(stringResource(R.string.totp_search)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("totp-query") + ) + state.progress?.let { progress -> + Column(Modifier.semantics { liveRegion = LiveRegionMode.Polite }) { + Text( + stringResource( + when (progress.phase) { + TotpDiscoveryPhase.Preparing -> + R.string.totp_preparing + TotpDiscoveryPhase.Inspecting -> + R.string.totp_inspecting + TotpDiscoveryPhase.Saving -> + R.string.totp_saving + TotpDiscoveryPhase.Complete -> + R.string.totp_complete + TotpDiscoveryPhase.Cancelled -> + R.string.totp_cancelled + } + ) + ) + Text( + stringResource( + R.string.totp_progress_counts, + progress.inspected, + progress.total, + progress.cacheHits, + progress.matches, + progress.unavailable + ) + ) + if (progress.total > 0) { + LinearProgressIndicator( + progress = { + ( + progress.inspected.toFloat() / + progress.total + ).coerceIn( + 0f, + 1f + ) + }, + modifier = Modifier.fillMaxWidth().testTag( + "totp-progress" + ) + ) + } + } + } + if (state.loading) { + if (state.searching || + state.progress?.phase == TotpDiscoveryPhase.Preparing + ) { + CircularProgressIndicator() + } + TextButton(onClick = repository::cancel) { + Text(stringResource(R.string.auth_cancel)) + } + } + state.notice?.let { Text(it) } + state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (!state.hasCatalog) { + Text(stringResource(R.string.totp_needs_catalog)) + } else if (state.rows.isEmpty() && !state.searching) { + Text( + stringResource( + if (state.query.isEmpty()) { + R.string.totp_empty + } else { + R.string.totp_no_matches + } + ) + ) } } - state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - if (!state.hasCatalog) { - Text(stringResource(R.string.totp_needs_catalog)) - } else if (state.rows.isEmpty()) { - Text(stringResource(R.string.totp_empty)) - } - } - items(state.rows, key = { it.id }) { row -> - Column( - Modifier.fillMaxWidth().clickable( - enabled = !state.loading, - role = Role.Button, - onClick = { - repository.open(row.id, row.revision) - } - ).padding(vertical = 12.dp) - ) { - Text(row.title, style = MaterialTheme.typography.titleMedium) - Text(row.detail) + items(state.rows, key = { it.id }) { row -> + Column( + Modifier.fillMaxWidth().clickable( + enabled = !state.loading, + role = Role.Button, + onClick = { + repository.open(row.id, row.revision) + } + ).padding(vertical = 12.dp) + ) { + Text(row.title, style = MaterialTheme.typography.titleMedium) + Text(row.detail) + } } } } diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 40dd0db..d73c47b 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -247,6 +247,14 @@ %1$s seconds remaining Refresh TOTP catalog + Search cached TOTP entries + No cached TOTP entries match this search. + Preparing discovery + Inspecting entries + Saving catalog + Discovery complete + Cancelled. Completed checkpoints are retained; refresh to resume. + Inspected: %1$d / %2$d · Cache hits: %3$d · Matches: %4$d · Unavailable: %5$d Refresh the catalog after unlocking to find TOTP entries. No TOTP entries found. Choose a TOTP entry. diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index b946fac..51071b2 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -1271,6 +1271,25 @@ pub struct MobileTotpDiscoveryProgress { pub unavailable: u32, } +impl From for MobileTotpDiscoveryProgress { + fn from(progress: ironstorage::mobile_totp::MobileTotpDiscoveryProgress) -> Self { + Self { + phase: progress.phase().into(), + total: progress.total(), + inspected: progress.inspected(), + cache_hits: progress.cache_hits(), + matches: progress.matches(), + unavailable: progress.unavailable(), + } + } +} + +#[derive(Clone, uniffi::Record)] +pub struct MobileTotpRefreshResult { + pub progress: MobileTotpDiscoveryProgress, + pub cache_notice: Option, +} + #[derive(uniffi::Object)] pub struct MobileTotpOperation { operation: StorageTotpOperation, @@ -1279,15 +1298,7 @@ pub struct MobileTotpOperation { #[uniffi::export] impl MobileTotpOperation { pub fn progress(&self) -> MobileTotpDiscoveryProgress { - let progress = self.operation.progress(); - MobileTotpDiscoveryProgress { - phase: progress.phase().into(), - total: progress.total(), - inspected: progress.inspected(), - cache_hits: progress.cache_hits(), - matches: progress.matches(), - unavailable: progress.unavailable(), - } + self.operation.progress().into() } pub fn cancel(&self) { @@ -2369,6 +2380,36 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn search_cached_totp_navigation( + &self, + query: String, + operation: Arc, + ) -> Result>, MobileAuthenticationFfiError> + { + self.authentication + .search_cached_totp_navigation(&query, &operation.operation) + .map(|rows| rows.map(|rows| rows.into_iter().map(Into::into).collect())) + .map_err(Into::into) + } + + pub fn refresh_totp_catalog( + &self, + operation: Arc, + passphrase: Option, + ) -> Result { + self.authentication + .refresh_totp_catalog( + &operation.operation, + passphrase + .map(|value| ironstorage::repository::SecretBytes::new(value.into_bytes())), + ) + .map(|result| MobileTotpRefreshResult { + progress: result.progress.into(), + cache_notice: result.cache_notice, + }) + .map_err(Into::into) + } + pub fn totp_detail( &self, path: String, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 5ef32c7..a69475c 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -1,7 +1,7 @@ //! Shared mobile authentication state; native frontends supply input and present results. use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, error::Error, fmt, path::Path, @@ -31,7 +31,7 @@ use crate::{ }, mobile_totp::{ MobileOtpCodePresentation, MobileTotpDetail, MobileTotpError, MobileTotpOperation, - MobileTotpPage, MobileTotpService, otp_code_presentation, + MobileTotpPage, MobileTotpRefreshResult, MobileTotpService, otp_code_presentation, }, mobile_watch::{ MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError, @@ -1899,11 +1899,37 @@ impl MobileAuthentication { Option>, MobileAuthenticationError, > { - self.cached_totp_page()? + self.search_cached_totp_navigation("", &MobileTotpOperation::default()) + } + + pub fn search_cached_totp_navigation( + &self, + query: &str, + operation: &MobileTotpOperation, + ) -> Result< + Option>, + MobileAuthenticationError, + > { + self.ensure_repository_idle()?; + if query.len() > 256 || query.chars().any(char::is_control) { + return Err(entry_detail( + "TOTP Search Is Unavailable", + "the search text is invalid", + )); + } + MobileTotpService::new(&self.repository, &self.keys) + .search_cached_page_cancellable( + &BTreeSet::new(), + &self.config.source().with_file_name("totp-catalog.toml"), + query, + operation, + ) + .map_err(totp_error)? .map(|page| { page.rows() .iter() .map(|row| { + operation.check_cancelled().map_err(totp_error)?; let path = EntryPath::parse(row.path()).map_err(entry_error)?; crate::mobile_passwords::navigation::entry_navigation_row( &self.repository, @@ -1916,6 +1942,56 @@ impl MobileAuthentication { .transpose() } + /// Android refresh reuses a stable catalog while locked and authenticates only for discovery. + pub fn refresh_totp_catalog( + &self, + operation: &MobileTotpOperation, + passphrase: Option, + ) -> Result { + self.ensure_repository_idle()?; + let _discovery = self.totp_discovery.lock().map_err(|_| { + entry_detail( + "TOTP Discovery Is Unavailable", + "the discovery lock is unavailable", + ) + })?; + let cache_path = self.config.source().with_file_name("totp-catalog.toml"); + match MobileTotpService::new(&self.repository, &self.keys) + .refresh_cached(&cache_path, operation) + { + Ok(page) => { + return Ok(MobileTotpRefreshResult { + progress: operation.progress(), + cache_notice: page.cache_notice().map(str::to_owned), + }); + } + Err(MobileTotpError::AuthenticationRequired) => {} + Err(error) => return Err(totp_error(error)), + } + operation.check_cancelled().map_err(totp_error)?; + if self.ensure_active().is_err() { + self.unlock_totp(passphrase)?; + } + let (handle, key) = { + let status = self.status()?; + let active = status.active.as_ref().ok_or_else(locked_error)?; + (active.handle.clone(), active.key.clone()) + }; + let page = MobileTotpService::new(&self.repository, &self.keys) + .discover( + &BTreeSet::new(), + &mut KeyOnlyProvider::new(handle, &key), + &cache_path, + operation, + ) + .map_err(totp_error)?; + self.ensure_active()?; + Ok(MobileTotpRefreshResult { + progress: operation.progress(), + cache_notice: page.cache_notice().map(str::to_owned), + }) + } + pub fn search_cached_totp_page( &self, query: &str, @@ -3452,6 +3528,54 @@ mod tests { ); } + #[test] + fn android_catalog_refresh_authenticates_only_for_uncached_ciphertext() { + use super::MobileAuthenticationErrorKind; + use crate::{mobile_totp::MobileTotpOperation, repository::SecretBytes}; + let (_root, authentication, _) = + otp_fixture("otpauth://totp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"); + let cold = MobileTotpOperation::default(); + assert_eq!( + authentication + .refresh_totp_catalog(&cold, None) + .unwrap_err() + .kind(), + MobileAuthenticationErrorKind::PassphraseRequired + ); + assert_eq!( + cold.progress().phase(), + crate::mobile_totp::MobileTotpDiscoveryPhase::Preparing + ); + let completed = authentication + .refresh_totp_catalog( + &MobileTotpOperation::default(), + Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())), + ) + .unwrap(); + assert_eq!(completed.progress.matches(), 1); + authentication.manual_lock().unwrap(); + let warm = authentication + .refresh_totp_catalog( + &MobileTotpOperation::default(), + Some(SecretBytes::new(b"incorrect and must not be used".to_vec())), + ) + .unwrap(); + assert_eq!(warm.progress.cache_hits(), warm.progress.total()); + assert!(!authentication.state().unwrap().unlocked()); + let rows = authentication + .search_cached_totp_navigation(" OTP ", &MobileTotpOperation::default()) + .unwrap() + .unwrap(); + assert_eq!(rows.len(), 1); + let cancelled = MobileTotpOperation::default(); + cancelled.cancel(); + assert!( + authentication + .search_cached_totp_navigation("", &cancelled) + .is_err() + ); + } + #[test] fn otp_presentation_matches_rfc_vectors_and_boundaries_without_renewing_the_lease() { use crate::{otp::OtpCodeValidity, repository::SecretBytes}; diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 7df27a0..8753d3e 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -50,6 +50,12 @@ pub struct MobileTotpDiscoveryProgress { unavailable: u32, } +#[derive(Debug)] +pub struct MobileTotpRefreshResult { + pub progress: MobileTotpDiscoveryProgress, + pub cache_notice: Option, +} + impl MobileTotpDiscoveryProgress { pub fn phase(&self) -> MobileTotpDiscoveryPhase { self.phase @@ -99,8 +105,12 @@ impl Default for MobileTotpOperation { impl MobileTotpOperation { pub fn cancel(&self) { - self.cancelled.store(true, Ordering::Relaxed); - self.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Cancelled); + self.update(|progress| { + if progress.phase != MobileTotpDiscoveryPhase::Complete { + self.cancelled.store(true, Ordering::Relaxed); + progress.phase = MobileTotpDiscoveryPhase::Cancelled; + } + }); } pub fn progress(&self) -> MobileTotpDiscoveryProgress { @@ -110,7 +120,7 @@ impl MobileTotpOperation { ) } - fn check_cancelled(&self) -> Result<(), MobileTotpError> { + pub(crate) fn check_cancelled(&self) -> Result<(), MobileTotpError> { if self.cancelled.load(Ordering::Relaxed) { Err(OtpError::Cancelled.into()) } else { @@ -119,11 +129,26 @@ impl MobileTotpOperation { } fn update(&self, update: impl FnOnce(&mut MobileTotpDiscoveryProgress)) { - match self.progress.lock() { - Ok(mut progress) => update(&mut progress), - Err(poisoned) => update(&mut poisoned.into_inner()), + let mut progress = self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !self.cancelled.load(Ordering::Relaxed) { + update(&mut progress); } } + + fn complete(&self, save: impl FnOnce()) -> Result<(), MobileTotpError> { + let mut progress = self + .progress + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + self.check_cancelled()?; + // Cancellation must not split final deletion pruning from successful completion. + save(); + progress.phase = MobileTotpDiscoveryPhase::Complete; + Ok(()) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -429,7 +454,7 @@ impl<'a> MobileTotpService<'a> { cache_path: &Path, operation: &MobileTotpOperation, ) -> Result { - self.discover_with_cache(shared, provider, cache_path, operation, true) + self.discover_with_cache(shared, provider, cache_path, operation, true, false) } pub fn rebuild( @@ -439,7 +464,32 @@ impl<'a> MobileTotpService<'a> { cache_path: &Path, operation: &MobileTotpOperation, ) -> Result { - self.discover_with_cache(shared, provider, cache_path, operation, false) + self.discover_with_cache(shared, provider, cache_path, operation, false, false) + } + + /// A complete unchanged catalog can be refreshed while locked without reading secrets. + pub fn refresh_cached( + &self, + cache_path: &Path, + operation: &MobileTotpOperation, + ) -> Result { + struct NoSecrets; + impl SecretProvider for NoSecrets { + fn secret_for( + &mut self, + _: &crate::crypto::KeyInfo, + ) -> Result { + Err(crate::crypto::SecretProviderError::Missing) + } + } + self.discover_with_cache( + &BTreeSet::new(), + &mut NoSecrets, + cache_path, + operation, + true, + true, + ) } fn discover_with_cache( @@ -449,19 +499,33 @@ impl<'a> MobileTotpService<'a> { cache_path: &Path, operation: &MobileTotpOperation, reuse_cache: bool, + cache_only: bool, ) -> Result { operation.check_cancelled()?; - let initial_inventory = inventory(self.repository)?; - operation.update(|progress| { - progress.phase = MobileTotpDiscoveryPhase::Inspecting; - progress.total = u32::try_from(initial_inventory.len()).unwrap_or(u32::MAX); - }); + let initial_inventory = inventory(self.repository, operation)?; let store = store_identity(self.repository.root_path(), cache_path); let (mut cached, mut cache_notice) = load_cache(cache_path, &store); if !reuse_cache { cached = None; } + if cache_only + && !cached.as_ref().is_some_and(|catalog| { + initial_inventory.iter().all(|(path, _, hash)| { + catalog + .entries + .get(&path.to_string()) + .is_some_and(|record| record.ciphertext_hash == *hash) + }) + }) + { + return Err(MobileTotpError::AuthenticationRequired); + } + operation.check_cancelled()?; + operation.update(|progress| { + progress.phase = MobileTotpDiscoveryPhase::Inspecting; + progress.total = u32::try_from(initial_inventory.len()).unwrap_or(u32::MAX); + }); let mut records = BTreeMap::new(); let mut rows = Vec::new(); let mut unavailable_entries = 0_u32; @@ -507,7 +571,7 @@ impl<'a> MobileTotpService<'a> { } operation.check_cancelled()?; - if initial_inventory != inventory(self.repository)? { + if initial_inventory != inventory(self.repository, operation)? { return Err(MobileTotpError::ConcurrentModification); } operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Saving); @@ -516,13 +580,14 @@ impl<'a> MobileTotpService<'a> { store, entries: records, }; - if let Err(error) = save_cache(cache_path, &catalog) { - cache_notice = Some(error.to_string()); - } + operation.complete(|| { + if let Err(error) = save_cache(cache_path, &catalog) { + cache_notice = Some(error.to_string()); + } + })?; sort_rows(&mut rows); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); - operation.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Complete); Ok(MobileTotpPage { rows, unavailable_entries, @@ -573,29 +638,55 @@ impl<'a> MobileTotpService<'a> { cache_path: &Path, query: &str, ) -> Option { + self.search_cached_page_cancellable( + shared, + cache_path, + query, + &MobileTotpOperation::default(), + ) + .ok() + .flatten() + } + + pub fn search_cached_page_cancellable( + &self, + shared: &BTreeSet, + cache_path: &Path, + query: &str, + operation: &MobileTotpOperation, + ) -> Result, MobileTotpError> { + operation.check_cancelled()?; let store = store_identity(self.repository.root_path(), cache_path); let (catalog, _) = load_cache(cache_path, &store); + let Some(catalog) = catalog else { + return Ok(None); + }; let query = query.trim().to_lowercase(); - let mut rows: Vec<_> = catalog? - .entries - .into_iter() - .filter(|(_, record)| record.is_totp) - .filter_map(|(path, record)| { - let path = EntryPath::parse(&path).ok()?; - let ciphertext = self.repository.read_entry(&path).ok()?; - (digest(ciphertext.as_bytes()) == record.ciphertext_hash).then_some(path) - }) - .filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query)) - .map(|path| cached_row(&path, shared.contains(&path))) - .collect(); + let mut rows = Vec::new(); + for (path, record) in catalog.entries { + operation.check_cancelled()?; + if !record.is_totp || !path.to_lowercase().contains(&query) { + continue; + } + let Ok(path) = EntryPath::parse(&path) else { + continue; + }; + let Ok(ciphertext) = self.repository.read_entry(&path) else { + continue; + }; + if digest(ciphertext.as_bytes()) == record.ciphertext_hash { + rows.push(cached_row(&path, shared.contains(&path))); + } + } sort_rows(&mut rows); let selected = rows.iter().filter(|row| row.shared_with_watch).count(); - Some(MobileTotpPage { + operation.check_cancelled()?; + Ok(Some(MobileTotpPage { rows, unavailable_entries: 0, cache_notice: None, watch: snapshot_status(selected), - }) + })) } pub fn reconcile_document( @@ -726,11 +817,13 @@ struct CachedCatalog { fn inventory( repository: &Repository, -) -> Result, RepositoryError> { + operation: &MobileTotpOperation, +) -> Result, MobileTotpError> { repository .snapshot()? .entries() .map(|entry| { + operation.check_cancelled()?; let encrypted = repository.read_entry(entry.path())?; let ciphertext_hash = digest(encrypted.as_bytes()); Ok((entry.path().clone(), encrypted, ciphertext_hash)) @@ -1013,6 +1106,7 @@ pub enum MobileTotpError { Repository(RepositoryError), Otp(OtpError), ConcurrentModification, + AuthenticationRequired, } impl fmt::Display for MobileTotpError { @@ -1022,6 +1116,9 @@ impl fmt::Display for MobileTotpError { Self::Otp(error) => error.fmt(formatter), Self::ConcurrentModification => formatter .write_str("the password store changed while TOTP discovery was in progress"), + Self::AuthenticationRequired => { + formatter.write_str("authenticate to inspect new or changed entries") + } } } } @@ -1039,3 +1136,31 @@ impl From for MobileTotpError { Self::Otp(error) } } + +#[cfg(test)] +mod operation_tests { + use super::*; + + #[test] + fn cancellation_cannot_split_final_save_from_completion() { + let cancelled = MobileTotpOperation::default(); + cancelled.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Saving); + cancelled.cancel(); + let mut saved = false; + assert!(cancelled.complete(|| saved = true).is_err()); + assert!(!saved); + assert_eq!( + cancelled.progress().phase(), + MobileTotpDiscoveryPhase::Cancelled + ); + + let completed = MobileTotpOperation::default(); + completed.complete(|| saved = true).unwrap(); + completed.cancel(); + assert!(saved); + assert_eq!( + completed.progress().phase(), + MobileTotpDiscoveryPhase::Complete + ); + } +} diff --git a/crates/storage/tests/mobile_totp.rs b/crates/storage/tests/mobile_totp.rs index 8576047..8bdf731 100644 --- a/crates/storage/tests/mobile_totp.rs +++ b/crates/storage/tests/mobile_totp.rs @@ -228,6 +228,14 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu assert_eq!(warm_secrets.requests, 0); assert_eq!(warm.progress().cache_hits(), warm.progress().total()); assert_eq!(warm_page.rows(), page.rows()); + let locked = MobileTotpOperation::default(); + assert_eq!(service.refresh_cached(&cache, &locked)?.rows(), page.rows()); + assert_eq!(locked.progress().cache_hits(), locked.progress().total()); + locked.cancel(); + assert_eq!( + locked.progress().phase(), + MobileTotpDiscoveryPhase::Complete + ); assert_eq!(service.cache_status(&cache).0 as usize, page.rows().len()); let rebuild = MobileTotpOperation::default(); let mut rebuild_secrets = CountingSecrets::all(&fixture); @@ -269,6 +277,17 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu "ordinary", b"otpauth://totp/New:new@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=New\n", )?; + let preserved = fs::read(&cache)?; + let requires_authentication = MobileTotpOperation::default(); + assert!(matches!( + service.refresh_cached(&cache, &requires_authentication), + Err(MobileTotpError::AuthenticationRequired) + )); + assert_eq!( + requires_authentication.progress().phase(), + MobileTotpDiscoveryPhase::Preparing + ); + assert_eq!(fs::read(&cache)?, preserved); let changed = MobileTotpOperation::default(); let mut changed_secrets = CountingSecrets::all(&fixture); let changed_page = @@ -327,6 +346,10 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu let cancelled = MobileTotpOperation::default(); cancelled.cancel(); + assert!(matches!( + service.search_cached_page_cancellable(&BTreeSet::new(), &cache, "team", &cancelled), + Err(MobileTotpError::Otp(OtpError::Cancelled)) + )); let mut cancelled_secrets = CountingSecrets::all(&fixture); assert!(matches!( service.discover(&BTreeSet::new(), &mut cancelled_secrets, &cache, &cancelled,), @@ -490,6 +513,99 @@ fn cancelled_discovery_checkpoints_completed_entries() -> TestResult { service.discover(&BTreeSet::new(), &mut resumed_secrets, &cache, &resumed)?; assert!(resumed.progress().cache_hits() >= 1); assert!(resumed_secrets.requests < resumed.progress().total() as usize); + let deleted = repository + .snapshot()? + .entries() + .next() + .unwrap() + .path() + .clone(); + repository.remove_entry(&deleted)?; + write_plaintext( + &repository, + &keys, + "new-checkpoint", + b"otpauth://totp/Test?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n", + )?; + let interrupted = MobileTotpOperation::default(); + let mut secrets = CancellingSecrets { + inner: FixtureSecrets::all(&fixture), + operation: &interrupted, + requests: 0, + }; + assert!(matches!( + service.discover(&BTreeSet::new(), &mut secrets, &cache, &interrupted), + Err(MobileTotpError::Otp(OtpError::Cancelled)) + )); + let checkpoint: toml::Value = toml::from_str(&fs::read_to_string(&cache)?)?; + assert!(checkpoint["entries"].get(deleted.to_string()).is_some()); + service.discover( + &BTreeSet::new(), + &mut FixtureSecrets::all(&fixture), + &cache, + &MobileTotpOperation::default(), + )?; + let completed: toml::Value = toml::from_str(&fs::read_to_string(&cache)?)?; + assert!(completed["entries"].get(deleted.to_string()).is_none()); + Ok(()) +} + +#[test] +fn large_cached_search_filters_paths_without_reading_secrets() -> TestResult { + let fixture = FixtureSet::load()?; + let store = fixture.materialize_store("basic")?; + let repository = Repository::open(store.path())?; + let keys = KeyStore::load(fixture.path("keys"))?; + write_plaintext( + &repository, + &keys, + "seed", + b"otpauth://totp/Test?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ\n", + )?; + let directory = tempfile::tempdir()?; + let cache = directory.path().join("totp-catalog.toml"); + let service = MobileTotpService::new(&repository, &keys); + service.discover( + &BTreeSet::new(), + &mut FixtureSecrets::all(&fixture), + &cache, + &MobileTotpOperation::default(), + )?; + let ciphertext = repository.read_entry(&EntryPath::parse("seed")?)?; + let mut catalog: toml::Value = toml::from_str(&fs::read_to_string(&cache)?)?; + let entries = catalog["entries"].as_table_mut().unwrap(); + let record = entries["seed"].clone(); + for index in 0..1_000 { + let path = format!("large/entry-{index:04}"); + repository.write_entry(&EntryPath::parse(&path)?, &ciphertext)?; + entries.insert(path, record.clone()); + } + fs::write(&cache, toml::to_string(&catalog)?)?; + let result = service + .search_cached_page_cancellable( + &BTreeSet::new(), + &cache, + " ENTRY-0999 ", + &MobileTotpOperation::default(), + )? + .unwrap(); + assert_eq!( + result + .rows() + .iter() + .map(|row| row.path()) + .collect::>(), + ["large/entry-0999"] + ); + let all = service + .search_cached_page_cancellable( + &BTreeSet::new(), + &cache, + "large/", + &MobileTotpOperation::default(), + )? + .unwrap(); + assert_eq!(all.rows().len(), 1_000); Ok(()) } diff --git a/docs/android/otp.md b/docs/android/otp.md index a26d006..ce63642 100644 --- a/docs/android/otp.md +++ b/docs/android/otp.md @@ -3,9 +3,10 @@ Issue [#107](https://git.rfc1437.de/hugo/IronStorage/issues/107) connects the TOTP destination to the storage-owned discovery catalog. Opening the list reads cached metadata without decrypting entries. Select a row, then Unlock to display its -issuer, account, grouped code, countdown and progress. Refresh TOTP catalog is an -explicit authenticated discovery action; the progress/search workflow belongs -to [#110](https://git.rfc1437.de/hugo/IronStorage/issues/110). +issuer, account, grouped code, countdown and progress. Refresh TOTP catalog reuses +unchanged ciphertext while locked and authenticates for new or changed entries; +[discovery, progress and cached search](totp-discovery.md) implement +[#110](https://git.rfc1437.de/hugo/IronStorage/issues/110). The normal entry viewer and TOTP detail use the same code component and native viewer controller. Decryption, creation and editing reconcile the catalog in diff --git a/docs/android/parity.md b/docs/android/parity.md index bc081cf..c6c140d 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -30,7 +30,7 @@ Every issue linked here belongs to milestone 23. | Password length/generation and replacement | generate; mobile_entry::MobileEntryDraft::generate_password | Generator controls call Rust | #103; Rust validation and unbiased generated value; no Kotlin generator | | OTP URI input, camera/image import, review before Save (OtpImportViewController; TOTP Add action) | mobile_entry::MobileEntryDraft::add_otp_image; document/otp/presentation | CameraX frames or Android photo picker bytes; Rust decode and field construction | #103/#106/#107; URI/image/scan parity, invalid/oversized input, cancel; no payload parsing in Kotlin | | Move/copy/delete entries and folders, destination browse, overwrite and dirty-editor handling (PasswordMutationCoordinator) | mobile_mutation::MobileMutationService/Plan/Request; mutation | Compose dialogs/destination picker | #108; revisions, destination conflicts, recipient reencryption and rollback | -| TOTP discovery catalog, incremental progress, warm cache, refresh, cancel/resume, cached search (TotpListViewController) | mobile_totp discovery/catalog/operation; mobile_authentication cached/search APIs | Compose list/progress/search; private no-backup catalog | #90/#110; cold/warm/change/add/remove/corrupt/wrong-store/concurrent-change tests | +| TOTP discovery catalog, incremental progress, warm cache, refresh, cancel/resume, cached search (TotpListViewController) | mobile_totp discovery/catalog/operation; mobile_authentication cached/search APIs | Compose list/progress/search; private no-backup catalog | #90/#110; [discovery behavior and evidence](totp-discovery.md), cold/warm/change/add/remove/corrupt/wrong-store/concurrent-change tests | | TOTP rows, large detail code, countdown/expiry, copying (TotpDetailViewController, TotpCodeView) | mobile_totp::MobileOtpCodePresentation; otp; mobile_authentication::{presented_otp_code,copy_presented_otp_code,advance_presented_hotp} | Compose code/detail; OS clock facts, Rust timing | #107/#100; [OTP behavior and evidence](otp.md), RFC vectors, boundary countdown, stale callback/relock and clipboard | | Repository/server identity, local-only/add-remote Settings, token replace/remove (PreferencesViewController, TokenUpdateViewController) | mobile_authentication::MobilePreferences; mobile_onboarding::replace_configured_application_token; config/secret_store | Compose Settings/facade | #104/#95; no token display; protected replacement/removal and offline operation | | Key identity/fingerprint, GPG recovery import and public/private QR export (KeyImportScannerViewController, KeyExportListViewController, KeyQrExportViewController) | mobile_key_transfer::{MobileKeyTransferService,MobileKeyTransferImport}; crypto/presentation | CameraX Preview/ImageAnalysis; animated Rust-provided matrices | #106/#104; multi-frame order/duplicates/interruption, bounded frames, private export authorization and camera denial | diff --git a/docs/android/totp-discovery.md b/docs/android/totp-discovery.md new file mode 100644 index 0000000..d8d59b7 --- /dev/null +++ b/docs/android/totp-discovery.md @@ -0,0 +1,56 @@ +# Android TOTP discovery + +The TOTP destination opens the storage-owned non-secret catalog while locked. +Rows are canonical entry identities and paths. Rust hashes current ciphertext +before returning a row, and Kotlin never parses entries or OTP URIs. Searching +uses the cancellable cached navigation API, debounces input, cancels the preceding +Rust operation, and publishes only the latest query's result. + +Explicit refresh and pull-to-refresh use one serialized Rust discovery operation. +An unchanged usable catalog refreshes while locked without requesting a passphrase +or decrypting an entry. New or changed ciphertext requires authentication; an +existing valid lease is reused. Only changed records are decrypted. The UI polls +Rust's typed preparing, inspecting, saving, complete and cancelled phases and +shows total, inspected, cache hits, matches and unavailable counts. Progress is +determinate once the total is known. Cancellation and loss of foreground ownership +cancel native work and leave completed checkpoints available for the next refresh. +A pass that observes a changed repository never reports a complete result. Deleted +records are pruned only after a stable complete pass. Cache recovery/write notices +are presented separately from authentication/operation failures. + +The existing catalog is `totp-catalog.toml` beside the private configuration in +Android's `noBackupFilesDir/ironstorage`, preserved by normal install-over updates. +The versioned store identity prevents another repository from using it. Each +record contains only canonical entry path, SHA-256 ciphertext hash, and `is_totp`. +Passwords, OTP URI/secret, issuer/account, codes, passphrases and keys are absent. +The file uses private permissions and atomic writes and is excluded from backup +and device transfer. Android bindings are built without the Watch feature, so +there are no Watch selection/status fields or controls. + +Verification covers real encrypted cold/warm scans, locked zero-decrypt refresh, +changed/add/remove records, cancellation/resume without early deletion pruning, +concurrent ciphertext changes, damaged/wrong-store catalogs, relocation/update +retention, and storage-owned search across 1,000 cached entries. Android native +instrumentation additionally exercises latest-query-wins, recovery, typed progress +and reconstruction of the Rust/private-session owner while retaining the catalog. +Compose tests cover the displayed phase/counts, query control, determinate progress +and cancellation/resume message. Discovery remains busy through the subsequent +row reload, so observers cannot see an idle state with the old catalog rows. + +On 2026-10-12 the required Rust gates passed, including 519 workspace tests. +Android verification passed for debug/release, both ARM ABIs, Lint, KtLint, +manifest/backup rules, native alignment/debug symbols and dependency/license/SBOM +audits. Full app, core and bridge instrumentation plus five cold starts passed on +API 26 ARM64/4 KB, API 37 ARM64/4 KB and API 37 ARM64/strict 16 KB. + +Computer use verified locked catalog opening, explicit warm refresh with all +records reported as cache hits, typed counts/determinate completion, a nonmatching +query, and catalog retention after install-over/process recreation. The retained +public fixture on the strict 16 KB emulator also displayed its TOTP row in +portrait and adaptive landscape; warm refresh reported four inspected records, +four cache hits, one match and zero unavailable records without authentication. +Authenticated cold/recovery/cancellation paths use real encrypted public fixtures +in native instrumentation. The opt-in retained fixture helper checks its exact +public key fingerprint and reuses an existing current TOTP record without replacing +it. No emulator data or enrollment was reset. ARMv7 is package-audited; its runtime +and physical devices are outside the available emulator evidence.