Implement Android TOTP and confirmed HOTP presentation (#107)
This commit is contained in:
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 =
|
||||
|
||||
+14
-8
@@ -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
|
||||
|
||||
+187
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+141
-40
@@ -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>
|
||||
Reference in new issue
Block a user