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
+1070 -165

No files matched your search

@@ -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,28 +29,68 @@ 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(
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()
)
@@ -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)