diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt index ab12e86..c0978ea 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/AdaptiveShellTest.kt @@ -74,7 +74,7 @@ class AdaptiveShellTest { original.search, original.authentication, original.editor, - original.settings, + original.settings, original.totp, saved ) compose.activity.runOnUiThread { @@ -159,7 +159,7 @@ class AdaptiveShellTest { original.search, original.authentication, original.editor, - original.settings, + original.settings, original.totp, SavedStateHandle( saved.keys().associateWith { saved.get(it) @@ -199,7 +199,7 @@ class AdaptiveShellTest { original.search, original.authentication, original.editor, - original.settings, + original.settings, original.totp, saved ) compose.activity.runOnUiThread { @@ -242,7 +242,7 @@ class AdaptiveShellTest { original.search, original.authentication, original.editor, - original.settings, + original.settings, original.totp, SavedStateHandle(mapOf("shell.tab" to "Passwords")) ) compose.activity.runOnUiThread { diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt index 576461d..2357149 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/EntryViewerTest.kt @@ -35,11 +35,12 @@ import de.rfc1437.ironstorage.core.PasswordField import de.rfc1437.ironstorage.core.PasswordKind import de.rfc1437.ironstorage.core.PasswordMutation 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.PasswordRow import de.rfc1437.ironstorage.core.PasswordSection import de.rfc1437.ironstorage.core.PasswordState -import de.rfc1437.ironstorage.core.PasswordTotp import de.rfc1437.ironstorage.core.ShellIcon import de.rfc1437.ironstorage.features.PasswordScreen import de.rfc1437.ironstorage.platform.AndroidSecretClipboard @@ -70,7 +71,7 @@ class EntryViewerTest { compose.onNodeWithText("654321").assertDoesNotExist() compose.onNodeWithText("Unlock").performClick() compose.onNodeWithText("alice 日本語").performScrollTo().assertIsDisplayed() - compose.onNodeWithText("123456").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("123 456").performScrollTo().assertIsDisplayed() compose.onNodeWithContentDescription("Reveal PIN").performScrollTo() .performSemanticsAction(SemanticsActions.OnClick) { org.junit.Assert.assertTrue(it()) } assertEquals(2uL, repository.lastId) @@ -97,7 +98,7 @@ class EntryViewerTest { } compose.runOnIdle { repository.lease.value = AuthenticationState() } compose.onNode(hasSetTextAction()).assertDoesNotExist() - compose.onNodeWithText("123456").assertDoesNotExist() + compose.onNodeWithText("123 456").assertDoesNotExist() compose.onNodeWithText("First line\nSecond line 🌻").assertDoesNotExist() compose.onNodeWithText("Unlock").assertIsDisplayed() } @@ -174,7 +175,7 @@ class EntryViewerTest { org.junit.Assert.assertTrue(android.os.SystemClock.elapsedRealtime() - started < 600000) } } - private class Fixture : PasswordRepository { + internal class Fixture : PasswordRepository { private val row = PasswordRow( "row", @@ -275,12 +276,18 @@ class EntryViewerTest { override fun unlock(passphrase: String) { lease.value = AuthenticationState(unlocked = true) 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() { authentication.lock() 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) { lastId = id @@ -309,7 +316,9 @@ class EntryViewerTest { lastId = 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) { lastId = id state.value = diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt index 8b52e0f..4c1b2e1 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/PasswordScreenTest.kt @@ -209,7 +209,9 @@ class PasswordScreenTest { override fun reveal(id: ULong) = Unit override fun hide(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 dismissEdit() = Unit override fun saveEdit(value: String) = Unit diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/Tier2WindowTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/Tier2WindowTest.kt index 2a4c86e..a4e0b16 100644 --- a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/Tier2WindowTest.kt +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/Tier2WindowTest.kt @@ -54,7 +54,10 @@ class Tier2WindowTest { val model = ShellViewModel( AdaptiveShellTest.Fixture(), original.onboarding, original.passwords, 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) } lateinit var inputMode: InputModeManager diff --git a/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt new file mode 100644 index 0000000..c33dc7a --- /dev/null +++ b/android/app/src/androidTest/kotlin/de/rfc1437/ironstorage/TotpScreenTest.kt @@ -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() + + 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() + 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() + } +} diff --git a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt index 8cfdfea..2702913 100644 --- a/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt +++ b/android/app/src/main/kotlin/de/rfc1437/ironstorage/ShellModule.kt @@ -18,12 +18,14 @@ import de.rfc1437.ironstorage.core.NativePasswordRepository import de.rfc1437.ironstorage.core.NativePrivateSession import de.rfc1437.ironstorage.core.NativeSearchRepository import de.rfc1437.ironstorage.core.NativeSettingsRepository +import de.rfc1437.ironstorage.core.NativeTotpRepository import de.rfc1437.ironstorage.core.OnboardingRepository import de.rfc1437.ironstorage.core.PasswordRepository import de.rfc1437.ironstorage.core.SearchRepository import de.rfc1437.ironstorage.core.SettingsPlatform import de.rfc1437.ironstorage.core.SettingsRepository import de.rfc1437.ironstorage.core.ShellRepository +import de.rfc1437.ironstorage.core.TotpRepository import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter import de.rfc1437.ironstorage.platform.AndroidSecretClipboard import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform @@ -108,6 +110,25 @@ object ShellModule { 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 @Singleton fun nativeAuthentication(session: NativePrivateSession): NativeAuthenticationRepository = diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt index ead6390..f32096f 100644 --- a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeEntryViewerTest.kt @@ -24,9 +24,14 @@ import org.junit.Test /** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */ 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 { val context = InstrumentationRegistry.getInstrumentation().targetContext - val name = "viewer-${UUID.randomUUID()}" + val name = rootName val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map { File(it, name).apply { mkdirs() }.canonicalPath } @@ -168,8 +173,8 @@ class NativeEntryViewerTest { assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable }) assertEquals(2, fields().count { it.label == "Custom" }) assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline }) - assertNotNull(repository.state.value.totp) - assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL) + assertNotNull(repository.state.value.otp) + assertTrue(repository.state.value.otp!!.remainingSeconds!! in 1uL..30uL) for (event in listOf( AuthenticationLifecycle.Background, AuthenticationLifecycle.DeviceLocked, @@ -184,7 +189,7 @@ class NativeEntryViewerTest { authentication.lifecycle(event) assertNull(repository.state.value.sections) assertNull(repository.state.value.edit) - assertNull(repository.state.value.totp) + assertNull(repository.state.value.otp) assertNull(clipboard.value) assertFalse(authentication.state.value.unlocked) } @@ -227,7 +232,7 @@ class NativeEntryViewerTest { request { repository.lock() } assertNull(repository.state.value.sections) assertNull(repository.state.value.edit) - assertNull(repository.state.value.totp) + assertNull(repository.state.value.otp) withTimeout(10000) { while (clipboard.value != null) delay(20) } request { repository.unlock(password) } authentication.authenticated { @@ -243,7 +248,7 @@ class NativeEntryViewerTest { authentication.authenticated { it.setAuthenticationTimeout(2u) } withTimeout(10000) { authentication.state.first { !it.unlocked } } withTimeout(10000) { repository.state.first { it.sections == null } } - assertNull(repository.state.value.totp) + assertNull(repository.state.value.otp) assertFalse(authentication.state.value.unlocked) authentication.authenticated { it.setAuthenticationTimeout(300u) } structuredEditorCreatesMutatesSavesAndRecoversEncryptedDrafts( @@ -287,6 +292,7 @@ class NativeEntryViewerTest { } finally { withContext(Dispatchers.Main.immediate) { repository.lock() } scope.cancel() + roots.forEach { assertTrue(File(it).deleteRecursively()) } } } @@ -593,7 +599,7 @@ class NativeEntryViewerTest { request { settings.setTimeout("300") } } - private class MemoryClipboard : SecretClipboard { + internal class MemoryClipboard : SecretClipboard { override fun beginCopy(): SecretClipboard = this @Volatile var value: ByteArray? = null @@ -605,7 +611,7 @@ class NativeEntryViewerTest { value = null } } - private class MemoryKeystore : AndroidKeystore { + internal class MemoryKeystore : AndroidKeystore { private val keys = mutableSetOf() val keyCount get() = keys.size var invalidated = false diff --git a/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeOtpPresentationTest.kt b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeOtpPresentationTest.kt new file mode 100644 index 0000000..1605cb7 --- /dev/null +++ b/android/core/src/androidTest/kotlin/de/rfc1437/ironstorage/core/NativeOtpPresentationTest.kt @@ -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() } + } + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt index 156c71a..ac7c491 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeAuthenticationRepository.kt @@ -26,7 +26,10 @@ import kotlinx.coroutines.withContext class NativeAuthenticationRepository( private val session: NativePrivateSession, 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 { internal fun text(id: Int) = session.text(id) @@ -38,9 +41,6 @@ class NativeAuthenticationRepository( private var revocation: Job? = null @Volatile private var current: MobileAuthentication? = null - private val clock = object : MobileClock { - override fun monotonicMilliseconds(): ULong = SystemClock.elapsedRealtime().toULong() - } init { presentation.onWindowFocusLost = { lifecycle(AuthenticationLifecycle.Background) } scope.launch { @@ -69,9 +69,17 @@ class NativeAuthenticationRepository( } internal suspend fun authenticated(block: (MobileAuthentication) -> T): T = + access(true, block) + + internal suspend fun observe(block: (MobileAuthentication) -> T): T? = withContext(kotlinx.coroutines.Dispatchers.Main.immediate) { - val ticket = sequence.incrementAndGet() - mutable.value = mutable.value.copy(busy = true, failure = null) + if (mutable.value.busy) null else access(false, block) + } + + private suspend fun 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 { revocation?.join() if (ticket != sequence.get()) throw cancelled(session) @@ -81,7 +89,7 @@ class NativeAuthenticationRepository( if (ticket != sequence.get()) throw cancelled(session) val result = block(authentication) if (ticket != sequence.get()) { - authentication.manualLock() + if (showBusy) authentication.manualLock() throw cancelled(session) } result @@ -114,7 +122,11 @@ class NativeAuthenticationRepository( if (ticket == sequence.get()) unavailable() throw error } finally { - if (ticket == sequence.get()) mutable.value = mutable.value.copy(busy = false) + if (showBusy && + ticket == sequence.get() + ) { + mutable.value = mutable.value.copy(busy = false) + } } } } diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt index 482521e..9349841 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativePasswordRepository.kt @@ -9,6 +9,8 @@ import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction 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.MobilePasswordNavigation 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.clearMobileEntryCopy import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing +import de.rfc1437.ironstorage.bridge.generated.mobileOtpWindowCurrent import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -24,6 +27,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow 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.runBlocking import kotlinx.coroutines.sync.Mutex @@ -35,7 +41,8 @@ class NativePasswordRepository( private val scope: CoroutineScope, private val authentication: NativeAuthenticationRepository, private val io: CoroutineDispatcher, - private val clipboard: SecretClipboard + private val clipboard: SecretClipboard, + private val unixSeconds: () -> ULong = ::unixSeconds ) : PasswordRepository { private val mutable = MutableStateFlow(PasswordState()) override val state: StateFlow = mutable @@ -51,32 +58,77 @@ class NativePasswordRepository( } } scope.coroutineContext[kotlinx.coroutines.Job]?.invokeOnCompletion { registration.close() } + // Hidden codes still expire; only the composed UI requests decryption/countdown refresh. scope.launch { - while (true) { - delay(1000) - val current = mutable.value - if (current.totp != null && !current.loading && - authentication.state.value.unlocked && - !authentication.state.value.busy - ) { - content(false) { current -> - val detail = authentication.authenticated { auth -> - auth.refreshEntryPresentation( - requireNotNull(current.selected).path, - requireNotNull(current.documentRevision), - unixSeconds() - ).totp - } - current.copy( - totp = detail?.let { - PasswordTotp(it.code, it.account, it.remainingSeconds) - } - ) + mutable.map { state -> + state.otp?.takeIf { it.kind == PasswordOtpKind.Totp && it.code != null }?.validUntil + }.distinctUntilChanged().collectLatest { end -> + if (end != null) { + while (true) { + delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L)) + expireOtp() } } } } } + private suspend fun expireOtp() { + val current = mutable.value + val code = current.otp ?: return + if (code.validUntil != null && code.period != null && code.code != null) { + val valid = withContext(io) { + mobileOtpWindowCurrent(code.validUntil, code.period, unixSeconds()) + } + if (!valid && + mutable.value.otp === code + ) { + mutable.value = current.copy(otp = code.masked()) + } + } + } + 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), + unixSeconds(), + current.otp?.counter + ) + } + 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?) = navigate(path, selected, revision, false) override fun openSearch(id: String, revision: String) { @@ -122,16 +174,18 @@ class NativePasswordRepository( false ).toFacade() if (verified.selected == null) return@run verified - val page = authentication.authenticated { auth -> - auth.navigationEntry( + val (page, code) = authentication.authenticated { auth -> + val page = auth.navigationEntry( current.path, selected.id, current.revision, 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() { @@ -216,7 +270,11 @@ class NativePasswordRepository( 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 refreshed = session.storage().passwordNavigation( current.path, @@ -224,27 +282,35 @@ class NativePasswordRepository( folder.revision, false ).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 -> auth.copyPresentedEntryField( current.entryPath(), id, requireNotNull(current.documentRevision) - ) + ) to null } - override fun copyTotp() = copyValue { auth, current -> - auth.copyPresentedTotpCode( + override fun copyOtp() = copyValue { auth, current -> + val now = unixSeconds() + val code = auth.presentedOtpCode( current.entryPath(), requireNotNull(current.documentRevision), - unixSeconds() + now, + current.otp?.counter ) + auth.copyPresentedOtpCode( + current.entryPath(), + requireNotNull(current.documentRevision), + now, + current.otp?.counter + ) to code } private fun copyValue( get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) -> - de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy + Pair ) = content { current -> - val copy = authentication.authenticated { get(it, current) } + val (copy, code) = authentication.authenticated { get(it, current) } val ticket = sequence.get() val owned = clipboard.beginCopy() val written = CompletableDeferred() @@ -258,6 +324,15 @@ class NativePasswordRepository( if (!authentication.state.value.unlocked) { throw MobilePlatformFfiException.Cancelled() } + if (code?.kind == MobileOtpCodeKind.TOTP && + !mobileOtpWindowCurrent( + requireNotNull(code.validUntil), + requireNotNull(code.period), + unixSeconds() + ) + ) { + throw MobilePlatformFfiException.Cancelled() + } clipboardAccess { owned.write(value) } written.complete(true) Unit @@ -372,19 +447,23 @@ class NativePasswordRepository( return } 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() mutable.value = (if (clear) mutable.value.cleared() else mutable.value).copy( - loading = true, + loading = showLoading, error = null ) scope.launch { mutex.withLock { if (sequence.get() != ticket) return@withLock - val result = try { + var result = try { withContext(io) { action() } } catch (error: MobilePasswordFfiException.Failed) { mutable.value.copy(error = error.detail, selected = null) @@ -402,6 +481,17 @@ class NativePasswordRepository( 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) { mutable.value = (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 PasswordState.cleared() = copy( sections = null, - totp = null, + otp = null, documentRevision = null, edit = null, mutation = null, notice = null ) -private fun PasswordState.present(value: MobileEntryPresentation) = copy( +private fun PasswordState.present( + value: MobileEntryPresentation, + code: MobileOtpCodePresentation? +) = copy( documentRevision = value.page.revision, sections = value.page.sections.map { section -> 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, edit = 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) { PasswordMutation.Move -> MobileMutationAction.MOVE PasswordMutation.Copy -> MobileMutationAction.COPY diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt new file mode 100644 index 0000000..50de608 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/NativeTotpRepository.kt @@ -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 = 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) + } + } + } + } +} diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt index 7cc2d31..7f62acd 100644 --- a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/PasswordRepository.kt @@ -28,8 +28,20 @@ data class PasswordField( override fun toString() = "PasswordField(id=$id, [REDACTED])" } data class PasswordSection(val title: String, val fields: List) -data class PasswordTotp(val code: String, val account: String, val remainingSeconds: ULong) { - override fun toString() = "PasswordTotp([REDACTED])" +enum class PasswordOtpKind { Totp, Hotp } +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( val id: ULong, @@ -65,7 +77,7 @@ data class PasswordState( val stale: Boolean = false, val error: String? = null, val sections: List? = null, - val totp: PasswordTotp? = null, + val otp: PasswordOtp? = null, val documentRevision: String? = null, val notice: String? = null, val edit: PasswordEdit? = null, @@ -85,7 +97,10 @@ interface PasswordRepository { fun reveal(id: ULong) fun hide(id: ULong) fun copy(id: ULong) - fun copyTotp() + fun copyOtp() + fun tickOtp() = Unit + fun refreshOtp() + fun advanceHotp() fun edit(id: ULong) fun dismissEdit() fun saveEdit(value: String) diff --git a/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt new file mode 100644 index 0000000..2452e38 --- /dev/null +++ b/android/core/src/main/kotlin/de/rfc1437/ironstorage/core/TotpRepository.kt @@ -0,0 +1,24 @@ +package de.rfc1437.ironstorage.core + +import kotlinx.coroutines.flow.StateFlow + +data class TotpState( + val rows: List = 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 + 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() +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt index 40235dc..dc341bf 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/EntryViewer.kt @@ -99,23 +99,16 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito } } state.notice?.let { item { Text(it) } } - state.totp?.let { totp -> + state.otp?.let { code -> item { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.entry_totp), Modifier.semantics { heading() }) - Text(totp.account) - Text(totp.code, style = MaterialTheme.typography.headlineMedium) - Text( - pluralStringResource( - R.plurals.entry_countdown, - totp.remainingSeconds.toInt(), - totp.remainingSeconds.toLong() - ) - ) - TextButton(enabled = !state.loading, onClick = repository::copyTotp) { - Text(stringResource(R.string.entry_copy_code)) - } - } + OtpCodeView( + code, + state.loading, + repository::copyOtp, + repository::refreshOtp, + repository::advanceHotp, + repository::tickOtp + ) } } state.sections?.forEach { section -> diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OtpCodeView.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OtpCodeView.kt new file mode 100644 index 0000000..416d9dc --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/OtpCodeView.kt @@ -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)) } + } + ) + } +} diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt index 3f18697..52ce0eb 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellScreen.kt @@ -200,6 +200,10 @@ private fun ShellDestination( Modifier.fillMaxSize(), leaveOnDispose = false ) + ready && route == ShellRoute.Totp -> { + LaunchedEffect(viewModel) { viewModel.openTotp() } + TotpScreen(viewModel.totp, viewModel.authentication) + } ready && route == ShellRoute.Home -> HomeScreen( viewModel.home, Modifier.fillMaxSize(), diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt index 3d7b6fd..a054eaa 100644 --- a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/ShellViewModel.kt @@ -15,6 +15,7 @@ import de.rfc1437.ironstorage.core.SettingsRepository import de.rfc1437.ironstorage.core.ShellRepository import de.rfc1437.ironstorage.core.ShellState import de.rfc1437.ironstorage.core.ShellTab +import de.rfc1437.ironstorage.core.TotpRepository import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -30,6 +31,7 @@ class ShellViewModel @Inject constructor( val authentication: AuthenticationRepository, val editor: EditorRepository, val settings: SettingsRepository, + val totp: TotpRepository, private val saved: SavedStateHandle ) : ViewModel() { 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("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 fun openSearch(row: PasswordRow) { @@ -116,7 +149,7 @@ class ShellViewModel @Inject constructor( settings.cancel() if (authentication.state.value.busy) authentication.cancel() } - ShellTab.Totp -> Unit + ShellTab.Totp -> totp.leave() } } override fun onCleared() { diff --git a/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt new file mode 100644 index 0000000..db81002 --- /dev/null +++ b/android/features/src/main/kotlin/de/rfc1437/ironstorage/features/TotpScreen.kt @@ -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) + } + ) + } +} diff --git a/android/features/src/main/res/values/strings.xml b/android/features/src/main/res/values/strings.xml index 53a6055..498050f 100644 --- a/android/features/src/main/res/values/strings.xml +++ b/android/features/src/main/res/values/strings.xml @@ -235,4 +235,22 @@ I checked this fingerprint and authorize displaying its private QR codes. Automatically advance frames Prepare the scanned key + HOTP + Waiting for a current code. + Generate a code to consume the next HOTP counter. + Refresh code + Generate next HOTP code + This consumes counter %1$s and saves the incremented counter in the encrypted entry. Continue? + Generated for counter %1$s + + %1$s second remaining + %1$s seconds remaining + + Refresh TOTP catalog + Refresh the catalog after unlocking to find TOTP entries. + No TOTP entries found. + Choose a TOTP entry. + This entry no longer has a TOTP code. + This entry changed. Refresh the catalog and select it again. + Back to TOTP entries diff --git a/crates/mobile-bridge/src/lib.rs b/crates/mobile-bridge/src/lib.rs index 10292eb..b17a6e5 100644 --- a/crates/mobile-bridge/src/lib.rs +++ b/crates/mobile-bridge/src/lib.rs @@ -1247,6 +1247,61 @@ pub struct MobileTotpDetail { 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, + pub account: String, + pub code: Option, + pub grouped_code: Option, + pub period: Option, + pub valid_until: Option, + pub remaining_seconds: Option, + pub counter: Option, + pub next_counter: Option, +} + +impl From 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 for MobileTotpDetail { fn from(detail: StorageTotpDetail) -> Self { Self { @@ -2010,6 +2065,44 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn presented_otp_code( + &self, + path: String, + revision: String, + unix_seconds: u64, + consumed_counter: Option, + ) -> Result, 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, + ) -> Result { + 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 { + self.authentication + .advance_presented_hotp(&path, &revision, confirmed) + .map(Into::into) + .map_err(Into::into) + } + pub fn prepare_presented_entry_mutation( &self, path: String, @@ -2146,6 +2239,16 @@ impl MobileAuthentication { .map_err(Into::into) } + pub fn cached_totp_navigation( + &self, + ) -> Result>, 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( &self, query: String, diff --git a/crates/storage/src/mobile_authentication.rs b/crates/storage/src/mobile_authentication.rs index 289215f..c70820e 100644 --- a/crates/storage/src/mobile_authentication.rs +++ b/crates/storage/src/mobile_authentication.rs @@ -30,13 +30,14 @@ use crate::{ MobileMutationRequest, MobileMutationService, }, mobile_totp::{ - MobileTotpDetail, MobileTotpError, MobileTotpOperation, MobileTotpPage, MobileTotpService, + MobileOtpCodePresentation, MobileTotpDetail, MobileTotpError, MobileTotpOperation, + MobileTotpPage, MobileTotpService, otp_code_presentation, }, mobile_watch::{ MobileWatchSnapshotState, MobileWatchSnapshotStatus, WatchSnapshotError, WatchSnapshotSender, WatchSnapshotTransfer, }, - otp::OtpError, + otp::{OtpError, OtpKind, OtpService}, recipient::RecipientPolicyManager, repository::{ 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, + ) -> Result, 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, + ) -> Result { + 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 { + 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( &self, path: &str, @@ -1283,6 +1399,29 @@ impl MobileAuthentication { self.search_cached_totp_page("") } + pub fn cached_totp_navigation( + &self, + ) -> Result< + Option>, + 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( &self, query: &str, @@ -2452,6 +2591,310 @@ mod tests { 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, + std::sync::Arc, + ) { + 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] fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() { use crate::authentication::SystemAuthenticationClock; diff --git a/crates/storage/src/mobile_passwords/navigation.rs b/crates/storage/src/mobile_passwords/navigation.rs index b53d499..aa4d6f6 100644 --- a/crates/storage/src/mobile_passwords/navigation.rs +++ b/crates/storage/src/mobile_passwords/navigation.rs @@ -5,8 +5,22 @@ use super::{ }; use crate::{ mobile_private_storage::{PrivateStorage, exclusive_lock}, - repository::{DirectoryPath, Repository}, + repository::{DirectoryPath, EntryPath, Repository}, }; + +pub(crate) fn entry_navigation_row( + repository: &Repository, + path: &EntryPath, +) -> Result { + 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 data_encoding::HEXLOWER; use sha2::{Digest, Sha256}; diff --git a/crates/storage/src/mobile_totp.rs b/crates/storage/src/mobile_totp.rs index 6d02674..7df27a0 100644 --- a/crates/storage/src/mobile_totp.rs +++ b/crates/storage/src/mobile_totp.rs @@ -204,6 +204,90 @@ pub struct MobileTotpDetail { watch: MobileWatchSnapshotStatus, } +pub struct MobileOtpCodePresentation { + pub document_revision: String, + pub kind: OtpKind, + pub issuer: Option, + pub account: String, + pub code: Option, + pub grouped_code: Option, + pub period: Option, + pub valid_until: Option, + pub remaining_seconds: Option, + pub counter: Option, + pub next_counter: Option, +} + +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, +) -> Result, 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 { pub fn path(&self) -> &str { &self.path diff --git a/crates/storage/src/otp.rs b/crates/storage/src/otp.rs index 3330d55..33b4daf 100644 --- a/crates/storage/src/otp.rs +++ b/crates/storage/src/otp.rs @@ -426,6 +426,21 @@ pub enum 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 { match self { 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 { + 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 /// read-only and never opens Git; HOTP opens the innermost repository only /// after the token has been decrypted and identified as counter based. diff --git a/docs/android/otp.md b/docs/android/otp.md new file mode 100644 index 0000000..a26d006 --- /dev/null +++ b/docs/android/otp.md @@ -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. diff --git a/docs/android/parity.md b/docs/android/parity.md index 6ad6dc7..85879a9 100644 --- a/docs/android/parity.md +++ b/docs/android/parity.md @@ -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 | | 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 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 | | 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 |