Show remaining time in TOTP field (#1766)

* Pass down remaining time for TOTPs to UI layer

* format-common: switch TOTP flow to use co-operative cancelation

* format-common: add a regression test for OTP duration calculation

* Abstract out labels

* Switch to launchIn
This commit is contained in:
Harsh Shandilya
2022-03-11 01:52:39 +05:30
committed by GitHub
parent 3e988b2a34
commit 2f034bc237
9 changed files with 82 additions and 41 deletions

View File

@@ -5,31 +5,39 @@
package dev.msfjarvis.aps.data.password package dev.msfjarvis.aps.data.password
import dev.msfjarvis.aps.data.passfile.Totp
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
class FieldItem(val key: String, val value: String, val action: ActionType) { class FieldItem(val key: String, val value: String, val action: ActionType) {
enum class ActionType { enum class ActionType {
COPY, COPY,
HIDE HIDE
} }
enum class ItemType(val type: String) { enum class ItemType(val type: String, val label: String) {
USERNAME("Username"), USERNAME("Username", "Username"),
PASSWORD("Password"), PASSWORD("Password", "Password"),
OTP("OTP") OTP("OTP", "OTP (expires in %ds)"),
} }
companion object { companion object {
// Extra helper methods // Extra helper methods
fun createOtpField(otp: String): FieldItem { fun createOtpField(totp: Totp): FieldItem {
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY) return FieldItem(
ItemType.OTP.label.format(totp.remainingTime.inWholeSeconds),
totp.value,
ActionType.COPY,
)
} }
fun createPasswordField(password: String): FieldItem { fun createPasswordField(password: String): FieldItem {
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE) return FieldItem(ItemType.PASSWORD.label, password, ActionType.HIDE)
} }
fun createUsernameField(username: String): FieldItem { fun createUsernameField(username: String): FieldItem {
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY) return FieldItem(ItemType.USERNAME.label, username, ActionType.COPY)
} }
} }
} }

View File

@@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import dev.msfjarvis.aps.R import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.passfile.Totp
import dev.msfjarvis.aps.data.password.FieldItem import dev.msfjarvis.aps.data.password.FieldItem
import dev.msfjarvis.aps.databinding.ItemFieldBinding import dev.msfjarvis.aps.databinding.ItemFieldBinding
@@ -35,13 +36,13 @@ class FieldItemAdapter(
return fieldItemList.size return fieldItemList.size
} }
fun updateOTPCode(code: String) { fun updateOTPCode(totp: Totp) {
var otpItemPosition = -1 var otpItemPosition = -1
fieldItemList = fieldItemList =
fieldItemList.mapIndexed { position, item -> fieldItemList.mapIndexed { position, item ->
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) { if (item.key.startsWith(FieldItem.ItemType.OTP.type, true)) {
otpItemPosition = position otpItemPosition = position
return@mapIndexed FieldItem.createOtpField(code) return@mapIndexed FieldItem.createOtpField(totp)
} }
return@mapIndexed item return@mapIndexed item

View File

@@ -30,8 +30,8 @@ import kotlin.time.Duration
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -210,12 +210,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
if (entry.hasTotp()) { if (entry.hasTotp()) {
lifecycleScope.launch { entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope)
entry
.totp
.onEach { code -> withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } }
.collect()
}
} }
} }
.onFailure { e -> logcat(ERROR) { e.asLog() } } .onFailure { e -> logcat(ERROR) { e.asLog() } }

View File

@@ -25,17 +25,18 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalTime::class)
@AndroidEntryPoint @AndroidEntryPoint
class DecryptActivityV2 : BasePgpActivity() { class DecryptActivityV2 : BasePgpActivity() {
@@ -90,10 +91,9 @@ class DecryptActivityV2 : BasePgpActivity() {
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent * Automatically finishes the activity 60 seconds after decryption succeeded to prevent
* information leaks from stale activities. * information leaks from stale activities.
*/ */
@OptIn(ExperimentalTime::class)
private fun startAutoDismissTimer() { private fun startAutoDismissTimer() {
lifecycleScope.launch { lifecycleScope.launch {
delay(Duration.seconds(60)) delay(60.seconds)
finish() finish()
} }
} }
@@ -198,12 +198,7 @@ class DecryptActivityV2 : BasePgpActivity() {
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
if (entry.hasTotp()) { if (entry.hasTotp()) {
lifecycleScope.launch { entry.totp.onEach(adapter::updateOTPCode).launchIn(lifecycleScope)
entry
.totp
.onEach { code -> withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } }
.collect()
}
} }
} }

View File

@@ -143,7 +143,7 @@ object AutofillPreferences {
// Always give priority to a username stored in the encrypted extras // Always give priority to a username stored in the encrypted extras
val username = val username =
entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername() entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
val totp = if (entry.hasTotp()) runBlocking { entry.totp.first() } else null val totp = if (entry.hasTotp()) runBlocking { entry.totp.first().value } else null
return Credentials(username, entry.password, totp) return Credentials(username, entry.password, totp)
} }
} }

View File

@@ -13,12 +13,17 @@ import dev.msfjarvis.aps.util.time.UserClock
import dev.msfjarvis.aps.util.totp.Otp import dev.msfjarvis.aps.util.totp.Otp
import dev.msfjarvis.aps.util.totp.TotpFinder import dev.msfjarvis.aps.util.totp.TotpFinder
import kotlin.collections.set import kotlin.collections.set
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
/** Represents a single entry in the password store. */ /** Represents a single entry in the password store. */
@OptIn(ExperimentalTime::class)
public class PasswordEntry public class PasswordEntry
@AssistedInject @AssistedInject
constructor( constructor(
@@ -52,13 +57,13 @@ constructor(
* does not have a TOTP secret, the flow will never emit. Users should call [hasTotp] before * does not have a TOTP secret, the flow will never emit. Users should call [hasTotp] before
* collection to check if it is valid to collect this [Flow]. * collection to check if it is valid to collect this [Flow].
*/ */
public val totp: Flow<String> = flow { public val totp: Flow<Totp> = flow {
if (totpSecret != null) { if (totpSecret != null) {
repeat(Int.MAX_VALUE) { do {
val (otp, remainingTime) = calculateTotp() val otp = calculateTotp()
emit(otp) emit(otp)
delay(remainingTime) delay(1000L)
} } while (coroutineContext.isActive)
} else { } else {
awaitCancellation() awaitCancellation()
} }
@@ -169,17 +174,17 @@ constructor(
return null return null
} }
private fun calculateTotp(): Pair<String, Long> { private fun calculateTotp(): Totp {
val digits = totpFinder.findDigits(content) val digits = totpFinder.findDigits(content)
val totpPeriod = totpFinder.findPeriod(content) val totpPeriod = totpFinder.findPeriod(content)
val totpAlgorithm = totpFinder.findAlgorithm(content) val totpAlgorithm = totpFinder.findAlgorithm(content)
val issuer = totpFinder.findIssuer(content) val issuer = totpFinder.findIssuer(content)
val millis = clock.millis() val millis = clock.millis()
val remainingTime = totpPeriod - (millis % totpPeriod) val remainingTime = (totpPeriod - ((millis / 1000) % totpPeriod)).seconds
Otp.calculateCode(totpSecret!!, millis / (1000 * totpPeriod), totpAlgorithm, digits, issuer) Otp.calculateCode(totpSecret!!, millis / (1000 * totpPeriod), totpAlgorithm, digits, issuer)
.mapBoth( .mapBoth(
{ code -> { code ->
return code to remainingTime return Totp(code, remainingTime)
}, },
{ throwable -> throw throwable } { throwable -> throw throwable }
) )

View File

@@ -0,0 +1,8 @@
package dev.msfjarvis.aps.data.passfile
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
/** Holder for a TOTP secret and the duration for which it is valid. */
@OptIn(ExperimentalTime::class)
public data class Totp(public val value: String, public val remainingTime: Duration)

View File

@@ -8,6 +8,7 @@ package dev.msfjarvis.aps.data.passfile
import dev.msfjarvis.aps.test.CoroutineTestRule import dev.msfjarvis.aps.test.CoroutineTestRule
import dev.msfjarvis.aps.test.test2 import dev.msfjarvis.aps.test.test2
import dev.msfjarvis.aps.util.time.TestUserClock import dev.msfjarvis.aps.util.time.TestUserClock
import dev.msfjarvis.aps.util.time.UserClock
import dev.msfjarvis.aps.util.totp.TotpFinder import dev.msfjarvis.aps.util.totp.TotpFinder
import java.util.Locale import java.util.Locale
import kotlin.test.Test import kotlin.test.Test
@@ -15,6 +16,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -24,9 +26,9 @@ import org.junit.Rule
class PasswordEntryTest { class PasswordEntryTest {
@get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
private fun makeEntry(content: String) = private fun makeEntry(content: String, clock: UserClock = fakeClock) =
PasswordEntry( PasswordEntry(
fakeClock, clock,
testFinder, testFinder,
content.encodeToByteArray(), content.encodeToByteArray(),
) )
@@ -134,7 +136,26 @@ class PasswordEntryTest {
val entry = makeEntry("secret\nextra\n$TOTP_URI") val entry = makeEntry("secret\nextra\n$TOTP_URI")
assertTrue(entry.hasTotp()) assertTrue(entry.hasTotp())
entry.totp.test2 { entry.totp.test2 {
assertEquals("818800", expectMostRecentItem()) val otp = expectMostRecentItem()
assertEquals("818800", otp.value)
assertEquals(30.seconds, otp.remainingTime)
cancelAndIgnoreRemainingEvents()
}
}
/**
* Same as [testGeneratesOtpFromTotpUri], but advances the clock by 5 seconds. This exercises the
* [Totp.remainingTime] calculation logic, and acts as a regression test to resolve the bug which
* blocked https://msfjarvis.dev/aps/issue/1550.
*/
@Test
fun testGeneratedOtpHasCorrectRemainingTime() = runTest {
val entry = makeEntry("secret\nextra\n$TOTP_URI", TestUserClock.withAddedSeconds(5))
assertTrue(entry.hasTotp())
entry.totp.test2 {
val otp = expectMostRecentItem()
assertEquals("818800", otp.value)
assertEquals(25.seconds, otp.remainingTime)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@@ -144,7 +165,9 @@ class PasswordEntryTest {
val entry = makeEntry(TOTP_URI) val entry = makeEntry(TOTP_URI)
assertNull(entry.password) assertNull(entry.password)
entry.totp.test2 { entry.totp.test2 {
assertEquals("818800", expectMostRecentItem()) val otp = expectMostRecentItem()
assertEquals("818800", otp.value)
assertEquals(30.seconds, otp.remainingTime)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }

View File

@@ -24,4 +24,10 @@ class TestUserClock(instant: Instant) : UserClock() {
override fun getZone(): ZoneId = UTC override fun getZone(): ZoneId = UTC
override fun instant(): Instant = clock.instant() override fun instant(): Instant = clock.instant()
companion object {
fun withAddedSeconds(secondsToAdd: Long): TestUserClock {
return TestUserClock(Instant.EPOCH.plusSeconds(secondsToAdd))
}
}
} }