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