Implement Android TOTP and confirmed HOTP presentation (#107)
Android ARM verification / rust (push) Failing after 49m4s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 21s

This commit is contained in:
hugo committed 2026-10-12 11:43:23 +02:00
1 parent d466b170ab
commit 6a864a8d7f
26 files changed
+1932 -88

No files matched your search

@@ -74,7 +74,7 @@ class AdaptiveShellTest {
original.search, original.search,
original.authentication, original.authentication,
original.editor, original.editor,
original.settings, original.settings, original.totp,
saved saved
) )
compose.activity.runOnUiThread { compose.activity.runOnUiThread {
@@ -159,7 +159,7 @@ class AdaptiveShellTest {
original.search, original.search,
original.authentication, original.authentication,
original.editor, original.editor,
original.settings, original.settings, original.totp,
SavedStateHandle( SavedStateHandle(
saved.keys().associateWith { saved.keys().associateWith {
saved.get<Any>(it) saved.get<Any>(it)
@@ -199,7 +199,7 @@ class AdaptiveShellTest {
original.search, original.search,
original.authentication, original.authentication,
original.editor, original.editor,
original.settings, original.settings, original.totp,
saved saved
) )
compose.activity.runOnUiThread { compose.activity.runOnUiThread {
@@ -242,7 +242,7 @@ class AdaptiveShellTest {
original.search, original.search,
original.authentication, original.authentication,
original.editor, original.editor,
original.settings, original.settings, original.totp,
SavedStateHandle(mapOf("shell.tab" to "Passwords")) SavedStateHandle(mapOf("shell.tab" to "Passwords"))
) )
compose.activity.runOnUiThread { compose.activity.runOnUiThread {
@@ -35,11 +35,12 @@ import de.rfc1437.ironstorage.core.PasswordField
import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordKind
import de.rfc1437.ironstorage.core.PasswordMutation import de.rfc1437.ironstorage.core.PasswordMutation
import de.rfc1437.ironstorage.core.PasswordMutationPlan import de.rfc1437.ironstorage.core.PasswordMutationPlan
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.PasswordRow import de.rfc1437.ironstorage.core.PasswordRow
import de.rfc1437.ironstorage.core.PasswordSection import de.rfc1437.ironstorage.core.PasswordSection
import de.rfc1437.ironstorage.core.PasswordState import de.rfc1437.ironstorage.core.PasswordState
import de.rfc1437.ironstorage.core.PasswordTotp
import de.rfc1437.ironstorage.core.ShellIcon import de.rfc1437.ironstorage.core.ShellIcon
import de.rfc1437.ironstorage.features.PasswordScreen import de.rfc1437.ironstorage.features.PasswordScreen
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
@@ -70,7 +71,7 @@ class EntryViewerTest {
compose.onNodeWithText("654321").assertDoesNotExist() compose.onNodeWithText("654321").assertDoesNotExist()
compose.onNodeWithText("Unlock").performClick() compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed() compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("123456").performScrollTo().assertIsDisplayed() compose.onNodeWithText("123 456").performScrollTo().assertIsDisplayed()
compose.onNodeWithContentDescription("Reveal PIN").performScrollTo() compose.onNodeWithContentDescription("Reveal PIN").performScrollTo()
.performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) } .performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) }
assertEquals(2uL, repository.lastId) assertEquals(2uL, repository.lastId)
@@ -97,7 +98,7 @@ class EntryViewerTest {
} }
compose.runOnIdle { repository.lease.value = AuthenticationState() } compose.runOnIdle { repository.lease.value = AuthenticationState() }
compose.onNode(hasSetTextAction()).assertDoesNotExist() compose.onNode(hasSetTextAction()).assertDoesNotExist()
compose.onNodeWithText("123456").assertDoesNotExist() compose.onNodeWithText("123 456").assertDoesNotExist()
compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist() compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed() compose.onNodeWithText("Unlock").assertIsDisplayed()
} }
@@ -174,7 +175,7 @@ class EntryViewerTest {
org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000) org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000)
} }
} }
private class Fixture : PasswordRepository { internal class Fixture : PasswordRepository {
private val row = private val row =
PasswordRow( PasswordRow(
"row", "row",
@@ -275,12 +276,18 @@ class EntryViewerTest {
override fun unlock(passphrase: String) { override fun unlock(passphrase: String) {
lease.value = AuthenticationState(unlocked = true) lease.value = AuthenticationState(unlocked = true)
state.value = state.value =
state.value.copy(sections = sections, totp = PasswordTotp("123456", "fixture", 17u)) state.value.copy(
sections = sections,
otp = PasswordOtp(
PasswordOtpKind.Totp, "123456", "123 456", null, "fixture",
17u, 30u, 30u, null, null
)
)
} }
override fun lock() { override fun lock() {
authentication.lock() authentication.lock()
state.value = state.value =
state.value.copy(sections = null, totp = null, edit = null) state.value.copy(sections = null, otp = null, edit = null)
} }
private fun field(id: ULong, reveal: Boolean) { private fun field(id: ULong, reveal: Boolean) {
lastId = id lastId = id
@@ -309,7 +316,9 @@ class EntryViewerTest {
lastId = id lastId = id
android.util.Log.i("IronStorage99", "copy-field-$id") android.util.Log.i("IronStorage99", "copy-field-$id")
} }
override fun copyTotp() = Unit override fun refreshOtp() = Unit
override fun advanceHotp() = error("TOTP fixture cannot consume a HOTP counter")
override fun copyOtp() = Unit
override fun edit(id: ULong) { override fun edit(id: ULong) {
lastId = id lastId = id
state.value = state.value =
@@ -209,7 +209,9 @@ class PasswordScreenTest {
override fun reveal(id: ULong) = Unit override fun reveal(id: ULong) = Unit
override fun hide(id: ULong) = Unit override fun hide(id: ULong) = Unit
override fun copy(id: ULong) = Unit override fun copy(id: ULong) = Unit
override fun copyTotp() = Unit override fun copyOtp() = Unit
override fun refreshOtp() = Unit
override fun advanceHotp() = Unit
override fun edit(id: ULong) = Unit override fun edit(id: ULong) = Unit
override fun dismissEdit() = Unit override fun dismissEdit() = Unit
override fun saveEdit(value: String) = Unit override fun saveEdit(value: String) = Unit
@@ -54,7 +54,10 @@ class Tier2WindowTest {
val model = ShellViewModel( val model = ShellViewModel(
AdaptiveShellTest.Fixture(), original.onboarding, original.passwords, AdaptiveShellTest.Fixture(), original.onboarding, original.passwords,
original.home, original.search, authentication, original.editor, original.home, original.search, authentication, original.editor,
SettingsScreenTest.Fixture(), SavedStateHandle(mapOf("shell.tab" to "Preferences")) SettingsScreenTest.Fixture(), original.totp,
SavedStateHandle(
mapOf("shell.tab" to "Preferences")
)
) )
compose.runOnIdle { compose.activity.viewModelStore.put("tier2-window-fixture", model) } compose.runOnIdle { compose.activity.viewModelStore.put("tier2-window-fixture", model) }
lateinit var inputMode: InputModeManager lateinit var inputMode: InputModeManager
@@ -0,0 +1,209 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
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.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
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.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.TotpRepository
import de.rfc1437.ironstorage.core.TotpState
import de.rfc1437.ironstorage.features.TotpScreen
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class TotpScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private fun display(fixture: Fixture, fontScale: Float = 1f) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, fontScale)
) {
MaterialTheme { TotpScreen(fixture, fixture.base.authentication) }
}
}
}
}
@Test fun catalogRequiresExplicitUnlockAndShowsSharedLargeCode() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("otp-code").assertTextEquals("123 456")
compose.onNodeWithText("17 seconds remaining").assertIsDisplayed()
compose.onNodeWithText("Copy code").performClick()
assertEquals(1, fixture.copies)
compose.onNodeWithText("Refresh code").performClick()
assertEquals(1, fixture.refreshes)
compose.onNodeWithText("Lock").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
}
@Test fun ticksStopWhenTheCodeComponentLeaves() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.waitUntil(5000) { fixture.ticks > 0 }
compose.onNodeWithText("Back to TOTP entries").performClick()
val stopped = fixture.ticks
Thread.sleep(1200)
compose.waitForIdle()
assertEquals(stopped, fixture.ticks)
}
@Test fun largeTextShowsEveryDigitWithoutClipping() {
val fixture = Fixture()
display(fixture, 3f)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
val results = mutableListOf<TextLayoutResult>()
compose.onNodeWithTag(
"otp-code"
).performSemanticsAction(SemanticsActions.GetTextLayoutResult) {
org.junit.Assert.assertTrue(it(results))
}
org.junit.Assert.assertTrue(results.isNotEmpty())
org.junit.Assert.assertFalse(results.single().hasVisualOverflow)
}
@Test fun hotpGenerationRequiresConfirmationAndCancelDoesNotConsume() {
val fixture = Fixture(hotp = true)
display(fixture)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Generate next HOTP code").performClick()
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, fixture.generations)
compose.onNodeWithText("Generate next HOTP code").performClick()
compose.onAllNodesWithText("Generate next HOTP code").onLast().performClick()
assertEquals(1, fixture.generations)
compose.onNodeWithTag("otp-code").assertTextEquals("287 082")
compose.onNodeWithText("Copy code").performClick()
compose.onNodeWithText("Refresh code").performClick()
assertEquals(1, fixture.generations)
compose.runOnIdle { fixture.base.authentication.lock() }
compose.onNodeWithTag("otp-code").assertDoesNotExist()
}
@Test fun inspectDisposableTotpWithComputerUse() {
val arguments = InstrumentationRegistry.getArguments()
assumeTrue(arguments.getString("otpInspect") == "true")
val fixture = Fixture(hotp = arguments.getString("otpKind") == "hotp")
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = { done.countDown() }) { Text("Finish OTP inspection") }
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides
Density(
density.density,
arguments.getString("otpFont")?.toFloat() ?: 1f
)
) {
TotpScreen(fixture, fixture.base.authentication)
}
}
}
}
}
compose.waitForIdle()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
}
}
private class Fixture(private val hotp: Boolean = false) : TotpRepository {
val base = EntryViewerTest.Fixture()
var copies = 0
var refreshes = 0
var generations = 0
var ticks = 0
override val state = MutableStateFlow(
TotpState(
rows = base.state.value.rows,
hasCatalog = true
)
)
override val viewer = object : PasswordRepository by base {
override fun unlock(passphrase: String) {
base.unlock(passphrase)
if (hotp) {
base.state.value = base.state.value.copy(
otp = PasswordOtp(
PasswordOtpKind.Hotp, null, null, "RFC", "alice",
null, null, null, null, 1u
)
)
}
}
override fun copyOtp() {
copies += 1
}
override fun tickOtp() {
ticks += 1
}
override fun refreshOtp() {
refreshes += 1
}
override fun advanceHotp() {
generations += 1
base.state.value = base.state.value.copy(
otp = PasswordOtp(
PasswordOtpKind.Hotp, "287082", "287 082", "RFC", "alice",
null, null, null, 1u, 2u
)
)
}
}
override fun enter(selected: String?, revision: String?) = Unit
override fun reload() = Unit
override fun open(id: String, revision: String) {
state.value =
state.value.copy(detail = true)
}
override fun back() {
viewer.leave()
state.value = state.value.copy(detail = false)
}
override fun discover(passphrase: String) = Unit
override fun cancel() = Unit
override fun leave() = viewer.leave()
}
}
@@ -18,12 +18,14 @@ import de.rfc1437.ironstorage.core.NativePasswordRepository
import de.rfc1437.ironstorage.core.NativePrivateSession import de.rfc1437.ironstorage.core.NativePrivateSession
import de.rfc1437.ironstorage.core.NativeSearchRepository import de.rfc1437.ironstorage.core.NativeSearchRepository
import de.rfc1437.ironstorage.core.NativeSettingsRepository import de.rfc1437.ironstorage.core.NativeSettingsRepository
import de.rfc1437.ironstorage.core.NativeTotpRepository
import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.OnboardingRepository
import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.SearchRepository import de.rfc1437.ironstorage.core.SearchRepository
import de.rfc1437.ironstorage.core.SettingsPlatform import de.rfc1437.ironstorage.core.SettingsPlatform
import de.rfc1437.ironstorage.core.SettingsRepository import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.TotpRepository
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
@@ -108,6 +110,25 @@ object ShellModule {
AndroidSecretClipboard(context) AndroidSecretClipboard(context)
) )
@Provides
@Singleton
fun totpRepository(
session: NativePrivateSession,
authentication: NativeAuthenticationRepository,
@ApplicationContext context: Context
): TotpRepository {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val viewer =
NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
AndroidSecretClipboard(context)
)
return NativeTotpRepository(session, authentication, viewer, scope)
}
@Provides @Provides
@Singleton @Singleton
fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository = fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository =
@@ -24,9 +24,14 @@ import org.junit.Test
/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */ /** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */
class NativeEntryViewerTest { class NativeEntryViewerTest {
companion object {
// Rust activates one private root per process; each test recreates this disposable root.
internal val rootName = "native-controller-${UUID.randomUUID()}"
}
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner(): Unit = runBlocking { @Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = "viewer-${UUID.randomUUID()}" val name = rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath File(it, name).apply { mkdirs() }.canonicalPath
} }
@@ -168,8 +173,8 @@ class NativeEntryViewerTest {
assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable }) assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable })
assertEquals(2, fields().count { it.label == "Custom" }) assertEquals(2, fields().count { it.label == "Custom" })
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline }) assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
assertNotNull(repository.state.value.totp) assertNotNull(repository.state.value.otp)
assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL) assertTrue(repository.state.value.otp!!.remainingSeconds!! in 1uL..30uL)
for (event in listOf( for (event in listOf(
AuthenticationLifecycle.Background, AuthenticationLifecycle.Background,
AuthenticationLifecycle.DeviceLocked, AuthenticationLifecycle.DeviceLocked,
@@ -184,7 +189,7 @@ class NativeEntryViewerTest {
authentication.lifecycle(event) authentication.lifecycle(event)
assertNull(repository.state.value.sections) assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit) assertNull(repository.state.value.edit)
assertNull(repository.state.value.totp) assertNull(repository.state.value.otp)
assertNull(clipboard.value) assertNull(clipboard.value)
assertFalse(authentication.state.value.unlocked) assertFalse(authentication.state.value.unlocked)
} }
@@ -227,7 +232,7 @@ class NativeEntryViewerTest {
request { repository.lock() } request { repository.lock() }
assertNull(repository.state.value.sections) assertNull(repository.state.value.sections)
assertNull(repository.state.value.edit) assertNull(repository.state.value.edit)
assertNull(repository.state.value.totp) assertNull(repository.state.value.otp)
withTimeout(10000) { while (clipboard.value != null) delay(20) } withTimeout(10000) { while (clipboard.value != null) delay(20) }
request { repository.unlock(password) } request { repository.unlock(password) }
authentication.authenticated { authentication.authenticated {
@@ -243,7 +248,7 @@ class NativeEntryViewerTest {
authentication.authenticated { it.setAuthenticationTimeout(2u) } authentication.authenticated { it.setAuthenticationTimeout(2u) }
withTimeout(10000) { authentication.state.first { !it.unlocked } } withTimeout(10000) { authentication.state.first { !it.unlocked } }
withTimeout(10000) { repository.state.first { it.sections == null } } withTimeout(10000) { repository.state.first { it.sections == null } }
assertNull(repository.state.value.totp) assertNull(repository.state.value.otp)
assertFalse(authentication.state.value.unlocked) assertFalse(authentication.state.value.unlocked)
authentication.authenticated { it.setAuthenticationTimeout(300u) } authentication.authenticated { it.setAuthenticationTimeout(300u) }
structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts( structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts(
@@ -287,6 +292,7 @@ class NativeEntryViewerTest {
} finally { } finally {
withContext(Dispatchers.Main.immediate) { repository.lock() } withContext(Dispatchers.Main.immediate) { repository.lock() }
scope.cancel() scope.cancel()
roots.forEach { assertTrue(File(it).deleteRecursively()) }
} }
} }
@@ -593,7 +599,7 @@ class NativeEntryViewerTest {
request { settings.setTimeout("300") } request { settings.setTimeout("300") }
} }
private class MemoryClipboard : SecretClipboard { internal class MemoryClipboard : SecretClipboard {
override fun beginCopy(): SecretClipboard = this override fun beginCopy(): SecretClipboard = this
@Volatile var value: ByteArray? = null @Volatile var value: ByteArray? = null
@@ -605,7 +611,7 @@ class NativeEntryViewerTest {
value = null value = null
} }
} }
private class MemoryKeystore : AndroidKeystore { internal class MemoryKeystore : AndroidKeystore {
private val keys = mutableSetOf<String>() private val keys = mutableSetOf<String>()
val keyCount get() = keys.size val keyCount get() = keys.size
var invalidated = false var invalidated = false
@@ -0,0 +1,187 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
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.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeOtpPresentationTest {
@Test fun catalogCodesBoundariesHotpAndLifecycleUseRust(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = NativeEntryViewerTest.rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val monotonic = AtomicLong(0)
val wall = AtomicLong(59)
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, NativeEntryViewerTest.MemoryKeystore())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val memoryClipboard = NativeEntryViewerTest.MemoryClipboard()
var expireCopy = false
val clipboard = object : SecretClipboard by memoryClipboard {
override fun beginCopy(): SecretClipboard {
if (expireCopy) wall.set(60)
return memoryClipboard
}
}
val authentication =
NativeAuthenticationRepository(
session,
scope,
Dispatchers.IO,
object : MobileClock {
override fun monotonicMilliseconds() = monotonic.get().toULong()
}
)
val viewer = NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
clipboard,
{ wall.get().toULong() }
)
val catalog = NativeTotpRepository(session, authentication, viewer, scope)
val passphrase = "public-otp-fixture"
suspend fun view(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { viewer.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key = requireNotNull(setup.generate("OTP fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
for ((entry, uri) in listOf(
"timed" to
"otpauth://totp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8&issuer=RFC",
"counter" to
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC"
)) {
val draft = auth.beginCreateEntry("", entry, passphrase)
val page = auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
uri
)
auth.saveEntryEditor(
draft.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
}
auth.setAuthenticationTimeout(2u)
}
withContext(Dispatchers.Main.immediate) {
authentication.lock()
catalog.enter(null, null)
}
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
assertNull(cached.error)
assertTrue(cached.hasCatalog)
assertEquals(listOf("timed"), cached.rows.map { it.title })
assertFalse(authentication.state.value.unlocked)
assertNull(viewer.state.value.otp)
val row = cached.rows.single()
view { catalog.open(row.id, row.revision) }
assertNull(viewer.state.value.otp)
val opened = view { viewer.unlock(passphrase) }
assertNull(opened.error)
assertEquals("9428 7082", requireNotNull(opened.otp).groupedCode)
assertEquals(1uL, requireNotNull(opened.otp).remainingSeconds)
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val observation = scope.async {
runCatching {
authentication.observe { auth ->
val value = auth.state()
entered.countDown()
assertTrue(release.await(10, TimeUnit.SECONDS))
value
}
}
}
withContext(Dispatchers.IO) { assertTrue(entered.await(10, TimeUnit.SECONDS)) }
val userAction = scope.async { authentication.authenticated { it.state() } }
withTimeout(10000) { authentication.state.first { it.busy } }
release.countDown()
assertTrue(observation.await().isFailure)
assertTrue(userAction.await().unlocked)
assertTrue(authentication.state.value.unlocked)
view { viewer.copyOtp() }
withTimeout(10000) { while (memoryClipboard.value == null) delay(20) }
assertEquals("94287082", memoryClipboard.value!!.toString(Charsets.UTF_8))
withContext(Dispatchers.Main.immediate) { memoryClipboard.clear() }
expireCopy = true
val rejectedCopy = view { viewer.copyOtp() }
assertTrue(rejectedCopy.error != null)
assertNull(memoryClipboard.value)
expireCopy = false
wall.set(60)
monotonic.set(1000)
withContext(Dispatchers.Main.immediate) { viewer.tickOtp() }
withTimeout(30000) { viewer.state.first { it.otp?.remainingSeconds == 30uL } }
assertEquals(30uL, viewer.state.value.otp!!.remainingSeconds)
assertNotEquals("94287082", viewer.state.value.otp!!.code)
monotonic.set(2000)
withTimeout(10000) {
authentication.state.first { !it.unlocked }
}
assertFalse(authentication.state.value.unlocked)
assertNull(memoryClipboard.value)
view { viewer.restore("", null, null) }
val counter = viewer.state.value.rows.single { it.title == "counter" }
view { viewer.activate(counter.id, viewer.state.value.revision) }
val pending = view { viewer.unlock(passphrase) }
assertEquals(PasswordOtpKind.Hotp, requireNotNull(pending.otp).kind)
assertNull(requireNotNull(pending.otp).code)
assertEquals(1uL, requireNotNull(pending.otp).nextCounter)
val generated = view { viewer.advanceHotp() }
assertNull(generated.error)
assertEquals("287 082", requireNotNull(generated.otp).groupedCode)
assertEquals(1uL, requireNotNull(generated.otp).counter)
view { viewer.refreshOtp() }
view { viewer.copyOtp() }
assertEquals(1uL, viewer.state.value.otp!!.counter)
val next = view { viewer.advanceHotp() }
assertEquals("359152", requireNotNull(next.otp).code)
assertEquals(2uL, requireNotNull(next.otp).counter)
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertNull(viewer.state.value.otp)
assertNull(viewer.state.value.sections)
assertNull(memoryClipboard.value)
}
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { File(it).deleteRecursively() }
}
}
}
@@ -26,7 +26,10 @@ import kotlinx.coroutines.withContext
class NativeAuthenticationRepository( class NativeAuthenticationRepository(
private val session: NativePrivateSession, private val session: NativePrivateSession,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val io: CoroutineDispatcher private val io: CoroutineDispatcher,
private val clock: MobileClock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
) : AuthenticationRepository { ) : AuthenticationRepository {
internal fun text(id: Int) = session.text(id) internal fun text(id: Int) = session.text(id)
@@ -38,9 +41,6 @@ class NativeAuthenticationRepository(
private var revocation: Job? = null private var revocation: Job? = null
@Volatile private var current: MobileAuthentication? = null @Volatile private var current: MobileAuthentication? = null
private val clock = object : MobileClock {
override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong()
}
init { init {
presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) } presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) }
scope.launch { scope.launch {
@@ -69,9 +69,17 @@ class NativeAuthenticationRepository(
} }
internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T = internal suspend fun <T> authenticated(block: (MobileAuthentication) -> T): T =
access(true, block)
internal suspend fun <T> observe(block: (MobileAuthentication) -> T): T? =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = sequence.incrementAndGet() if (mutable.value.busy) null else access(false, block)
mutable.value = mutable.value.copy(busy = true, failure = null) }
private suspend fun <T> access(showBusy: Boolean, block: (MobileAuthentication) -> T): T =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = if (showBusy) sequence.incrementAndGet() else sequence.get()
if (showBusy) mutable.value = mutable.value.copy(busy = true, failure = null)
operation.withLock { operation.withLock {
revocation?.join() revocation?.join()
if (ticket != sequence.get()) throw cancelled(session) if (ticket != sequence.get()) throw cancelled(session)
@@ -81,7 +89,7 @@ class NativeAuthenticationRepository(
if (ticket != sequence.get()) throw cancelled(session) if (ticket != sequence.get()) throw cancelled(session)
val result = block(authentication) val result = block(authentication)
if (ticket != sequence.get()) { if (ticket != sequence.get()) {
authentication.manualLock() if (showBusy) authentication.manualLock()
throw cancelled(session) throw cancelled(session)
} }
result result
@@ -114,7 +122,11 @@ class NativeAuthenticationRepository(
if (ticket == sequence.get()) unavailable() if (ticket == sequence.get()) unavailable()
throw error throw error
} finally { } finally {
if (ticket == sequence.get()) mutable.value = mutable.value.copy(busy = false) if (showBusy &&
ticket == sequence.get()
) {
mutable.value = mutable.value.copy(busy = false)
}
} }
} }
} }
@@ -9,6 +9,8 @@ import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation
import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction
import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest
import de.rfc1437.ironstorage.bridge.generated.MobileOtpCodeKind
import de.rfc1437.ironstorage.bridge.generated.MobileOtpCodePresentation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
@@ -16,6 +18,7 @@ import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy import de.rfc1437.ironstorage.bridge.generated.clearMobileEntryCopy
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
import de.rfc1437.ironstorage.bridge.generated.mobileOtpWindowCurrent
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -24,6 +27,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -35,7 +41,8 @@ class NativePasswordRepository(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val authentication: NativeAuthenticationRepository, private val authentication: NativeAuthenticationRepository,
private val io: CoroutineDispatcher, private val io: CoroutineDispatcher,
private val clipboard: SecretClipboard private val clipboard: SecretClipboard,
private val unixSeconds: () -> ULong = ::unixSeconds
) : PasswordRepository { ) : PasswordRepository {
private val mutable = MutableStateFlow(PasswordState()) private val mutable = MutableStateFlow(PasswordState())
override val state: StateFlow<PasswordState> = mutable override val state: StateFlow<PasswordState> = mutable
@@ -51,31 +58,76 @@ class NativePasswordRepository(
} }
} }
scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() } scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() }
// Hidden codes still expire; only the composed UI requests decryption/countdown refresh.
scope.launch { scope.launch {
mutable.map { state ->
state.otp?.takeIf { it.kind == PasswordOtpKind.Totp && it.code != null }?.validUntil
}.distinctUntilChanged().collectLatest { end ->
if (end != null) {
while (true) { while (true) {
delay(1000) delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L))
expireOtp()
}
}
}
}
}
private suspend fun expireOtp() {
val current = mutable.value val current = mutable.value
if (current.totp != null && !current.loading && val code = current.otp ?: return
authentication.state.value.unlocked && if (code.validUntil != null && code.period != null && code.code != null) {
!authentication.state.value.busy val valid = withContext(io) {
mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds())
}
if (!valid &&
mutable.value.otp === code
) { ) {
content(false) { current -> mutable.value = current.copy(otp = code.masked())
val detail = authentication.authenticated { auth -> }
auth.refreshEntryPresentation( }
requireNotNull(current.selected).path, }
override fun tickOtp() {
if (mutable.value.otp?.kind != PasswordOtpKind.Totp) return
scope.launch {
expireOtp()
if (!mutex.isLocked && !mutable.value.loading &&
authentication.state.value.unlocked && !authentication.state.value.busy
) {
refreshOtp(false)
}
}
}
override fun refreshOtp() = refreshOtp(true)
private fun refreshOtp(userActivity: Boolean) = content(userActivity) { current ->
val code = authentication.observe { auth ->
auth.presentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision), requireNotNull(current.documentRevision),
unixSeconds() unixSeconds(),
).totp current.otp?.counter
}
current.copy(
totp = detail?.let {
PasswordTotp(it.code, it.account, it.remainingSeconds)
}
) )
} }
if (code == null) current else current.copy(otp = code.toFacade())
} }
override fun advanceHotp() = content { current ->
val code = authentication.authenticated { auth ->
auth.advancePresentedHotp(
current.entryPath(),
requireNotNull(current.documentRevision),
true
)
} }
val page = authentication.authenticated { auth ->
auth.refreshEntryPresentation(current.entryPath(), code.documentRevision, unixSeconds())
} }
val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation(
current.path,
current.selected?.id,
folder.revision,
false
).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page, code)
} }
override fun restore(path: String, selected: String?, revision: String?) = override fun restore(path: String, selected: String?, revision: String?) =
navigate(path, selected, revision, false) navigate(path, selected, revision, false)
@@ -122,16 +174,18 @@ class NativePasswordRepository(
false false
).toFacade() ).toFacade()
if (verified.selected == null) return@run verified if (verified.selected == null) return@run verified
val page = authentication.authenticated { auth -> val (page, code) = authentication.authenticated { auth ->
auth.navigationEntry( val page = auth.navigationEntry(
current.path, current.path,
selected.id, selected.id,
current.revision, current.revision,
passphrase.ifEmpty { null }, passphrase.ifEmpty { null },
(System.currentTimeMillis() / 1000).toULong() unixSeconds()
) )
page to
auth.presentedOtpCode(selected.path, page.page.revision, unixSeconds(), null)
} }
verified.present(page) verified.present(page, code)
} }
} }
override fun leave() { override fun leave() {
@@ -216,7 +270,11 @@ class NativePasswordRepository(
value value
) )
} }
val page = authentication.authenticated { it.entryPage(current.entryPath(), unixSeconds()) } val (page, code) = authentication.authenticated {
val page = it.entryPage(current.entryPath(), unixSeconds())
page to
it.presentedOtpCode(current.entryPath(), page.page.revision, unixSeconds(), null)
}
val folder = session.storage().passwordNavigation(current.path, null, null, false) val folder = session.storage().passwordNavigation(current.path, null, null, false)
val refreshed = session.storage().passwordNavigation( val refreshed = session.storage().passwordNavigation(
current.path, current.path,
@@ -224,27 +282,35 @@ class NativePasswordRepository(
folder.revision, folder.revision,
false false
).toFacade() ).toFacade()
if (refreshed.selected == null) refreshed else refreshed.present(page) if (refreshed.selected == null) refreshed else refreshed.present(page, code)
} }
override fun copy(id: ULong) = copyValue { auth, current -> override fun copy(id: ULong) = copyValue { auth, current ->
auth.copyPresentedEntryField( auth.copyPresentedEntryField(
current.entryPath(), current.entryPath(),
id, id,
requireNotNull(current.documentRevision) requireNotNull(current.documentRevision)
) ) to null
} }
override fun copyTotp() = copyValue { auth, current -> override fun copyOtp() = copyValue { auth, current ->
auth.copyPresentedTotpCode( val now = unixSeconds()
val code = auth.presentedOtpCode(
current.entryPath(), current.entryPath(),
requireNotNull(current.documentRevision), requireNotNull(current.documentRevision),
unixSeconds() now,
current.otp?.counter
) )
auth.copyPresentedOtpCode(
current.entryPath(),
requireNotNull(current.documentRevision),
now,
current.otp?.counter
) to code
} }
private fun copyValue( private fun copyValue(
get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) -> get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) ->
de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy Pair<de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy, MobileOtpCodePresentation?>
) = content { current -> ) = content { current ->
val copy = authentication.authenticated { get(it, current) } val (copy, code) = authentication.authenticated { get(it, current) }
val ticket = sequence.get() val ticket = sequence.get()
val owned = clipboard.beginCopy() val owned = clipboard.beginCopy()
val written = CompletableDeferred<Boolean>() val written = CompletableDeferred<Boolean>()
@@ -258,6 +324,15 @@ class NativePasswordRepository(
if (!authentication.state.value.unlocked) { if (!authentication.state.value.unlocked) {
throw MobilePlatformFfiException.Cancelled() throw MobilePlatformFfiException.Cancelled()
} }
if (code?.kind == MobileOtpCodeKind.TOTP &&
!mobileOtpWindowCurrent(
requireNotNull(code.validUntil),
requireNotNull(code.period),
unixSeconds()
)
) {
throw MobilePlatformFfiException.Cancelled()
}
clipboardAccess { owned.write(value) } clipboardAccess { owned.write(value) }
written.complete(true) written.complete(true)
Unit Unit
@@ -372,19 +447,23 @@ class NativePasswordRepository(
return return
} }
if (userActivity) authentication.touch() if (userActivity) authentication.touch()
run(false) { action(current) } run(false, userActivity) { action(current) }
} }
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) { private fun run(
clear: Boolean = true,
showLoading: Boolean = true,
action: suspend () -> PasswordState
) {
val ticket = sequence.incrementAndGet() val ticket = sequence.incrementAndGet()
mutable.value = mutable.value =
(if (clear) mutable.value.cleared() else mutable.value).copy( (if (clear) mutable.value.cleared() else mutable.value).copy(
loading = true, loading = showLoading,
error = null error = null
) )
scope.launch { scope.launch {
mutex.withLock { mutex.withLock {
if (sequence.get() != ticket) return@withLock if (sequence.get() != ticket) return@withLock
val result = try { var result = try {
withContext(io) { action() } withContext(io) { action() }
} catch (error: MobilePasswordFfiException.Failed) { } catch (error: MobilePasswordFfiException.Failed) {
mutable.value.copy(error = error.detail, selected = null) mutable.value.copy(error = error.detail, selected = null)
@@ -402,6 +481,17 @@ class NativePasswordRepository(
selected = null selected = null
) )
} }
val code = result.otp
if (code?.kind == PasswordOtpKind.Totp && code.code != null &&
code.validUntil != null &&
code.period != null
) {
val valid =
withContext(io) {
mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds())
}
if (!valid) result = result.copy(otp = code.masked())
}
if (sequence.get() == ticket) { if (sequence.get() == ticket) {
mutable.value = mutable.value =
(if (authentication.state.value.unlocked) result else result.cleared()) (if (authentication.state.value.unlocked) result else result.cleared())
@@ -439,13 +529,16 @@ internal fun MobilePasswordNavigation.toFacade(): PasswordState = PasswordState(
private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong() private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong()
private fun PasswordState.cleared() = copy( private fun PasswordState.cleared() = copy(
sections = null, sections = null,
totp = null, otp = null,
documentRevision = null, documentRevision = null,
edit = null, edit = null,
mutation = null, mutation = null,
notice = null notice = null
) )
private fun PasswordState.present(value: MobileEntryPresentation) = copy( private fun PasswordState.present(
value: MobileEntryPresentation,
code: MobileOtpCodePresentation?
) = copy(
documentRevision = value.page.revision, documentRevision = value.page.revision,
sections = value.page.sections.map { section -> sections = value.page.sections.map { section ->
PasswordSection( PasswordSection(
@@ -460,11 +553,19 @@ private fun PasswordState.present(value: MobileEntryPresentation) = copy(
} }
) )
}, },
totp = value.totp?.let { PasswordTotp(it.code, it.account, it.remainingSeconds) }, otp = code?.toFacade(),
notice = value.cacheNotice, notice = value.cacheNotice,
edit = null, edit = null,
mutation = null mutation = null
) )
private fun PasswordOtp.masked() = copy(code = null, groupedCode = null, remainingSeconds = 0u)
private fun MobileOtpCodePresentation.toFacade() = PasswordOtp(
when (kind) {
MobileOtpCodeKind.TOTP -> PasswordOtpKind.Totp
MobileOtpCodeKind.HOTP -> PasswordOtpKind.Hotp
},
code, groupedCode, issuer, account, remainingSeconds, period, validUntil, counter, nextCounter
)
private fun PasswordMutation.native() = when (this) { private fun PasswordMutation.native() = when (this) {
PasswordMutation.Move -> MobileMutationAction.MOVE PasswordMutation.Move -> MobileMutationAction.MOVE
PasswordMutation.Copy -> MobileMutationAction.COPY PasswordMutation.Copy -> MobileMutationAction.COPY
@@ -0,0 +1,131 @@
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.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeTotpRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
override val viewer: PasswordRepository,
private val scope: CoroutineScope
) : TotpRepository {
private val mutable = MutableStateFlow(TotpState())
override val state: StateFlow<TotpState> = mutable
private val serial = Mutex()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
init {
val cleanup = authentication.presentation.register(::cancel)
scope.coroutineContext[Job]?.invokeOnCompletion { cleanup.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 open(id: String, revision: String) {
if (mutable.value.rows.none { it.id == id && it.revision == revision }) return
mutable.value = mutable.value.copy(detail = true, error = null)
viewer.openSearch(id, revision)
}
override fun back() {
viewer.leave()
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()
}
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
discovery?.cancel()
mutable.value = mutable.value.copy(loading = false)
}
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)
}
}
}
}
}
@@ -28,8 +28,20 @@ data class PasswordField(
override fun toString() = "PasswordField(id=$id, [REDACTED])" override fun toString() = "PasswordField(id=$id, [REDACTED])"
} }
data class PasswordSection(val title: String, val fields: List<PasswordField>) data class PasswordSection(val title: String, val fields: List<PasswordField>)
data class PasswordTotp(val code: String, val account: String, val remainingSeconds: ULong) { enum class PasswordOtpKind { Totp, Hotp }
override fun toString() = "PasswordTotp([REDACTED])" data class PasswordOtp(
val kind: PasswordOtpKind,
val code: String?,
val groupedCode: String?,
val issuer: String?,
val account: String,
val remainingSeconds: ULong?,
val period: ULong?,
val validUntil: ULong?,
val counter: ULong?,
val nextCounter: ULong?
) {
override fun toString() = "PasswordOtp([REDACTED])"
} }
data class PasswordEdit( data class PasswordEdit(
val id: ULong, val id: ULong,
@@ -65,7 +77,7 @@ data class PasswordState(
val stale: Boolean = false, val stale: Boolean = false,
val error: String? = null, val error: String? = null,
val sections: List<PasswordSection>? = null, val sections: List<PasswordSection>? = null,
val totp: PasswordTotp? = null, val otp: PasswordOtp? = null,
val documentRevision: String? = null, val documentRevision: String? = null,
val notice: String? = null, val notice: String? = null,
val edit: PasswordEdit? = null, val edit: PasswordEdit? = null,
@@ -85,7 +97,10 @@ interface PasswordRepository {
fun reveal(id: ULong) fun reveal(id: ULong)
fun hide(id: ULong) fun hide(id: ULong)
fun copy(id: ULong) fun copy(id: ULong)
fun copyTotp() fun copyOtp()
fun tickOtp() = Unit
fun refreshOtp()
fun advanceHotp()
fun edit(id: ULong) fun edit(id: ULong)
fun dismissEdit() fun dismissEdit()
fun saveEdit(value: String) fun saveEdit(value: String)
@@ -0,0 +1,24 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class TotpState(
val rows: List<PasswordRow> = emptyList(),
val loading: Boolean = false,
val hasCatalog: Boolean = false,
val detail: Boolean = false,
val error: String? = null
) {
override fun toString() = "TotpState(loading=$loading, [REDACTED])"
}
interface TotpRepository {
val state: StateFlow<TotpState>
val viewer: PasswordRepository
fun enter(selected: String?, revision: String?)
fun reload()
fun open(id: String, revision: String)
fun back()
fun discover(passphrase: String)
fun cancel()
fun leave()
}
@@ -99,23 +99,16 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
} }
} }
state.notice?.let { item { Text(it) } } state.notice?.let { item { Text(it) } }
state.totp?.let { totp -> state.otp?.let { code ->
item { item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OtpCodeView(
Text(stringResource(R.string.entry_totp), Modifier.semantics { heading() }) code,
Text(totp.account) state.loading,
Text(totp.code, style = MaterialTheme.typography.headlineMedium) repository::copyOtp,
Text( repository::refreshOtp,
pluralStringResource( repository::advanceHotp,
R.plurals.entry_countdown, repository::tickOtp
totp.remainingSeconds.toInt(),
totp.remainingSeconds.toLong()
) )
)
TextButton(enabled = !state.loading, onClick = repository::copyTotp) {
Text(stringResource(R.string.entry_copy_code))
}
}
} }
} }
state.sections?.forEach { section -> state.sections?.forEach { section ->
@@ -0,0 +1,159 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import kotlinx.coroutines.delay
@Composable
internal fun OtpCodeView(
code: PasswordOtp,
busy: Boolean,
copy: () -> Unit,
refresh: () -> Unit,
advance: () -> Unit,
tick: () -> Unit
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle, code.kind) {
if (code.kind == PasswordOtpKind.Totp) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
tick()
while (true) {
delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L))
tick()
}
}
}
}
var confirmation by remember(code.nextCounter) { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(
if (code.kind ==
PasswordOtpKind.Totp
) {
R.string.entry_totp
} else {
R.string.otp_hotp
}
),
Modifier.semantics { heading() }
)
code.issuer?.let { Text(it, style = MaterialTheme.typography.titleLarge) }
Text(code.account)
code.groupedCode?.let { value ->
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
value,
Modifier.fillMaxWidth().testTag("otp-code"),
style = MaterialTheme.typography.displayMedium,
fontFamily = FontFamily.Monospace,
maxLines = 2,
autoSize = TextAutoSize.StepBased(minFontSize = 24.sp, maxFontSize = 48.sp)
)
}
}
?: Text(
stringResource(
if (code.kind ==
PasswordOtpKind.Hotp
) {
R.string.otp_hotp_pending
} else {
R.string.otp_waiting
}
)
)
val period = code.period
val remaining = code.remainingSeconds
if (code.kind == PasswordOtpKind.Totp && period != null && remaining != null) {
LinearProgressIndicator(progress = {
(
remaining.toFloat() /
period.toFloat()
).coerceIn(0f, 1f)
}, modifier = Modifier.fillMaxWidth())
Text(
pluralStringResource(
R.plurals.otp_countdown,
remaining.coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
remaining.toString()
)
)
}
if (code.kind == PasswordOtpKind.Hotp &&
code.code != null
) {
Text(stringResource(R.string.otp_counter, code.counter.toString()))
}
FlowRow {
TextButton(enabled = !busy && code.code != null, onClick = copy) {
Text(stringResource(R.string.entry_copy_code))
}
TextButton(enabled = !busy, onClick = refresh) {
Text(stringResource(R.string.otp_refresh))
}
if (code.kind ==
PasswordOtpKind.Hotp
) {
TextButton(enabled = !busy && code.nextCounter != null, onClick = {
confirmation =
true
}) { Text(stringResource(R.string.otp_generate_hotp)) }
}
}
}
if (confirmation) {
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = { confirmation = false },
title = { Text(stringResource(R.string.otp_generate_hotp)) },
text = { Text(stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString())) },
confirmButton = {
TextButton(enabled = !busy, onClick = {
confirmation = false
advance()
}) { Text(stringResource(R.string.otp_generate_hotp)) }
},
dismissButton = {
TextButton(onClick = {
confirmation = false
}) { Text(stringResource(R.string.auth_cancel)) }
}
)
}
}
@@ -200,6 +200,10 @@ private fun ShellDestination(
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
leaveOnDispose = false leaveOnDispose = false
) )
ready && route == ShellRoute.Totp -> {
LaunchedEffect(viewModel) { viewModel.openTotp() }
TotpScreen(viewModel.totp, viewModel.authentication)
}
ready && route == ShellRoute.Home -> HomeScreen( ready && route == ShellRoute.Home -> HomeScreen(
viewModel.home, viewModel.home,
Modifier.fillMaxSize(), Modifier.fillMaxSize(),
@@ -15,6 +15,7 @@ import de.rfc1437.ironstorage.core.SettingsRepository
import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellRepository
import de.rfc1437.ironstorage.core.ShellState import de.rfc1437.ironstorage.core.ShellState
import de.rfc1437.ironstorage.core.ShellTab import de.rfc1437.ironstorage.core.ShellTab
import de.rfc1437.ironstorage.core.TotpRepository
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -30,6 +31,7 @@ class ShellViewModel @Inject constructor(
val authentication: AuthenticationRepository, val authentication: AuthenticationRepository,
val editor: EditorRepository, val editor: EditorRepository,
val settings: SettingsRepository, val settings: SettingsRepository,
val totp: TotpRepository,
private val saved: SavedStateHandle private val saved: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
val snapshot = repository.snapshot val snapshot = repository.snapshot
@@ -71,6 +73,37 @@ class ShellViewModel @Inject constructor(
} }
} }
} }
viewModelScope.launch {
totp.state.collect { saved["totp.detail"] = it.detail }
}
viewModelScope.launch {
totp.viewer.state.collect { value ->
val selected = value.selected
if (!value.loading && value.error == null && selected != null) {
saved["totp.selection"] = selected.id
saved["totp.revision"] = selected.revision
}
}
}
}
private var totpRestored = false
fun openTotp() {
if (!totpRestored) {
totpRestored = true
totp.enter(
if (saved.get<Boolean>("totp.detail") ==
true
) {
saved["totp.selection"]
} else {
null
},
saved["totp.revision"]
)
} else {
val selected = totp.viewer.state.value.selected.takeIf { totp.state.value.detail }
totp.enter(selected?.id, selected?.revision)
}
} }
private var passwordsRestored = false private var passwordsRestored = false
fun openSearch(row: PasswordRow) { fun openSearch(row: PasswordRow) {
@@ -116,7 +149,7 @@ class ShellViewModel @Inject constructor(
settings.cancel() settings.cancel()
if (authentication.state.value.busy) authentication.cancel() if (authentication.state.value.busy) authentication.cancel()
} }
ShellTab.Totp -> Unit ShellTab.Totp -> totp.leave()
} }
} }
override fun onCleared() { override fun onCleared() {
@@ -0,0 +1,192 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.Role
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.TotpRepository
@Composable
fun TotpScreen(repository: TotpRepository, authentication: AuthenticationRepository) {
val state by repository.state.collectAsStateWithLifecycle()
val entry by repository.viewer.state.collectAsStateWithLifecycle()
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var discovery by remember { mutableStateOf(false) }
var requested by remember { mutableStateOf(false) }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { repository.leave() }
BackHandler(state.detail) { repository.back() }
androidx.compose.runtime.LaunchedEffect(lease.failure, state.loading, entry.loading) {
if (requested && !state.loading && !entry.loading && !lease.busy) {
requested = false
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
) {
manual = true
}
}
}
val requestCatalog = {
discovery = true
requested = true
repository.discover("")
}
val requestUnlock = {
discovery = false
requested = true
repository.viewer.unlock("")
}
BoxWithConstraints(Modifier.fillMaxSize()) {
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)
) {
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))
}
}
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)
}
}
}
}
if (expanded || state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-detail"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
if (state.detail) {
TextButton(onClick = repository::back) {
Text(stringResource(R.string.totp_back))
}
}
entry.selected?.let {
Text(it.title, style = MaterialTheme.typography.headlineSmall)
}
if (entry.loading) CircularProgressIndicator()
entry.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.detail) {
Text(stringResource(R.string.totp_choose))
} else if (entry.stale) {
Text(stringResource(R.string.totp_stale))
} else if (entry.selected != null &&
(!lease.unlocked || entry.sections == null)
) {
Button(
enabled = !entry.loading && !lease.busy,
onClick = requestUnlock
) {
Text(stringResource(R.string.password_unlock))
}
TextButton(enabled = !lease.busy, onClick = {
discovery = false
manual =
true
}) { Text(stringResource(R.string.auth_manual)) }
if (lease.busy) {
TextButton(onClick = authentication::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
} else if (lease.unlocked && entry.sections != null) {
entry.otp?.let {
OtpCodeView(
it,
entry.loading,
repository.viewer::copyOtp,
repository.viewer::refreshOtp,
repository.viewer::advanceHotp,
repository.viewer::tickOtp
)
}
?: Text(stringResource(R.string.totp_unavailable))
TextButton(onClick = repository.viewer::lock) {
Text(stringResource(R.string.password_lock))
}
entry.notice?.let { Text(it) }
}
}
}
}
}
}
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.auth_manual_detail),
presentation = authentication.presentation,
onCancel = {
manual = false
repository.cancel()
authentication.cancel()
},
onSubmit = {
manual = false
requested = true
if (discovery) repository.discover(it) else repository.viewer.unlock(it)
}
)
}
}
@@ -235,4 +235,22 @@
<string name="qr_export_private_confirm">I checked this fingerprint and authorize displaying its private QR codes.</string> <string name="qr_export_private_confirm">I checked this fingerprint and authorize displaying its private QR codes.</string>
<string name="qr_animate">Automatically advance frames</string> <string name="qr_animate">Automatically advance frames</string>
<string name="qr_setup_import">Prepare the scanned key</string> <string name="qr_setup_import">Prepare the scanned key</string>
<string name="otp_hotp">HOTP</string>
<string name="otp_waiting">Waiting for a current code.</string>
<string name="otp_hotp_pending">Generate a code to consume the next HOTP counter.</string>
<string name="otp_refresh">Refresh code</string>
<string name="otp_generate_hotp">Generate next HOTP code</string>
<string name="otp_confirm_hotp">This consumes counter %1$s and saves the incremented counter in the encrypted entry. Continue?</string>
<string name="otp_counter">Generated for counter %1$s</string>
<plurals name="otp_countdown">
<item quantity="one">%1$s second remaining</item>
<item quantity="other">%1$s seconds remaining</item>
</plurals>
<string name="totp_refresh_catalog">Refresh TOTP catalog</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>
<string name="totp_unavailable">This entry no longer has a TOTP code.</string>
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
<string name="totp_back">Back to TOTP entries</string>
</resources> </resources>
+103
View File
@@ -1247,6 +1247,61 @@ pub struct MobileTotpDetail {
pub watch: MobileWatchSnapshotStatus, pub watch: MobileWatchSnapshotStatus,
} }
#[derive(Clone, Copy, uniffi::Enum)]
pub enum MobileOtpCodeKind {
Totp,
Hotp,
}
#[derive(Clone, uniffi::Record)]
pub struct MobileOtpCodePresentation {
pub document_revision: String,
pub kind: MobileOtpCodeKind,
pub issuer: Option<String>,
pub account: String,
pub code: Option<String>,
pub grouped_code: Option<String>,
pub period: Option<u64>,
pub valid_until: Option<u64>,
pub remaining_seconds: Option<u64>,
pub counter: Option<u64>,
pub next_counter: Option<u64>,
}
impl From<ironstorage::mobile_totp::MobileOtpCodePresentation> for MobileOtpCodePresentation {
fn from(value: ironstorage::mobile_totp::MobileOtpCodePresentation) -> Self {
let text = |bytes: ironstorage::repository::SecretBytes| {
String::from_utf8(bytes.expose().to_vec())
.expect("storage-generated OTP codes are ASCII")
};
Self {
document_revision: value.document_revision,
kind: match value.kind {
ironstorage::otp::OtpKind::Totp => MobileOtpCodeKind::Totp,
ironstorage::otp::OtpKind::Hotp => MobileOtpCodeKind::Hotp,
},
issuer: value.issuer,
account: value.account,
code: value.code.map(text),
grouped_code: value.grouped_code.map(text),
period: value.period,
valid_until: value.valid_until,
remaining_seconds: value.remaining_seconds,
counter: value.counter,
next_counter: value.next_counter,
}
}
}
#[uniffi::export]
pub fn mobile_otp_window_current(valid_until: u64, period: u64, unix_seconds: u64) -> bool {
ironstorage::otp::OtpCodeValidity::Timed {
valid_until,
period,
}
.is_current(unix_seconds)
}
impl From<StorageTotpDetail> for MobileTotpDetail { impl From<StorageTotpDetail> for MobileTotpDetail {
fn from(detail: StorageTotpDetail) -> Self { fn from(detail: StorageTotpDetail) -> Self {
Self { Self {
@@ -2010,6 +2065,44 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn presented_otp_code(
&self,
path: String,
revision: String,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<Option<MobileOtpCodePresentation>, MobileAuthenticationFfiError> {
self.authentication
.presented_otp_code(&path, &revision, unix_seconds, consumed_counter)
.map(|value| value.map(Into::into))
.map_err(Into::into)
}
pub fn copy_presented_otp_code(
&self,
path: String,
revision: String,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
self.authentication
.copy_presented_otp_code(&path, &revision, unix_seconds, consumed_counter)
.map(Into::into)
.map_err(Into::into)
}
pub fn advance_presented_hotp(
&self,
path: String,
revision: String,
confirmed: bool,
) -> Result<MobileOtpCodePresentation, MobileAuthenticationFfiError> {
self.authentication
.advance_presented_hotp(&path, &revision, confirmed)
.map(Into::into)
.map_err(Into::into)
}
pub fn prepare_presented_entry_mutation( pub fn prepare_presented_entry_mutation(
&self, &self,
path: String, path: String,
@@ -2146,6 +2239,16 @@ impl MobileAuthentication {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn cached_totp_navigation(
&self,
) -> Result<Option<Vec<password_navigation::MobileNavigationRow>>, MobileAuthenticationFfiError>
{
self.authentication
.cached_totp_navigation()
.map(|rows| rows.map(|rows| rows.into_iter().map(Into::into).collect()))
.map_err(Into::into)
}
pub fn search_cached_totp_page( pub fn search_cached_totp_page(
&self, &self,
query: String, query: String,
+445 -2
View File
@@ -30,13 +30,14 @@ use crate::{
MobileMutationRequest, MobileMutationService, MobileMutationRequest, MobileMutationService,
}, },
mobile_totp::{ mobile_totp::{
MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService, MobileOtpCodePresentation, MobileTotpDetail, MobileTotpError, MobileTotpOperation,
MobileTotpPage, MobileTotpService, otp_code_presentation,
}, },
mobile_watch::{ mobile_watch::{
MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError, MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError,
WatchSnapshotSender, WatchSnapshotTransfer, WatchSnapshotSender, WatchSnapshotTransfer,
}, },
otp::OtpError, otp::{OtpError, OtpKind, OtpService},
recipient::RecipientPolicyManager, recipient::RecipientPolicyManager,
repository::{ repository::{
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes, DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
@@ -1124,6 +1125,121 @@ impl MobileAuthentication {
}) })
} }
pub fn presented_otp_code(
&self,
path: &str,
revision: &str,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<Option<MobileOtpCodePresentation>, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let document = self.open_presented_document(path, revision)?;
match otp_code_presentation(&document, unix_seconds, consumed_counter) {
Err(OtpError::ConcurrentModification { .. }) => {
self.manual_lock()?;
Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Conflict,
"HOTP Entry Changed",
"Refresh and unlock the entry before using its code.",
))
}
result => result.map_err(|error| totp_error(error.into())),
}
}
pub fn copy_presented_otp_code(
&self,
path: &str,
revision: &str,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
let code = self
.presented_otp_code(path, revision, unix_seconds, consumed_counter)?
.and_then(|value| value.code)
.ok_or_else(|| {
entry_detail(
"OTP Code Is Unavailable",
"Open a TOTP code or explicitly generate a HOTP code before copying.",
)
})?;
Ok(MobileEntryCopy {
value: String::from_utf8(code.expose().to_vec())
.map_err(|_| entry_detail("OTP Code Is Unavailable", "The code is not UTF-8."))?,
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
})
}
pub fn advance_presented_hotp(
&self,
path: &str,
revision: &str,
confirmed: bool,
) -> Result<MobileOtpCodePresentation, MobileAuthenticationError> {
if !confirmed {
return Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Cancelled,
"HOTP Generation Cancelled",
"The HOTP counter was not changed.",
));
}
self.ensure_repository_idle()?;
self.ensure_active()?;
self.reserve_entry_mutation()?;
let result = (|| {
let document = self.open_presented_document(path, revision)?;
if self.editor_state(path)?.0 {
return Err(entry_detail(
"HOTP Entry Is Being Edited",
"Close the entry editor before generating a code.",
));
}
if document
.otp_uri()
.map_err(|error| totp_error(error.into()))?
.is_none_or(|uri| uri.kind() != OtpKind::Hotp)
{
return Err(totp_error(OtpError::NotHotp.into()));
}
let (handle, key, identity) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(
active.handle.clone(),
active.key.clone(),
status.git_identity.clone(),
)
};
let mut provider = KeyOnlyProvider::new(handle, &key);
let mut committer =
AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
.map_err(|error| entry_detail("HOTP Counter Could Not Be Updated", error))?;
let outcome = OtpService::new(&self.repository, &self.keys)
.code_for_document(&document, &mut provider, &mut committer)
.map_err(|error| totp_error(error.into()))?;
let committed_error = || {
entry_detail(
"HOTP Counter Was Updated",
"Refresh and unlock the entry before generating another code. The previous counter was already consumed.",
)
};
let updated = self
.open_active_document(path)
.map_err(|_| committed_error())?;
let code = otp_code_presentation(&updated, 0, outcome.counter())
.map_err(|_| committed_error())?
.ok_or_else(committed_error)?;
if code.code.as_ref().map(SecretBytes::expose) != Some(outcome.code().expose()) {
self.manual_lock()?;
return Err(committed_error());
}
self.reconcile_saved_document(&updated);
Ok(code)
})();
self.release_entry_mutation();
result
}
pub fn prepare_presented_entry_mutation( pub fn prepare_presented_entry_mutation(
&self, &self,
path: &str, path: &str,
@@ -1283,6 +1399,29 @@ impl MobileAuthentication {
self.search_cached_totp_page("") self.search_cached_totp_page("")
} }
pub fn cached_totp_navigation(
&self,
) -> Result<
Option<Vec<crate::mobile_passwords::navigation::NavigationRow>>,
MobileAuthenticationError,
> {
self.cached_totp_page()?
.map(|page| {
page.rows()
.iter()
.map(|row| {
let path = EntryPath::parse(row.path()).map_err(entry_error)?;
crate::mobile_passwords::navigation::entry_navigation_row(
&self.repository,
&path,
)
.map_err(|error| entry_detail(error.title(), error.detail()))
})
.collect()
})
.transpose()
}
pub fn search_cached_totp_page( pub fn search_cached_totp_page(
&self, &self,
query: &str, query: &str,
@@ -2452,6 +2591,310 @@ mod tests {
watch_preference, watch_preference,
}; };
struct OtpClock(std::sync::atomic::AtomicU64);
impl crate::authentication::AuthenticationClock for OtpClock {
fn now(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.0.load(std::sync::atomic::Ordering::Relaxed))
}
}
fn otp_fixture(
uri: &str,
) -> (
tempfile::TempDir,
std::sync::Arc<super::MobileAuthentication>,
std::sync::Arc<OtpClock>,
) {
use crate::{
crypto::KeyStore,
git::{GitIdentity, GitRepository},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
};
let (root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture();
let config = private.configuration().unwrap().unwrap();
let repository = Repository::open(config.vault()).unwrap();
let keys = KeyStore::load(config.key_material()).unwrap();
let path = EntryPath::parse("otp").unwrap();
let policy = RecipientPolicyManager::new(&repository, &keys)
.resolve_for_entry(&path, None)
.unwrap();
let encrypted = keys
.encrypt(
SecretBytes::new(format!("fixture-password\n{uri}\n").into_bytes()),
policy.recipients(),
)
.unwrap();
repository.write_entry(&path, &encrypted).unwrap();
GitRepository::open(&repository, GitIdentity::ironstorage())
.unwrap()
.commit_all("Seed encrypted OTP fixture")
.unwrap();
let clock = std::sync::Arc::new(OtpClock(std::sync::atomic::AtomicU64::new(0)));
let authentication = private.authentication(backend, clock.clone()).unwrap();
(root, authentication, clock)
}
#[test]
fn otp_presentation_matches_rfc_vectors_and_boundaries_without_renewing_the_lease() {
use crate::{otp::OtpCodeValidity, repository::SecretBytes};
for (algorithm, secret, expected) in [
("SHA1", "12345678901234567890", "94287082"),
("SHA256", "12345678901234567890123456789012", "46119246"),
(
"SHA512",
"1234567890123456789012345678901234567890123456789012345678901234",
"90693936",
),
] {
let secret = data_encoding::BASE32_NOPAD.encode(secret.as_bytes());
let uri = format!(
"otpauth://totp/RFC:alice?secret={secret}&algorithm={algorithm}&digits=8&period=30&issuer=RFC"
);
let (_root, authentication, clock) = otp_fixture(&uri);
assert!(authentication.cached_totp_navigation().unwrap().is_none());
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
authentication.set_authentication_timeout_text("2").unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
let revision = page.page().revision();
let code = authentication
.presented_otp_code("otp", revision, 59, None)
.unwrap()
.unwrap();
assert_eq!(code.code.as_ref().unwrap().expose(), expected.as_bytes());
assert_eq!(
code.grouped_code.as_ref().unwrap().expose(),
format!("{} {}", &expected[..4], &expected[4..]).as_bytes()
);
assert_eq!(code.remaining_seconds, Some(1));
assert_eq!(code.valid_until, Some(60));
let rows = authentication.cached_totp_navigation().unwrap().unwrap();
assert_eq!(rows.len(), 1);
let cached = std::fs::read(
authentication
.config
.source()
.with_file_name("totp-catalog.toml"),
)
.unwrap();
let at_boundary = authentication
.presented_otp_code("otp", revision, 60, None)
.unwrap()
.unwrap();
assert_ne!(
at_boundary.code.as_ref().unwrap().expose(),
expected.as_bytes()
);
assert_eq!(at_boundary.remaining_seconds, Some(30));
assert_eq!(at_boundary.valid_until, Some(90));
assert_eq!(
std::fs::read(
authentication
.config
.source()
.with_file_name("totp-catalog.toml")
)
.unwrap(),
cached
);
let window = OtpCodeValidity::Timed {
valid_until: 60,
period: 30,
};
assert!(!window.is_current(29));
assert!(window.is_current(30));
assert!(window.is_current(59));
assert!(!window.is_current(60));
clock.0.store(1, std::sync::atomic::Ordering::Relaxed);
authentication
.presented_otp_code("otp", revision, 61, None)
.unwrap();
assert_eq!(authentication.state().unwrap().remaining_seconds(), 1);
clock.0.store(2, std::sync::atomic::Ordering::Relaxed);
assert!(
authentication
.presented_otp_code("otp", revision, 62, None)
.is_err()
);
assert!(!authentication.state().unwrap().unlocked());
}
}
#[test]
fn malformed_otp_keeps_the_structured_entry_readable() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture("otpauth://totp/Invalid?secret=!");
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
assert!(!page.page().sections().is_empty());
assert!(
authentication
.presented_otp_code("otp", page.page().revision(), 59, None)
.unwrap()
.is_none()
);
}
#[test]
fn hotp_requires_confirmation_commits_once_and_copy_refresh_never_advance() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture(
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC",
);
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 0).unwrap();
let revision = page.page().revision();
let before = authentication
.repository
.read_entry(&crate::repository::EntryPath::parse("otp").unwrap())
.unwrap();
assert!(
authentication
.advance_presented_hotp("otp", revision, false)
.is_err()
);
assert_eq!(
authentication
.repository
.read_entry(&crate::repository::EntryPath::parse("otp").unwrap())
.unwrap(),
before
);
assert!(
authentication
.presented_otp_code("otp", revision, 0, None)
.unwrap()
.unwrap()
.code
.is_none()
);
assert!(
authentication
.copy_presented_otp_code("otp", revision, 0, None)
.is_err()
);
let issued = authentication
.advance_presented_hotp("otp", revision, true)
.unwrap();
assert_eq!(issued.code.as_ref().unwrap().expose(), b"287082");
assert_eq!(issued.counter, Some(1));
assert_eq!(issued.next_counter, Some(2));
assert_eq!(
authentication
.copy_presented_otp_code("otp", &issued.document_revision, 100, issued.counter)
.unwrap()
.value(),
"287082"
);
let refreshed = authentication
.presented_otp_code("otp", &issued.document_revision, 200, issued.counter)
.unwrap()
.unwrap();
assert_eq!(refreshed.next_counter, Some(2));
let next = authentication
.advance_presented_hotp("otp", &issued.document_revision, true)
.unwrap();
assert_eq!(next.code.as_ref().unwrap().expose(), b"359152");
assert_eq!(next.next_counter, Some(3));
assert!(
authentication
.copy_presented_otp_code("otp", &next.document_revision, 201, issued.counter)
.is_err()
);
authentication.manual_lock().unwrap();
assert!(
authentication
.copy_presented_otp_code("otp", &next.document_revision, 202, next.counter)
.is_err()
);
}
#[test]
fn hotp_commit_failure_and_overflow_leave_the_ciphertext_unchanged() {
use crate::repository::{EntryPath, SecretBytes};
for counter in ["0", "18446744073709551615"] {
let (_root, authentication, _) = otp_fixture(&format!(
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter={counter}"
));
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let path = EntryPath::parse("otp").unwrap();
let before = authentication.repository.read_entry(&path).unwrap();
let page = authentication.entry_page("otp", 0).unwrap();
if counter == "0" {
std::fs::write(
authentication
.repository
.root_path()
.join(".git/index.lock"),
b"locked",
)
.unwrap();
}
assert!(
authentication
.advance_presented_hotp("otp", page.page().revision(), true)
.is_err()
);
assert_eq!(authentication.repository.read_entry(&path).unwrap(), before);
}
}
#[test]
fn custom_period_and_stale_revision_are_storage_owned() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture(
"otpauth://totp/Custom:alice?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=6&period=60&issuer=Custom",
);
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
let code = authentication
.presented_otp_code("otp", page.page().revision(), 59, None)
.unwrap()
.unwrap();
assert_eq!(code.period, Some(60));
assert_eq!(code.valid_until, Some(60));
assert_eq!(code.remaining_seconds, Some(1));
assert_eq!(code.code.as_ref().unwrap().expose().len(), 6);
assert_eq!(code.grouped_code.as_ref().unwrap().expose().len(), 7);
let next = authentication
.presented_otp_code("otp", page.page().revision(), 60, None)
.unwrap()
.unwrap();
assert_eq!(next.valid_until, Some(120));
assert_eq!(next.remaining_seconds, Some(60));
assert!(
authentication
.presented_otp_code("otp", "stale", 60, None)
.is_err()
);
assert!(!authentication.state().unwrap().unlocked());
}
#[test] #[test]
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() { fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
use crate::authentication::SystemAuthenticationClock; use crate::authentication::SystemAuthenticationClock;
@@ -5,8 +5,22 @@ use super::{
}; };
use crate::{ use crate::{
mobile_private_storage::{PrivateStorage, exclusive_lock}, mobile_private_storage::{PrivateStorage, exclusive_lock},
repository::{DirectoryPath, Repository}, repository::{DirectoryPath, EntryPath, Repository},
}; };
pub(crate) fn entry_navigation_row(
repository: &Repository,
path: &EntryPath,
) -> Result<NavigationRow, MobilePasswordError> {
let row = super::search_row(path, &path.to_string());
let revision = repository
.navigation_revision(path.as_path(), true)
.map_err(MobilePasswordError::repository)?;
Ok(NavigationRow {
row,
revision: HEXLOWER.encode(&Sha256::digest(revision.as_bytes())),
})
}
use cap_std::{ambient_authority, fs::Dir}; use cap_std::{ambient_authority, fs::Dir};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
+84
View File
@@ -204,6 +204,90 @@ pub struct MobileTotpDetail {
watch: MobileWatchSnapshotStatus, watch: MobileWatchSnapshotStatus,
} }
pub struct MobileOtpCodePresentation {
pub document_revision: String,
pub kind: OtpKind,
pub issuer: Option<String>,
pub account: String,
pub code: Option<SecretBytes>,
pub grouped_code: Option<SecretBytes>,
pub period: Option<u64>,
pub valid_until: Option<u64>,
pub remaining_seconds: Option<u64>,
pub counter: Option<u64>,
pub next_counter: Option<u64>,
}
impl fmt::Debug for MobileOtpCodePresentation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MobileOtpCodePresentation([REDACTED])")
}
}
pub(crate) fn otp_code_presentation(
document: &EntryDocument,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<Option<MobileOtpCodePresentation>, OtpError> {
let Some(uri) = document.otp_uri().ok().flatten() else {
return Ok(None);
};
let (code, remaining_seconds, counter, valid_until) = match uri.kind() {
OtpKind::Totp => {
if consumed_counter.is_some() {
return Err(OtpError::UnexpectedCounter);
}
let period = uri.period().ok_or(OtpError::NotTotp)?;
let end = (unix_seconds / period)
.checked_add(1)
.and_then(|step| step.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?;
(
Some(uri.code_at(unix_seconds)?),
Some(end - unix_seconds),
None,
Some(end),
)
}
OtpKind::Hotp => {
let next = uri.counter().ok_or(OtpError::MissingCounter)?;
let code = if let Some(consumed) = consumed_counter {
if consumed != next {
return Err(OtpError::ConcurrentModification {
entry: document.path().clone(),
});
}
Some(uri.code_for_counter(consumed)?)
} else {
None
};
(code, None, consumed_counter, None)
}
};
let grouped_code = code.as_ref().map(|code| {
let bytes = code.expose();
let middle = bytes.len() / 2;
let mut grouped = Vec::with_capacity(bytes.len() + 1);
grouped.extend_from_slice(&bytes[..middle]);
grouped.push(b' ');
grouped.extend_from_slice(&bytes[middle..]);
SecretBytes::new(grouped)
});
Ok(Some(MobileOtpCodePresentation {
document_revision: crate::mobile_entry::document_revision(document),
kind: uri.kind(),
issuer: uri.issuer().map(str::to_owned),
account: uri.account().to_owned(),
code,
grouped_code,
period: uri.period(),
valid_until,
remaining_seconds,
counter,
next_counter: uri.counter().and_then(|counter| counter.checked_add(1)),
}))
}
impl MobileTotpDetail { impl MobileTotpDetail {
pub fn path(&self) -> &str { pub fn path(&self) -> &str {
&self.path &self.path
+32
View File
@@ -426,6 +426,21 @@ pub enum OtpCodeValidity {
} }
impl OtpCodeValidity { impl OtpCodeValidity {
pub fn is_current(self, unix_seconds: u64) -> bool {
match self {
Self::Timed {
valid_until,
period,
} => {
period > 0
&& valid_until
.checked_sub(period)
.is_some_and(|start| start <= unix_seconds && unix_seconds < valid_until)
}
Self::CounterBased { .. } => true,
}
}
pub fn valid_until(self) -> Option<u64> { pub fn valid_until(self) -> Option<u64> {
match self { match self {
Self::Timed { valid_until, .. } => Some(valid_until), Self::Timed { valid_until, .. } => Some(valid_until),
@@ -678,6 +693,23 @@ impl<'a> OtpService<'a> {
) )
} }
pub(crate) fn code_for_document(
&self,
document: &crate::document::EntryDocument,
provider: &mut impl SecretProvider,
committer: &mut impl EntryCommitter,
) -> Result<OtpCodeOutcome, OtpError> {
let (path, original, plaintext, range, uri) =
self.load_code_entry(&document.path().to_string(), provider)?;
if document.original_ciphertext() != Some(&original) {
return Err(OtpError::ConcurrentModification { entry: path });
}
if uri.kind() != OtpKind::Hotp {
return Err(OtpError::NotHotp);
}
self.finish_code(path, original, plaintext, range, uri, 0, None, committer)
}
/// Generate a code with storage-owned lazy Git selection. TOTP is /// Generate a code with storage-owned lazy Git selection. TOTP is
/// read-only and never opens Git; HOTP opens the innermost repository only /// read-only and never opens Git; HOTP opens the innermost repository only
/// after the token has been decrypted and identified as counter based. /// after the token has been decrypted and identified as counter based.
+49
View File
@@ -0,0 +1,49 @@
# Android one-time passwords
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).
The normal entry viewer and TOTP detail use the same code component and native
viewer controller. Decryption, creation and editing reconcile the catalog in
Rust. Only nonsecret row identities and revisions are restored after recreation.
OTP parameters, codes and authentication secrets are never persisted by Kotlin.
Rust computes SHA1/SHA256/SHA512 codes, six/eight digits, custom periods, absolute
validity boundaries, grouping and countdown. The composed code component owns a
STARTED-lifecycle coroutine that submits OS clock facts without touching the
authentication lease. Scrolling it out or leaving stops decryption/countdown ticks;
an expiry-only native window check still erases held codes when hidden. Rust validates each
returned window before publication, including delayed results and backward clock
changes. Lock, background, expiry, entry changes and stale revisions remove codes
and cancel presentation. Explicit copies use the existing sensitive clipboard
adapter and Rust timeout/ownership checks.
HOTP in the normal entry viewer requires Generate next HOTP code and a separate
counter confirmation. The existing pass-otp core increments and atomically commits
the encrypted counter before returning its code. Refresh and Copy use that same
committed counter without incrementing. Stale documents, open editors, failed Git
commits and overflow cannot consume a counter. The TOTP discovery catalog remains
specific to time-based entries. Android exposes no Watch sharing controls.
## Validation
Rust tests cover RFC 6238 algorithm vectors, six/eight digits, 30/60-second
boundaries, cache agreement, polling without lease renewal, malformed URI
diagnostics, stale revision revocation, HOTP confirmation, copy/refresh stability,
commit rollback and counter overflow. Android's NativeOtpPresentationTest uses
real encrypted stores with fake wall/lease clocks to verify catalog-only reads,
boundary transitions, tick lease expiry, clipboard handoff expiry, queued user
actions overtaking read-only observations, both HOTP generations and background
revocation. TotpScreenTest covers explicit unlock, grouped code/countdown/copy,
lock cleanup, HOTP confirm/cancel, tick cancellation on leaving and 300% text
without clipped digits. Computer use inspects the same Compose
components on an ARM64 emulator using disposable presentation fixtures.
All emulator installs preserve the retained app container. Production Keystore
and biometric enrollment are unchanged by this story; the native controller tests
provide deterministic OS capability callbacks. Supported release ABIs remain
arm64-v8a and armeabi-v7a; physical devices and x86_64 are outside this milestone.
+1 -1
View File
@@ -31,7 +31,7 @@ Every issue linked here belongs to milestone 23.
| 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 | | 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 | | 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; cold/warm/change/add/remove/corrupt/wrong-store/concurrent-change tests |
| TOTP rows, large detail code, countdown/expiry, copying (TotpDetailViewController, TotpCodeView) | mobile_totp::MobileTotpPage/Detail; otp; mobile_authentication::{totp_detail,copy_totp_code} | Compose code/detail; OS clock facts, Rust timing | #107/#100; RFC vectors, boundary countdown, stale callback/relock and clipboard | | 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 | | 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 | | 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 |
| Inactivity timeout, biometric enable/disable, Lock Now, appearance and Git commit name/email | mobile_authentication preference setters, git_identity; config | Compose Settings, BiometricPrompt, system/light/dark theme | #98/#104; persistent settings, validation, Keystore enrollment/recovery and lease revocation | | Inactivity timeout, biometric enable/disable, Lock Now, appearance and Git commit name/email | mobile_authentication preference setters, git_identity; config | Compose Settings, BiometricPrompt, system/light/dark theme | #98/#104; persistent settings, validation, Keystore enrollment/recovery and lease revocation |