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>
+103
View File
@@ -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<String>,
pub account: String,
pub code: Option<String>,
pub grouped_code: Option<String>,
pub period: Option<u64>,
pub valid_until: Option<u64>,
pub remaining_seconds: Option<u64>,
pub counter: Option<u64>,
pub next_counter: Option<u64>,
}
impl From<ironstorage::mobile_totp::MobileOtpCodePresentation> for MobileOtpCodePresentation {
fn from(value: ironstorage::mobile_totp::MobileOtpCodePresentation) -> Self {
let text = |bytes: ironstorage::repository::SecretBytes| {
String::from_utf8(bytes.expose().to_vec())
.expect("storage-generated OTP codes are ASCII")
};
Self {
document_revision: value.document_revision,
kind: match value.kind {
ironstorage::otp::OtpKind::Totp => MobileOtpCodeKind::Totp,
ironstorage::otp::OtpKind::Hotp => MobileOtpCodeKind::Hotp,
},
issuer: value.issuer,
account: value.account,
code: value.code.map(text),
grouped_code: value.grouped_code.map(text),
period: value.period,
valid_until: value.valid_until,
remaining_seconds: value.remaining_seconds,
counter: value.counter,
next_counter: value.next_counter,
}
}
}
#[uniffi::export]
pub fn mobile_otp_window_current(valid_until: u64, period: u64, unix_seconds: u64) -> bool {
ironstorage::otp::OtpCodeValidity::Timed {
valid_until,
period,
}
.is_current(unix_seconds)
}
impl From<StorageTotpDetail> for MobileTotpDetail {
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<u64>,
) -> Result<Option<MobileOtpCodePresentation>, MobileAuthenticationFfiError> {
self.authentication
.presented_otp_code(&path, &revision, unix_seconds, consumed_counter)
.map(|value| value.map(Into::into))
.map_err(Into::into)
}
pub fn copy_presented_otp_code(
&self,
path: String,
revision: String,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<MobileEntryCopy, MobileAuthenticationFfiError> {
self.authentication
.copy_presented_otp_code(&path, &revision, unix_seconds, consumed_counter)
.map(Into::into)
.map_err(Into::into)
}
pub fn advance_presented_hotp(
&self,
path: String,
revision: String,
confirmed: bool,
) -> Result<MobileOtpCodePresentation, MobileAuthenticationFfiError> {
self.authentication
.advance_presented_hotp(&path, &revision, confirmed)
.map(Into::into)
.map_err(Into::into)
}
pub fn prepare_presented_entry_mutation(
&self,
path: String,
@@ -2146,6 +2239,16 @@ impl MobileAuthentication {
.map_err(Into::into)
}
pub fn cached_totp_navigation(
&self,
) -> Result<Option<Vec<password_navigation::MobileNavigationRow>>, MobileAuthenticationFfiError>
{
self.authentication
.cached_totp_navigation()
.map(|rows| rows.map(|rows| rows.into_iter().map(Into::into).collect()))
.map_err(Into::into)
}
pub fn search_cached_totp_page(
&self,
query: String,
+445 -2
View File
@@ -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<u64>,
) -> Result<Option<MobileOtpCodePresentation>, MobileAuthenticationError> {
self.ensure_repository_idle()?;
let document = self.open_presented_document(path, revision)?;
match otp_code_presentation(&document, unix_seconds, consumed_counter) {
Err(OtpError::ConcurrentModification { .. }) => {
self.manual_lock()?;
Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Conflict,
"HOTP Entry Changed",
"Refresh and unlock the entry before using its code.",
))
}
result => result.map_err(|error| totp_error(error.into())),
}
}
pub fn copy_presented_otp_code(
&self,
path: &str,
revision: &str,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<MobileEntryCopy, MobileAuthenticationError> {
let code = self
.presented_otp_code(path, revision, unix_seconds, consumed_counter)?
.and_then(|value| value.code)
.ok_or_else(|| {
entry_detail(
"OTP Code Is Unavailable",
"Open a TOTP code or explicitly generate a HOTP code before copying.",
)
})?;
Ok(MobileEntryCopy {
value: String::from_utf8(code.expose().to_vec())
.map_err(|_| entry_detail("OTP Code Is Unavailable", "The code is not UTF-8."))?,
timeout_seconds: self.config.clipboard_timeout().duration().as_secs(),
})
}
pub fn advance_presented_hotp(
&self,
path: &str,
revision: &str,
confirmed: bool,
) -> Result<MobileOtpCodePresentation, MobileAuthenticationError> {
if !confirmed {
return Err(MobileAuthenticationError::new(
MobileAuthenticationErrorKind::Cancelled,
"HOTP Generation Cancelled",
"The HOTP counter was not changed.",
));
}
self.ensure_repository_idle()?;
self.ensure_active()?;
self.reserve_entry_mutation()?;
let result = (|| {
let document = self.open_presented_document(path, revision)?;
if self.editor_state(path)?.0 {
return Err(entry_detail(
"HOTP Entry Is Being Edited",
"Close the entry editor before generating a code.",
));
}
if document
.otp_uri()
.map_err(|error| totp_error(error.into()))?
.is_none_or(|uri| uri.kind() != OtpKind::Hotp)
{
return Err(totp_error(OtpError::NotHotp.into()));
}
let (handle, key, identity) = {
let status = self.status()?;
let active = status.active.as_ref().ok_or_else(locked_error)?;
(
active.handle.clone(),
active.key.clone(),
status.git_identity.clone(),
)
};
let mut provider = KeyOnlyProvider::new(handle, &key);
let mut committer =
AutomaticEntryCommitter::for_entry(&self.repository, path, identity)
.map_err(|error| entry_detail("HOTP Counter Could Not Be Updated", error))?;
let outcome = OtpService::new(&self.repository, &self.keys)
.code_for_document(&document, &mut provider, &mut committer)
.map_err(|error| totp_error(error.into()))?;
let committed_error = || {
entry_detail(
"HOTP Counter Was Updated",
"Refresh and unlock the entry before generating another code. The previous counter was already consumed.",
)
};
let updated = self
.open_active_document(path)
.map_err(|_| committed_error())?;
let code = otp_code_presentation(&updated, 0, outcome.counter())
.map_err(|_| committed_error())?
.ok_or_else(committed_error)?;
if code.code.as_ref().map(SecretBytes::expose) != Some(outcome.code().expose()) {
self.manual_lock()?;
return Err(committed_error());
}
self.reconcile_saved_document(&updated);
Ok(code)
})();
self.release_entry_mutation();
result
}
pub fn prepare_presented_entry_mutation(
&self,
path: &str,
@@ -1283,6 +1399,29 @@ impl MobileAuthentication {
self.search_cached_totp_page("")
}
pub fn cached_totp_navigation(
&self,
) -> Result<
Option<Vec<crate::mobile_passwords::navigation::NavigationRow>>,
MobileAuthenticationError,
> {
self.cached_totp_page()?
.map(|page| {
page.rows()
.iter()
.map(|row| {
let path = EntryPath::parse(row.path()).map_err(entry_error)?;
crate::mobile_passwords::navigation::entry_navigation_row(
&self.repository,
&path,
)
.map_err(|error| entry_detail(error.title(), error.detail()))
})
.collect()
})
.transpose()
}
pub fn search_cached_totp_page(
&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<super::MobileAuthentication>,
std::sync::Arc<OtpClock>,
) {
use crate::{
crypto::KeyStore,
git::{GitIdentity, GitRepository},
recipient::RecipientPolicyManager,
repository::{EntryPath, Repository, SecretBytes},
};
let (root, private, backend) = crate::mobile_onboarding::android::tests::home_fixture();
let config = private.configuration().unwrap().unwrap();
let repository = Repository::open(config.vault()).unwrap();
let keys = KeyStore::load(config.key_material()).unwrap();
let path = EntryPath::parse("otp").unwrap();
let policy = RecipientPolicyManager::new(&repository, &keys)
.resolve_for_entry(&path, None)
.unwrap();
let encrypted = keys
.encrypt(
SecretBytes::new(format!("fixture-password\n{uri}\n").into_bytes()),
policy.recipients(),
)
.unwrap();
repository.write_entry(&path, &encrypted).unwrap();
GitRepository::open(&repository, GitIdentity::ironstorage())
.unwrap()
.commit_all("Seed encrypted OTP fixture")
.unwrap();
let clock = std::sync::Arc::new(OtpClock(std::sync::atomic::AtomicU64::new(0)));
let authentication = private.authentication(backend, clock.clone()).unwrap();
(root, authentication, clock)
}
#[test]
fn otp_presentation_matches_rfc_vectors_and_boundaries_without_renewing_the_lease() {
use crate::{otp::OtpCodeValidity, repository::SecretBytes};
for (algorithm, secret, expected) in [
("SHA1", "12345678901234567890", "94287082"),
("SHA256", "12345678901234567890123456789012", "46119246"),
(
"SHA512",
"1234567890123456789012345678901234567890123456789012345678901234",
"90693936",
),
] {
let secret = data_encoding::BASE32_NOPAD.encode(secret.as_bytes());
let uri = format!(
"otpauth://totp/RFC:alice?secret={secret}&algorithm={algorithm}&digits=8&period=30&issuer=RFC"
);
let (_root, authentication, clock) = otp_fixture(&uri);
assert!(authentication.cached_totp_navigation().unwrap().is_none());
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
authentication.set_authentication_timeout_text("2").unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
let revision = page.page().revision();
let code = authentication
.presented_otp_code("otp", revision, 59, None)
.unwrap()
.unwrap();
assert_eq!(code.code.as_ref().unwrap().expose(), expected.as_bytes());
assert_eq!(
code.grouped_code.as_ref().unwrap().expose(),
format!("{} {}", &expected[..4], &expected[4..]).as_bytes()
);
assert_eq!(code.remaining_seconds, Some(1));
assert_eq!(code.valid_until, Some(60));
let rows = authentication.cached_totp_navigation().unwrap().unwrap();
assert_eq!(rows.len(), 1);
let cached = std::fs::read(
authentication
.config
.source()
.with_file_name("totp-catalog.toml"),
)
.unwrap();
let at_boundary = authentication
.presented_otp_code("otp", revision, 60, None)
.unwrap()
.unwrap();
assert_ne!(
at_boundary.code.as_ref().unwrap().expose(),
expected.as_bytes()
);
assert_eq!(at_boundary.remaining_seconds, Some(30));
assert_eq!(at_boundary.valid_until, Some(90));
assert_eq!(
std::fs::read(
authentication
.config
.source()
.with_file_name("totp-catalog.toml")
)
.unwrap(),
cached
);
let window = OtpCodeValidity::Timed {
valid_until: 60,
period: 30,
};
assert!(!window.is_current(29));
assert!(window.is_current(30));
assert!(window.is_current(59));
assert!(!window.is_current(60));
clock.0.store(1, std::sync::atomic::Ordering::Relaxed);
authentication
.presented_otp_code("otp", revision, 61, None)
.unwrap();
assert_eq!(authentication.state().unwrap().remaining_seconds(), 1);
clock.0.store(2, std::sync::atomic::Ordering::Relaxed);
assert!(
authentication
.presented_otp_code("otp", revision, 62, None)
.is_err()
);
assert!(!authentication.state().unwrap().unlocked());
}
}
#[test]
fn malformed_otp_keeps_the_structured_entry_readable() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture("otpauth://totp/Invalid?secret=!");
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
assert!(!page.page().sections().is_empty());
assert!(
authentication
.presented_otp_code("otp", page.page().revision(), 59, None)
.unwrap()
.is_none()
);
}
#[test]
fn hotp_requires_confirmation_commits_once_and_copy_refresh_never_advance() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture(
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=0&issuer=RFC",
);
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 0).unwrap();
let revision = page.page().revision();
let before = authentication
.repository
.read_entry(&crate::repository::EntryPath::parse("otp").unwrap())
.unwrap();
assert!(
authentication
.advance_presented_hotp("otp", revision, false)
.is_err()
);
assert_eq!(
authentication
.repository
.read_entry(&crate::repository::EntryPath::parse("otp").unwrap())
.unwrap(),
before
);
assert!(
authentication
.presented_otp_code("otp", revision, 0, None)
.unwrap()
.unwrap()
.code
.is_none()
);
assert!(
authentication
.copy_presented_otp_code("otp", revision, 0, None)
.is_err()
);
let issued = authentication
.advance_presented_hotp("otp", revision, true)
.unwrap();
assert_eq!(issued.code.as_ref().unwrap().expose(), b"287082");
assert_eq!(issued.counter, Some(1));
assert_eq!(issued.next_counter, Some(2));
assert_eq!(
authentication
.copy_presented_otp_code("otp", &issued.document_revision, 100, issued.counter)
.unwrap()
.value(),
"287082"
);
let refreshed = authentication
.presented_otp_code("otp", &issued.document_revision, 200, issued.counter)
.unwrap()
.unwrap();
assert_eq!(refreshed.next_counter, Some(2));
let next = authentication
.advance_presented_hotp("otp", &issued.document_revision, true)
.unwrap();
assert_eq!(next.code.as_ref().unwrap().expose(), b"359152");
assert_eq!(next.next_counter, Some(3));
assert!(
authentication
.copy_presented_otp_code("otp", &next.document_revision, 201, issued.counter)
.is_err()
);
authentication.manual_lock().unwrap();
assert!(
authentication
.copy_presented_otp_code("otp", &next.document_revision, 202, next.counter)
.is_err()
);
}
#[test]
fn hotp_commit_failure_and_overflow_leave_the_ciphertext_unchanged() {
use crate::repository::{EntryPath, SecretBytes};
for counter in ["0", "18446744073709551615"] {
let (_root, authentication, _) = otp_fixture(&format!(
"otpauth://hotp/RFC:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter={counter}"
));
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let path = EntryPath::parse("otp").unwrap();
let before = authentication.repository.read_entry(&path).unwrap();
let page = authentication.entry_page("otp", 0).unwrap();
if counter == "0" {
std::fs::write(
authentication
.repository
.root_path()
.join(".git/index.lock"),
b"locked",
)
.unwrap();
}
assert!(
authentication
.advance_presented_hotp("otp", page.page().revision(), true)
.is_err()
);
assert_eq!(authentication.repository.read_entry(&path).unwrap(), before);
}
}
#[test]
fn custom_period_and_stale_revision_are_storage_owned() {
use crate::repository::SecretBytes;
let (_root, authentication, _) = otp_fixture(
"otpauth://totp/Custom:alice?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=6&period=60&issuer=Custom",
);
authentication
.unlock_entry(
"otp",
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)
.unwrap();
let page = authentication.entry_page("otp", 59).unwrap();
let code = authentication
.presented_otp_code("otp", page.page().revision(), 59, None)
.unwrap()
.unwrap();
assert_eq!(code.period, Some(60));
assert_eq!(code.valid_until, Some(60));
assert_eq!(code.remaining_seconds, Some(1));
assert_eq!(code.code.as_ref().unwrap().expose().len(), 6);
assert_eq!(code.grouped_code.as_ref().unwrap().expose().len(), 7);
let next = authentication
.presented_otp_code("otp", page.page().revision(), 60, None)
.unwrap()
.unwrap();
assert_eq!(next.valid_until, Some(120));
assert_eq!(next.remaining_seconds, Some(60));
assert!(
authentication
.presented_otp_code("otp", "stale", 60, None)
.is_err()
);
assert!(!authentication.state().unwrap().unlocked());
}
#[test]
fn explicit_biometric_enrollment_and_restore_work_without_a_password_entry() {
use crate::authentication::SystemAuthenticationClock;
@@ -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<NavigationRow, MobilePasswordError> {
let row = super::search_row(path, &path.to_string());
let revision = repository
.navigation_revision(path.as_path(), true)
.map_err(MobilePasswordError::repository)?;
Ok(NavigationRow {
row,
revision: HEXLOWER.encode(&Sha256::digest(revision.as_bytes())),
})
}
use cap_std::{ambient_authority, fs::Dir};
use data_encoding::HEXLOWER;
use sha2::{Digest, Sha256};
+84
View File
@@ -204,6 +204,90 @@ pub struct MobileTotpDetail {
watch: MobileWatchSnapshotStatus,
}
pub struct MobileOtpCodePresentation {
pub document_revision: String,
pub kind: OtpKind,
pub issuer: Option<String>,
pub account: String,
pub code: Option<SecretBytes>,
pub grouped_code: Option<SecretBytes>,
pub period: Option<u64>,
pub valid_until: Option<u64>,
pub remaining_seconds: Option<u64>,
pub counter: Option<u64>,
pub next_counter: Option<u64>,
}
impl fmt::Debug for MobileOtpCodePresentation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MobileOtpCodePresentation([REDACTED])")
}
}
pub(crate) fn otp_code_presentation(
document: &EntryDocument,
unix_seconds: u64,
consumed_counter: Option<u64>,
) -> Result<Option<MobileOtpCodePresentation>, OtpError> {
let Some(uri) = document.otp_uri().ok().flatten() else {
return Ok(None);
};
let (code, remaining_seconds, counter, valid_until) = match uri.kind() {
OtpKind::Totp => {
if consumed_counter.is_some() {
return Err(OtpError::UnexpectedCounter);
}
let period = uri.period().ok_or(OtpError::NotTotp)?;
let end = (unix_seconds / period)
.checked_add(1)
.and_then(|step| step.checked_mul(period))
.ok_or(OtpError::CounterOverflow)?;
(
Some(uri.code_at(unix_seconds)?),
Some(end - unix_seconds),
None,
Some(end),
)
}
OtpKind::Hotp => {
let next = uri.counter().ok_or(OtpError::MissingCounter)?;
let code = if let Some(consumed) = consumed_counter {
if consumed != next {
return Err(OtpError::ConcurrentModification {
entry: document.path().clone(),
});
}
Some(uri.code_for_counter(consumed)?)
} else {
None
};
(code, None, consumed_counter, None)
}
};
let grouped_code = code.as_ref().map(|code| {
let bytes = code.expose();
let middle = bytes.len() / 2;
let mut grouped = Vec::with_capacity(bytes.len() + 1);
grouped.extend_from_slice(&bytes[..middle]);
grouped.push(b' ');
grouped.extend_from_slice(&bytes[middle..]);
SecretBytes::new(grouped)
});
Ok(Some(MobileOtpCodePresentation {
document_revision: crate::mobile_entry::document_revision(document),
kind: uri.kind(),
issuer: uri.issuer().map(str::to_owned),
account: uri.account().to_owned(),
code,
grouped_code,
period: uri.period(),
valid_until,
remaining_seconds,
counter,
next_counter: uri.counter().and_then(|counter| counter.checked_add(1)),
}))
}
impl MobileTotpDetail {
pub fn path(&self) -> &str {
&self.path
+32
View File
@@ -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<u64> {
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<OtpCodeOutcome, OtpError> {
let (path, original, plaintext, range, uri) =
self.load_code_entry(&document.path().to_string(), provider)?;
if document.original_ciphertext() != Some(&original) {
return Err(OtpError::ConcurrentModification { entry: path });
}
if uri.kind() != OtpKind::Hotp {
return Err(OtpError::NotHotp);
}
self.finish_code(path, original, plaintext, range, uri, 0, None, committer)
}
/// Generate a code with storage-owned lazy Git selection. TOTP is
/// read-only and never opens Git; HOTP opens the innermost repository only
/// after the token has been decrypted and identified as counter based.
+49
View File
@@ -0,0 +1,49 @@
# Android one-time passwords
Issue [#107](https://git.rfc1437.de/hugo/IronStorage/issues/107) connects the TOTP
destination to the storage-owned discovery catalog. Opening the list reads cached
metadata without decrypting entries. Select a row, then Unlock to display its
issuer, account, grouped code, countdown and progress. Refresh TOTP catalog is an
explicit authenticated discovery action; the progress/search workflow belongs
to [#110](https://git.rfc1437.de/hugo/IronStorage/issues/110).
The normal entry viewer and TOTP detail use the same code component and native
viewer controller. Decryption, creation and editing reconcile the catalog in
Rust. Only nonsecret row identities and revisions are restored after recreation.
OTP parameters, codes and authentication secrets are never persisted by Kotlin.
Rust computes SHA1/SHA256/SHA512 codes, six/eight digits, custom periods, absolute
validity boundaries, grouping and countdown. The composed code component owns a
STARTED-lifecycle coroutine that submits OS clock facts without touching the
authentication lease. Scrolling it out or leaving stops decryption/countdown ticks;
an expiry-only native window check still erases held codes when hidden. Rust validates each
returned window before publication, including delayed results and backward clock
changes. Lock, background, expiry, entry changes and stale revisions remove codes
and cancel presentation. Explicit copies use the existing sensitive clipboard
adapter and Rust timeout/ownership checks.
HOTP in the normal entry viewer requires Generate next HOTP code and a separate
counter confirmation. The existing pass-otp core increments and atomically commits
the encrypted counter before returning its code. Refresh and Copy use that same
committed counter without incrementing. Stale documents, open editors, failed Git
commits and overflow cannot consume a counter. The TOTP discovery catalog remains
specific to time-based entries. Android exposes no Watch sharing controls.
## Validation
Rust tests cover RFC 6238 algorithm vectors, six/eight digits, 30/60-second
boundaries, cache agreement, polling without lease renewal, malformed URI
diagnostics, stale revision revocation, HOTP confirmation, copy/refresh stability,
commit rollback and counter overflow. Android's NativeOtpPresentationTest uses
real encrypted stores with fake wall/lease clocks to verify catalog-only reads,
boundary transitions, tick lease expiry, clipboard handoff expiry, queued user
actions overtaking read-only observations, both HOTP generations and background
revocation. TotpScreenTest covers explicit unlock, grouped code/countdown/copy,
lock cleanup, HOTP confirm/cancel, tick cancellation on leaving and 300% text
without clipped digits. Computer use inspects the same Compose
components on an ARM64 emulator using disposable presentation fixtures.
All emulator installs preserve the retained app container. Production Keystore
and biometric enrollment are unchanged by this story; the native controller tests
provide deterministic OS capability callbacks. Supported release ABIs remain
arm64-v8a and armeabi-v7a; physical devices and x86_64 are outside this milestone.
+1 -1
View File
@@ -31,7 +31,7 @@ Every issue linked here belongs to milestone 23.
| OTP URI input, camera/image import, review before Save (OtpImportViewController; TOTP Add action) | mobile_entry::MobileEntryDraft::add_otp_image; document/otp/presentation | CameraX frames or Android photo picker bytes; Rust decode and field construction | #103/#106/#107; URI/image/scan parity, invalid/oversized input, cancel; no payload parsing in Kotlin |
| 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 |