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
@@ -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
|
||||
}
|
||||
+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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.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<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,
|
||||
};
|
||||
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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user