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