Implement Android TOTP cached discovery and search (#110)
Android ARM verification / rust (push) Failing after 49m58s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Failing after 22s

This commit is contained in:
hugo committed 2026-10-12 20:38:33 +02:00
1 parent 0c8366d34f
commit f4a6edbc68
18 files changed
+1018 -113

No files matched your search

+2
View File
@@ -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.
@@ -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)
@@ -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() }
}
}
@@ -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()
}
}
@@ -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
}
@@ -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() }
}
}
}
@@ -76,6 +76,8 @@ class NativeAuthenticationRepository(
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
access(true, block)
internal suspend fun <T> metadata(block: (MobileAuthentication) -> T): T = access(false, block)
internal suspend fun <T> observe(block: (MobileAuthentication) -> T): T? =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
if (mutable.value.busy) null else access(false, block)
@@ -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,29 +29,69 @@ class NativeTotpRepository(
private val mutable = MutableStateFlow(TotpState())
override val state: StateFlow<TotpState> = 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(
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
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) {
if (mutable.value.rows.none { it.id == id && it.revision == revision }) return
@@ -58,74 +103,116 @@ class NativeTotpRepository(
mutable.value = mutable.value.copy(detail = false)
reload()
}
override fun discover(passphrase: String) = run { ticket ->
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 != epoch) active.cancel()
if (ticket != refreshEpoch) active.cancel()
try {
val rows = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.totpPage(active)
it.cachedTotpNavigation()
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 ==
epoch
refreshEpoch
) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
progress = result.progress.facade(),
notice = result.cacheNotice
)
}
} finally {
discovery = null
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)
}
}
}
}
}
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()
)
@@ -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<PasswordRow> = 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)
@@ -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,8 +76,13 @@ fun TotpScreen(repository: TotpRepository, authentication: AuthenticationReposit
val expanded = maxWidth >= 600.dp
Row(Modifier.fillMaxSize()) {
if (expanded || !state.detail) {
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = requestCatalog,
modifier = Modifier.weight(1f)
) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-list"),
Modifier.fillMaxSize().padding(16.dp).testTag("totp-list"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
@@ -82,17 +96,83 @@ fun TotpScreen(repository: TotpRepository, authentication: AuthenticationReposit
) {
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()) {
Text(stringResource(R.string.totp_empty))
} else if (state.rows.isEmpty() && !state.searching) {
Text(
stringResource(
if (state.query.isEmpty()) {
R.string.totp_empty
} else {
R.string.totp_no_matches
}
)
)
}
}
items(state.rows, key = { it.id }) { row ->
@@ -111,6 +191,7 @@ fun TotpScreen(repository: TotpRepository, authentication: AuthenticationReposit
}
}
}
}
if (expanded || state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-detail"),
@@ -247,6 +247,14 @@
<item quantity="other">%1$s seconds remaining</item>
</plurals>
<string name="totp_refresh_catalog">Refresh TOTP catalog</string>
<string name="totp_search">Search cached TOTP entries</string>
<string name="totp_no_matches">No cached TOTP entries match this search.</string>
<string name="totp_preparing">Preparing discovery</string>
<string name="totp_inspecting">Inspecting entries</string>
<string name="totp_saving">Saving catalog</string>
<string name="totp_complete">Discovery complete</string>
<string name="totp_cancelled">Cancelled. Completed checkpoints are retained; refresh to resume.</string>
<string name="totp_progress_counts">Inspected: %1$d / %2$d · Cache hits: %3$d · Matches: %4$d · Unavailable: %5$d</string>
<string name="totp_needs_catalog">Refresh the catalog after unlocking to find TOTP entries.</string>
<string name="totp_empty">No TOTP entries found.</string>
<string name="totp_choose">Choose a TOTP entry.</string>
+50 -9
View File
@@ -1271,6 +1271,25 @@ pub struct MobileTotpDiscoveryProgress {
pub unavailable: u32,
}
impl From<ironstorage::mobile_totp::MobileTotpDiscoveryProgress> 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<String>,
}
#[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<MobileTotpOperation>,
) -> Result<Option<Vec<password_navigation::MobileNavigationRow>>, 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<MobileTotpOperation>,
passphrase: Option<String>,
) -> Result<MobileTotpRefreshResult, MobileAuthenticationFfiError> {
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,
+127 -3
View File
@@ -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<Vec<crate::mobile_passwords::navigation::NavigationRow>>,
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<Vec<crate::mobile_passwords::navigation::NavigationRow>>,
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<SecretBytes>,
) -> Result<MobileTotpRefreshResult, MobileAuthenticationError> {
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};
+154 -29
View File
@@ -50,6 +50,12 @@ pub struct MobileTotpDiscoveryProgress {
unavailable: u32,
}
#[derive(Debug)]
pub struct MobileTotpRefreshResult {
pub progress: MobileTotpDiscoveryProgress,
pub cache_notice: Option<String>,
}
impl MobileTotpDiscoveryProgress {
pub fn phase(&self) -> MobileTotpDiscoveryPhase {
self.phase
@@ -99,8 +105,12 @@ impl Default for MobileTotpOperation {
impl MobileTotpOperation {
pub fn cancel(&self) {
self.update(|progress| {
if progress.phase != MobileTotpDiscoveryPhase::Complete {
self.cancelled.store(true, Ordering::Relaxed);
self.update(|progress| progress.phase = MobileTotpDiscoveryPhase::Cancelled);
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<MobileTotpPage, MobileTotpError> {
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<MobileTotpPage, MobileTotpError> {
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<MobileTotpPage, MobileTotpError> {
struct NoSecrets;
impl SecretProvider for NoSecrets {
fn secret_for(
&mut self,
_: &crate::crypto::KeyInfo,
) -> Result<SecretBytes, crate::crypto::SecretProviderError> {
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<MobileTotpPage, MobileTotpError> {
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,
};
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<MobileTotpPage> {
self.search_cached_page_cancellable(
shared,
cache_path,
query,
&MobileTotpOperation::default(),
)
.ok()
.flatten()
}
pub fn search_cached_page_cancellable(
&self,
shared: &BTreeSet<EntryPath>,
cache_path: &Path,
query: &str,
operation: &MobileTotpOperation,
) -> Result<Option<MobileTotpPage>, 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<Vec<(EntryPath, EncryptedEntry, String)>, RepositoryError> {
operation: &MobileTotpOperation,
) -> Result<Vec<(EntryPath, EncryptedEntry, String)>, 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<OtpError> 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
);
}
}
+116
View File
@@ -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::<Vec<_>>(),
["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(())
}
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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 |
+56
View File
@@ -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.