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

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

No files matched your search

@@ -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<Any>(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 {
@@ -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 =
@@ -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
@@ -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
@@ -0,0 +1,209 @@
package de.rfc1437.ironstorage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.Density
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import de.rfc1437.ironstorage.core.PasswordRepository
import de.rfc1437.ironstorage.core.TotpRepository
import de.rfc1437.ironstorage.core.TotpState
import de.rfc1437.ironstorage.features.TotpScreen
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalTestApi::class)
class TotpScreenTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private fun display(fixture: Fixture, fontScale: Float = 1f) {
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, fontScale)
) {
MaterialTheme { TotpScreen(fixture, fixture.base.authentication) }
}
}
}
}
@Test fun catalogRequiresExplicitUnlockAndShowsSharedLargeCode() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("otp-code").assertTextEquals("123 456")
compose.onNodeWithText("17 seconds remaining").assertIsDisplayed()
compose.onNodeWithText("Copy code").performClick()
assertEquals(1, fixture.copies)
compose.onNodeWithText("Refresh code").performClick()
assertEquals(1, fixture.refreshes)
compose.onNodeWithText("Lock").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Unlock").assertIsDisplayed()
}
@Test fun ticksStopWhenTheCodeComponentLeaves() {
val fixture = Fixture()
display(fixture)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.waitUntil(5000) { fixture.ticks > 0 }
compose.onNodeWithText("Back to TOTP entries").performClick()
val stopped = fixture.ticks
Thread.sleep(1200)
compose.waitForIdle()
assertEquals(stopped, fixture.ticks)
}
@Test fun largeTextShowsEveryDigitWithoutClipping() {
val fixture = Fixture()
display(fixture, 3f)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
val results = mutableListOf<TextLayoutResult>()
compose.onNodeWithTag(
"otp-code"
).performSemanticsAction(SemanticsActions.GetTextLayoutResult) {
org.junit.Assert.assertTrue(it(results))
}
org.junit.Assert.assertTrue(results.isNotEmpty())
org.junit.Assert.assertFalse(results.single().hasVisualOverflow)
}
@Test fun hotpGenerationRequiresConfirmationAndCancelDoesNotConsume() {
val fixture = Fixture(hotp = true)
display(fixture)
compose.onNodeWithText("Viewer").performClick()
compose.onNodeWithText("Unlock").performClick()
compose.onNodeWithTag("otp-code").assertDoesNotExist()
compose.onNodeWithText("Generate next HOTP code").performClick()
compose.onNodeWithText("Cancel").performClick()
assertEquals(0, fixture.generations)
compose.onNodeWithText("Generate next HOTP code").performClick()
compose.onAllNodesWithText("Generate next HOTP code").onLast().performClick()
assertEquals(1, fixture.generations)
compose.onNodeWithTag("otp-code").assertTextEquals("287 082")
compose.onNodeWithText("Copy code").performClick()
compose.onNodeWithText("Refresh code").performClick()
assertEquals(1, fixture.generations)
compose.runOnIdle { fixture.base.authentication.lock() }
compose.onNodeWithTag("otp-code").assertDoesNotExist()
}
@Test fun inspectDisposableTotpWithComputerUse() {
val arguments = InstrumentationRegistry.getArguments()
assumeTrue(arguments.getString("otpInspect") == "true")
val fixture = Fixture(hotp = arguments.getString("otpKind") == "hotp")
val done = CountDownLatch(1)
compose.activity.runOnUiThread {
compose.activity.setProtectedTestContent {
MaterialTheme {
Column(Modifier.fillMaxSize()) {
TextButton(onClick = { done.countDown() }) { Text("Finish OTP inspection") }
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides
Density(
density.density,
arguments.getString("otpFont")?.toFloat() ?: 1f
)
) {
TotpScreen(fixture, fixture.base.authentication)
}
}
}
}
}
compose.waitForIdle()
while (!done.await(100, TimeUnit.MILLISECONDS)) {
compose.mainClock.advanceTimeBy(100)
compose.waitForIdle()
}
}
private class Fixture(private val hotp: Boolean = false) : TotpRepository {
val base = EntryViewerTest.Fixture()
var copies = 0
var refreshes = 0
var generations = 0
var ticks = 0
override val state = MutableStateFlow(
TotpState(
rows = base.state.value.rows,
hasCatalog = true
)
)
override val viewer = object : PasswordRepository by base {
override fun unlock(passphrase: String) {
base.unlock(passphrase)
if (hotp) {
base.state.value = base.state.value.copy(
otp = PasswordOtp(
PasswordOtpKind.Hotp, null, null, "RFC", "alice",
null, null, null, null, 1u
)
)
}
}
override fun copyOtp() {
copies += 1
}
override fun tickOtp() {
ticks += 1
}
override fun refreshOtp() {
refreshes += 1
}
override fun advanceHotp() {
generations += 1
base.state.value = base.state.value.copy(
otp = PasswordOtp(
PasswordOtpKind.Hotp, "287082", "287 082", "RFC", "alice",
null, null, null, 1u, 2u
)
)
}
}
override fun enter(selected: String?, revision: String?) = Unit
override fun reload() = Unit
override fun open(id: String, revision: String) {
state.value =
state.value.copy(detail = true)
}
override fun back() {
viewer.leave()
state.value = state.value.copy(detail = false)
}
override fun discover(passphrase: String) = Unit
override fun cancel() = Unit
override fun leave() = viewer.leave()
}
}
@@ -18,12 +18,14 @@ import de.rfc1437.ironstorage.core.NativePasswordRepository
import de.rfc1437.ironstorage.core.NativePrivateSession
import de.rfc1437.ironstorage.core.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 =
@@ -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<String>()
val keyCount get() = keys.size
var invalidated = false
@@ -0,0 +1,187 @@
package de.rfc1437.ironstorage.core
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.bridge.generated.MobileClock
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeOtpPresentationTest {
@Test fun catalogCodesBoundariesHotpAndLifecycleUseRust(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val name = NativeEntryViewerTest.rootName
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
File(it, name).apply { mkdirs() }.canonicalPath
}
val monotonic = AtomicLong(0)
val wall = AtomicLong(59)
val session = NativePrivateSession(context, {
PrivateDirectories(roots[0], roots[1], roots[2])
}, NativeEntryViewerTest.MemoryKeystore())
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val memoryClipboard = NativeEntryViewerTest.MemoryClipboard()
var expireCopy = false
val clipboard = object : SecretClipboard by memoryClipboard {
override fun beginCopy(): SecretClipboard {
if (expireCopy) wall.set(60)
return memoryClipboard
}
}
val authentication =
NativeAuthenticationRepository(
session,
scope,
Dispatchers.IO,
object : MobileClock {
override fun monotonicMilliseconds() = monotonic.get().toULong()
}
)
val viewer = NativePasswordRepository(
session,
scope,
authentication,
Dispatchers.IO,
clipboard,
{ wall.get().toULong() }
)
val catalog = NativeTotpRepository(session, authentication, viewer, scope)
val passphrase = "public-otp-fixture"
suspend fun view(action: () -> Unit): PasswordState {
withContext(Dispatchers.Main.immediate) { action() }
return withTimeout(30000) { viewer.state.first { !it.loading } }
}
try {
withContext(Dispatchers.IO) {
session.initialize()
val setup = session.storage().androidOnboarding(session.backend())
val key = requireNotNull(setup.generate("OTP fixture", passphrase).selectedKey)
setup.setupLocal(key, passphrase)
}
authentication.authenticated { auth ->
for ((entry, uri) in listOf(
"timed" to
"otpauth://totp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8&issuer=RFC",
"counter" to
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC"
)) {
val draft = auth.beginCreateEntry("", entry, passphrase)
val page = auth.addEntryEditorField(
draft.id,
MobileEntryEditorFieldKind.OTP_URI,
null,
uri
)
auth.saveEntryEditor(
draft.id,
page.fields.map {
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
}
)
}
auth.setAuthenticationTimeout(2u)
}
withContext(Dispatchers.Main.immediate) {
authentication.lock()
catalog.enter(null, null)
}
val cached = withTimeout(30000) { catalog.state.first { !it.loading } }
assertNull(cached.error)
assertTrue(cached.hasCatalog)
assertEquals(listOf("timed"), cached.rows.map { it.title })
assertFalse(authentication.state.value.unlocked)
assertNull(viewer.state.value.otp)
val row = cached.rows.single()
view { catalog.open(row.id, row.revision) }
assertNull(viewer.state.value.otp)
val opened = view { viewer.unlock(passphrase) }
assertNull(opened.error)
assertEquals("9428 7082", requireNotNull(opened.otp).groupedCode)
assertEquals(1uL, requireNotNull(opened.otp).remainingSeconds)
val entered = CountDownLatch(1)
val release = CountDownLatch(1)
val observation = scope.async {
runCatching {
authentication.observe { auth ->
val value = auth.state()
entered.countDown()
assertTrue(release.await(10, TimeUnit.SECONDS))
value
}
}
}
withContext(Dispatchers.IO) { assertTrue(entered.await(10, TimeUnit.SECONDS)) }
val userAction = scope.async { authentication.authenticated { it.state() } }
withTimeout(10000) { authentication.state.first { it.busy } }
release.countDown()
assertTrue(observation.await().isFailure)
assertTrue(userAction.await().unlocked)
assertTrue(authentication.state.value.unlocked)
view { viewer.copyOtp() }
withTimeout(10000) { while (memoryClipboard.value == null) delay(20) }
assertEquals("94287082", memoryClipboard.value!!.toString(Charsets.UTF_8))
withContext(Dispatchers.Main.immediate) { memoryClipboard.clear() }
expireCopy = true
val rejectedCopy = view { viewer.copyOtp() }
assertTrue(rejectedCopy.error != null)
assertNull(memoryClipboard.value)
expireCopy = false
wall.set(60)
monotonic.set(1000)
withContext(Dispatchers.Main.immediate) { viewer.tickOtp() }
withTimeout(30000) { viewer.state.first { it.otp?.remainingSeconds == 30uL } }
assertEquals(30uL, viewer.state.value.otp!!.remainingSeconds)
assertNotEquals("94287082", viewer.state.value.otp!!.code)
monotonic.set(2000)
withTimeout(10000) {
authentication.state.first { !it.unlocked }
}
assertFalse(authentication.state.value.unlocked)
assertNull(memoryClipboard.value)
view { viewer.restore("", null, null) }
val counter = viewer.state.value.rows.single { it.title == "counter" }
view { viewer.activate(counter.id, viewer.state.value.revision) }
val pending = view { viewer.unlock(passphrase) }
assertEquals(PasswordOtpKind.Hotp, requireNotNull(pending.otp).kind)
assertNull(requireNotNull(pending.otp).code)
assertEquals(1uL, requireNotNull(pending.otp).nextCounter)
val generated = view { viewer.advanceHotp() }
assertNull(generated.error)
assertEquals("287 082", requireNotNull(generated.otp).groupedCode)
assertEquals(1uL, requireNotNull(generated.otp).counter)
view { viewer.refreshOtp() }
view { viewer.copyOtp() }
assertEquals(1uL, viewer.state.value.otp!!.counter)
val next = view { viewer.advanceHotp() }
assertEquals("359152", requireNotNull(next.otp).code)
assertEquals(2uL, requireNotNull(next.otp).counter)
withContext(Dispatchers.Main.immediate) {
authentication.lifecycle(AuthenticationLifecycle.Background)
assertNull(viewer.state.value.otp)
assertNull(viewer.state.value.sections)
assertNull(memoryClipboard.value)
}
} finally {
withContext(Dispatchers.Main.immediate) { scope.cancel() }
roots.forEach { File(it).deleteRecursively() }
}
}
}
@@ -26,7 +26,10 @@ import kotlinx.coroutines.withContext
class NativeAuthenticationRepository(
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 <T> authenticated(block: (MobileAuthentication) -> T): T =
access(true, block)
internal suspend fun <T> 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 <T> access(showBusy: Boolean, block: (MobileAuthentication) -> T): T =
withContext(kotlinx.coroutines.Dispatchers.Main.immediate) {
val ticket = if (showBusy) sequence.incrementAndGet() else sequence.get()
if (showBusy) mutable.value = mutable.value.copy(busy = true, failure = null)
operation.withLock {
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)
}
}
}
}
@@ -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<PasswordState> = 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<de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy, MobileOtpCodePresentation?>
) = 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<Boolean>()
@@ -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
@@ -0,0 +1,131 @@
package de.rfc1437.ironstorage.core
import de.rfc1437.ironstorage.bridge.NativeBridgeException
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class NativeTotpRepository(
private val session: NativePrivateSession,
private val authentication: NativeAuthenticationRepository,
override val viewer: PasswordRepository,
private val scope: CoroutineScope
) : TotpRepository {
private val mutable = MutableStateFlow(TotpState())
override val state: StateFlow<TotpState> = mutable
private val serial = Mutex()
@Volatile private var epoch = 0L
@Volatile private var discovery: MobileTotpOperation? = null
init {
val cleanup = authentication.presentation.register(::cancel)
scope.coroutineContext[Job]?.invokeOnCompletion { cleanup.close() }
}
override fun enter(selected: String?, revision: String?) = load(selected, revision)
override fun reload() = load(null, null)
private fun load(selected: String?, revision: String?) = run { ticket ->
val rows = authentication.authenticated { it.cachedTotpNavigation() }
if (ticket == epoch) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
)
if (selected != null && revision != null) open(selected, revision)
}
}
override fun open(id: String, revision: String) {
if (mutable.value.rows.none { it.id == id && it.revision == revision }) return
mutable.value = mutable.value.copy(detail = true, error = null)
viewer.openSearch(id, revision)
}
override fun back() {
viewer.leave()
mutable.value = mutable.value.copy(detail = false)
reload()
}
override fun discover(passphrase: String) = run { ticket ->
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
discovery = active
if (ticket != epoch) active.cancel()
try {
val rows = authentication.authenticated {
it.unlockTotp(passphrase.ifEmpty { null })
it.totpPage(active)
it.cachedTotpNavigation()
}
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(
rows = rows.orEmpty().map { it.toFacade() },
hasCatalog =
rows != null
)
}
} finally {
discovery = null
active.close()
}
}
override fun cancel() {
epoch += 1
discovery?.cancel()
mutable.value = mutable.value.copy(loading = false)
}
override fun leave() {
cancel()
viewer.leave()
}
private fun run(action: suspend (Long) -> Unit) {
if (mutable.value.loading) return
val ticket = epoch
mutable.value = mutable.value.copy(loading = true, error = null)
scope.launch {
serial.withLock {
if (ticket != epoch) return@withLock
try {
action(ticket)
} catch (error: MobileAuthenticationFfiException.Failed) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = error.title + "\n" + error.detail)
}
} catch (_: MobilePrivateStorageFfiException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_private_recovery))
}
} catch (_: NativeBridgeException) {
if (ticket ==
epoch
) {
mutable.value =
mutable.value.copy(error = session.text(R.string.core_update_retry))
}
} finally {
if (ticket == epoch) mutable.value = mutable.value.copy(loading = false)
}
}
}
}
}
@@ -28,8 +28,20 @@ data class PasswordField(
override fun toString() = "PasswordField(id=$id, [REDACTED])"
}
data class PasswordSection(val title: String, val fields: List<PasswordField>)
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<PasswordSection>? = 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)
@@ -0,0 +1,24 @@
package de.rfc1437.ironstorage.core
import kotlinx.coroutines.flow.StateFlow
data class TotpState(
val rows: List<PasswordRow> = emptyList(),
val loading: Boolean = false,
val hasCatalog: Boolean = false,
val detail: Boolean = false,
val error: String? = null
) {
override fun toString() = "TotpState(loading=$loading, [REDACTED])"
}
interface TotpRepository {
val state: StateFlow<TotpState>
val viewer: PasswordRepository
fun enter(selected: String?, revision: String?)
fun reload()
fun open(id: String, revision: String)
fun back()
fun discover(passphrase: String)
fun cancel()
fun leave()
}
@@ -99,23 +99,16 @@ fun LazyListScope.entryViewer(state: PasswordState, repository: PasswordReposito
}
}
state.notice?.let { item { Text(it) } }
state.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 ->
@@ -0,0 +1,159 @@
package de.rfc1437.ironstorage.features
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import de.rfc1437.ironstorage.core.PasswordOtp
import de.rfc1437.ironstorage.core.PasswordOtpKind
import kotlinx.coroutines.delay
@Composable
internal fun OtpCodeView(
code: PasswordOtp,
busy: Boolean,
copy: () -> Unit,
refresh: () -> Unit,
advance: () -> Unit,
tick: () -> Unit
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle, code.kind) {
if (code.kind == PasswordOtpKind.Totp) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
tick()
while (true) {
delay(1000L - Math.floorMod(System.currentTimeMillis(), 1000L))
tick()
}
}
}
}
var confirmation by remember(code.nextCounter) { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(
if (code.kind ==
PasswordOtpKind.Totp
) {
R.string.entry_totp
} else {
R.string.otp_hotp
}
),
Modifier.semantics { heading() }
)
code.issuer?.let { Text(it, style = MaterialTheme.typography.titleLarge) }
Text(code.account)
code.groupedCode?.let { value ->
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
value,
Modifier.fillMaxWidth().testTag("otp-code"),
style = MaterialTheme.typography.displayMedium,
fontFamily = FontFamily.Monospace,
maxLines = 2,
autoSize = TextAutoSize.StepBased(minFontSize = 24.sp, maxFontSize = 48.sp)
)
}
}
?: Text(
stringResource(
if (code.kind ==
PasswordOtpKind.Hotp
) {
R.string.otp_hotp_pending
} else {
R.string.otp_waiting
}
)
)
val period = code.period
val remaining = code.remainingSeconds
if (code.kind == PasswordOtpKind.Totp && period != null && remaining != null) {
LinearProgressIndicator(progress = {
(
remaining.toFloat() /
period.toFloat()
).coerceIn(0f, 1f)
}, modifier = Modifier.fillMaxWidth())
Text(
pluralStringResource(
R.plurals.otp_countdown,
remaining.coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
remaining.toString()
)
)
}
if (code.kind == PasswordOtpKind.Hotp &&
code.code != null
) {
Text(stringResource(R.string.otp_counter, code.counter.toString()))
}
FlowRow {
TextButton(enabled = !busy && code.code != null, onClick = copy) {
Text(stringResource(R.string.entry_copy_code))
}
TextButton(enabled = !busy, onClick = refresh) {
Text(stringResource(R.string.otp_refresh))
}
if (code.kind ==
PasswordOtpKind.Hotp
) {
TextButton(enabled = !busy && code.nextCounter != null, onClick = {
confirmation =
true
}) { Text(stringResource(R.string.otp_generate_hotp)) }
}
}
}
if (confirmation) {
ProtectedWindow()
AlertDialog(
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
onDismissRequest = { confirmation = false },
title = { Text(stringResource(R.string.otp_generate_hotp)) },
text = { Text(stringResource(R.string.otp_confirm_hotp, code.nextCounter.toString())) },
confirmButton = {
TextButton(enabled = !busy, onClick = {
confirmation = false
advance()
}) { Text(stringResource(R.string.otp_generate_hotp)) }
},
dismissButton = {
TextButton(onClick = {
confirmation = false
}) { Text(stringResource(R.string.auth_cancel)) }
}
)
}
}
@@ -200,6 +200,10 @@ private fun ShellDestination(
Modifier.fillMaxSize(),
leaveOnDispose = false
)
ready && route == ShellRoute.Totp -> {
LaunchedEffect(viewModel) { viewModel.openTotp() }
TotpScreen(viewModel.totp, viewModel.authentication)
}
ready && route == ShellRoute.Home -> HomeScreen(
viewModel.home,
Modifier.fillMaxSize(),
@@ -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<Boolean>("totp.detail") ==
true
) {
saved["totp.selection"]
} else {
null
},
saved["totp.revision"]
)
} else {
val selected = totp.viewer.state.value.selected.takeIf { totp.state.value.detail }
totp.enter(selected?.id, selected?.revision)
}
}
private var passwordsRestored = false
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() {
@@ -0,0 +1,192 @@
package de.rfc1437.ironstorage.features
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.rfc1437.ironstorage.core.AuthenticationFailureKind
import de.rfc1437.ironstorage.core.AuthenticationRepository
import de.rfc1437.ironstorage.core.TotpRepository
@Composable
fun TotpScreen(repository: TotpRepository, authentication: AuthenticationRepository) {
val state by repository.state.collectAsStateWithLifecycle()
val entry by repository.viewer.state.collectAsStateWithLifecycle()
val lease by authentication.state.collectAsStateWithLifecycle()
var manual by remember { mutableStateOf(false) }
var discovery by remember { mutableStateOf(false) }
var requested by remember { mutableStateOf(false) }
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { repository.leave() }
BackHandler(state.detail) { repository.back() }
androidx.compose.runtime.LaunchedEffect(lease.failure, state.loading, entry.loading) {
if (requested && !state.loading && !entry.loading && !lease.busy) {
requested = false
if (lease.failure?.kind == AuthenticationFailureKind.PassphraseRequired ||
lease.failure?.kind == AuthenticationFailureKind.BiometryUnavailable
) {
manual = true
}
}
}
val requestCatalog = {
discovery = true
requested = true
repository.discover("")
}
val requestUnlock = {
discovery = false
requested = true
repository.viewer.unlock("")
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val expanded = maxWidth >= 600.dp
Row(Modifier.fillMaxSize()) {
if (expanded || !state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-list"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
stringResource(R.string.entry_totp),
style = MaterialTheme.typography.headlineMedium
)
TextButton(
enabled = !state.loading && !lease.busy,
onClick = requestCatalog
) {
Text(stringResource(R.string.totp_refresh_catalog))
}
if (state.loading) {
CircularProgressIndicator()
TextButton(onClick = repository::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.hasCatalog) {
Text(stringResource(R.string.totp_needs_catalog))
} else if (state.rows.isEmpty()) {
Text(stringResource(R.string.totp_empty))
}
}
items(state.rows, key = { it.id }) { row ->
Column(
Modifier.fillMaxWidth().clickable(
enabled = !state.loading,
role = Role.Button,
onClick = {
repository.open(row.id, row.revision)
}
).padding(vertical = 12.dp)
) {
Text(row.title, style = MaterialTheme.typography.titleMedium)
Text(row.detail)
}
}
}
}
if (expanded || state.detail) {
LazyColumn(
Modifier.weight(1f).padding(16.dp).testTag("totp-detail"),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
if (state.detail) {
TextButton(onClick = repository::back) {
Text(stringResource(R.string.totp_back))
}
}
entry.selected?.let {
Text(it.title, style = MaterialTheme.typography.headlineSmall)
}
if (entry.loading) CircularProgressIndicator()
entry.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (!state.detail) {
Text(stringResource(R.string.totp_choose))
} else if (entry.stale) {
Text(stringResource(R.string.totp_stale))
} else if (entry.selected != null &&
(!lease.unlocked || entry.sections == null)
) {
Button(
enabled = !entry.loading && !lease.busy,
onClick = requestUnlock
) {
Text(stringResource(R.string.password_unlock))
}
TextButton(enabled = !lease.busy, onClick = {
discovery = false
manual =
true
}) { Text(stringResource(R.string.auth_manual)) }
if (lease.busy) {
TextButton(onClick = authentication::cancel) {
Text(stringResource(R.string.auth_cancel))
}
}
} else if (lease.unlocked && entry.sections != null) {
entry.otp?.let {
OtpCodeView(
it,
entry.loading,
repository.viewer::copyOtp,
repository.viewer::refreshOtp,
repository.viewer::advanceHotp,
repository.viewer::tickOtp
)
}
?: Text(stringResource(R.string.totp_unavailable))
TextButton(onClick = repository.viewer::lock) {
Text(stringResource(R.string.password_lock))
}
entry.notice?.let { Text(it) }
}
}
}
}
}
}
if (manual) {
PassphraseDialog(
stringResource(R.string.auth_manual),
stringResource(R.string.auth_manual_detail),
presentation = authentication.presentation,
onCancel = {
manual = false
repository.cancel()
authentication.cancel()
},
onSubmit = {
manual = false
requested = true
if (discovery) repository.discover(it) else repository.viewer.unlock(it)
}
)
}
}
@@ -235,4 +235,22 @@
<string name="qr_export_private_confirm">I checked this fingerprint and authorize displaying its private QR codes.</string>
<string name="qr_animate">Automatically advance frames</string>
<string name="qr_setup_import">Prepare the scanned key</string>
<string name="otp_hotp">HOTP</string>
<string name="otp_waiting">Waiting for a current code.</string>
<string name="otp_hotp_pending">Generate a code to consume the next HOTP counter.</string>
<string name="otp_refresh">Refresh code</string>
<string name="otp_generate_hotp">Generate next HOTP code</string>
<string name="otp_confirm_hotp">This consumes counter %1$s and saves the incremented counter in the encrypted entry. Continue?</string>
<string name="otp_counter">Generated for counter %1$s</string>
<plurals name="otp_countdown">
<item quantity="one">%1$s second remaining</item>
<item quantity="other">%1$s seconds remaining</item>
</plurals>
<string name="totp_refresh_catalog">Refresh TOTP catalog</string>
<string name="totp_needs_catalog">Refresh the catalog after unlocking to find TOTP entries.</string>
<string name="totp_empty">No TOTP entries found.</string>
<string name="totp_choose">Choose a TOTP entry.</string>
<string name="totp_unavailable">This entry no longer has a TOTP code.</string>
<string name="totp_stale">This entry changed. Refresh the catalog and select it again.</string>
<string name="totp_back">Back to TOTP entries</string>
</resources>