Implement Android TOTP cached discovery and search (#110)
This commit is contained in:
1 parent
0c8366d34f
commit
f4a6edbc68
18 files changed
+1070
-165
No files matched your search
+142
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user