Skip to content

Commit 4f6eb8e

Browse files
committed
[TOTP] Fix space-contained totp url detection
This fixes issues occurring when a totp url contains spaces. Ths approach handles it and encodes them internally.
1 parent 02fd5c3 commit 4f6eb8e

5 files changed

Lines changed: 59 additions & 5 deletions

File tree

app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordContent.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit)
180180
DialogState.TotpParseError -> {
181181
TotpParseErrorDialog(
182182
onDismiss = { onEvent(PasswordUiEvent.OnTotpParseErrorDismiss) },
183+
modifier = Modifier.fillMaxWidth()
183184
)
184185
}
185186

@@ -230,6 +231,7 @@ fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit)
230231
}
231232
}
232233

234+
// TODO: check permissions
233235
if (state.scanning) {
234236
QRScanner(
235237
onClose = { onEvent(PasswordUiEvent.OnBackClick) },

app/src/main/kotlin/de/davis/keygo/item/create/presentation/password/PasswordViewModel.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package de.davis.keygo.item.create.presentation.password
22

3+
import android.util.Log
34
import androidx.compose.foundation.text.input.TextFieldState
45
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
56
import androidx.compose.runtime.snapshotFlow
@@ -203,6 +204,7 @@ class PasswordViewModel(
203204

204205
private fun initWithTotpUri(totpUri: String) {
205206
getTotpSecret(totpUri).onFailure {
207+
Log.e(TAG, "Error parsing TOTP URI: $it")
206208
showTotpParseError()
207209
}.onSuccess { secret ->
208210
totpSecretInformation = secret
@@ -314,7 +316,11 @@ class PasswordViewModel(
314316
}
315317

316318
is PasswordUiEvent.OnCodesScanned -> {
317-
event.codes.firstNotNullOfOrNull { getTotpSecret(it).getOrNull() }
319+
event.codes.firstNotNullOfOrNull {
320+
getTotpSecret(it).onFailure { failure ->
321+
Log.e(TAG, "Error parsing TOTP URI: $failure")
322+
}.getOrNull()
323+
}
318324
?.let {
319325
_uiState.update { state ->
320326
state.copy(scanning = false)
@@ -512,4 +518,8 @@ class PasswordViewModel(
512518
)
513519
}
514520
}
521+
522+
companion object {
523+
private const val TAG = "PasswordViewModel"
524+
}
515525
}

app/src/main/kotlin/de/davis/keygo/totp/domain/model/TotpSecretUrlParseError.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package de.davis.keygo.totp.domain.model
22

33
sealed interface TotpSecretUrlParseError {
4+
data class CouldNotParseUrl(val url: String) : TotpSecretUrlParseError
5+
46
data class SchemeNotSupported(val scheme: String?) : TotpSecretUrlParseError
57
data class HostNotSupported(val host: String?) : TotpSecretUrlParseError
68
data class IssuerMismatch(val param: String, val query: String) : TotpSecretUrlParseError

app/src/main/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCase.kt

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package de.davis.keygo.totp.domain.usecase
22

33
import de.davis.keygo.core.domain.Result
4+
import de.davis.keygo.core.domain.getOrNull
45
import de.davis.keygo.totp.domain.model.Algorithm
56
import de.davis.keygo.totp.domain.model.TotpSecretInformation
67
import de.davis.keygo.totp.domain.model.TotpSecretUrlParseError
@@ -11,9 +12,15 @@ import java.net.URI
1112
class GetTotpSecretFromUrlUseCase {
1213

1314
operator fun invoke(url: String): Result<TotpSecretInformation, TotpSecretUrlParseError> {
14-
val uri = URI.create(url)
15-
if (uri.scheme != "otpauth")
16-
return Result.Failure(TotpSecretUrlParseError.SchemeNotSupported(uri.scheme))
15+
if (!url.startsWith("otpauth://"))
16+
return Result.Failure(
17+
TotpSecretUrlParseError.SchemeNotSupported(
18+
url.split("://").firstOrNull()
19+
)
20+
)
21+
22+
val uri = sanitizeAndParse(url).getOrNull()
23+
?: return Result.Failure(TotpSecretUrlParseError.CouldNotParseUrl(url))
1724

1825
if (uri.host != "totp")
1926
return Result.Failure(TotpSecretUrlParseError.HostNotSupported(uri.host))
@@ -33,7 +40,8 @@ class GetTotpSecretFromUrlUseCase {
3340
}
3441
}
3542

36-
val query = uri.query ?: return Result.Failure(TotpSecretUrlParseError.NoQueryProvided)
43+
val query = uri.query.ifBlank { null }
44+
?: return Result.Failure(TotpSecretUrlParseError.NoQueryProvided)
3745
val algorithm = query.getQueryParameter("algorithm").asAlgorithmOrSHA1()
3846
val digits = query.getQueryParameter("digits")
3947
?.toIntOrNull()
@@ -68,6 +76,23 @@ class GetTotpSecretFromUrlUseCase {
6876

6977
private fun String?.asAlgorithmOrSHA1() =
7078
this?.let { Algorithm.fromString(it) } ?: DefaultTotpValues.DEFAULT_ALGORITHM
79+
80+
private fun sanitizeAndParse(url: String): Result<URI, Unit> {
81+
// - (?<authority>[^/?#]+) : Named group capturing one or more characters that are not '/', '?', or '#'
82+
// - (?<label>[^?#]*)? : Optional named group capturing zero or more characters that are not '?' or '#'.
83+
// It includes the leading '/' in the path.
84+
// - (?:\\?(?<query>.*))? : Non-capturing group capturing the query part after '?' if it exists
85+
val matches = "^otpauth://(?<authority>[^/?#]+)(?<label>[^?#]*)?(?:\\?(?<query>.*))?$"
86+
.toRegex()
87+
.matchEntire(url)
88+
?: return Result.Failure(Unit)
89+
90+
val authority = matches.groups["authority"]?.value
91+
val label = matches.groups["label"]?.value
92+
val query = matches.groups["query"]?.value
93+
94+
return Result.Success(URI("otpauth", authority, label, query, null))
95+
}
7196
}
7297

7398
object DefaultTotpValues {

app/src/test/kotlin/de/davis/keygo/totp/domain/usecase/GetTotpSecretFromUrlUseCaseTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ class GetTotpSecretFromUrlUseCaseTest {
3131
assertEquals(30, info.period)
3232
}
3333

34+
@Test
35+
fun `valid space-contained totp url returns totp secret information`() {
36+
val url = "otpauth://totp/example.com (alice@gmail.com)?secret=JBSWY3DPEHPK3PXP"
37+
val result = useCase(url)
38+
assertTrue(result is Result.Success)
39+
val info = (result as Result.Success).success
40+
assertEquals("JBSWY3DPEHPK3PXP", info.secret)
41+
//TODO: extract issue and acc name from a string that is not separated by a ":"
42+
// --> assertEquals("example.com", info.issuer)
43+
assertEquals("example.com (alice@gmail.com)", info.accountName)
44+
assertEquals(Algorithm.SHA1, info.algorithm)
45+
assertEquals(6, info.digits)
46+
assertEquals(30, info.period)
47+
}
48+
3449
@Test
3550
fun `missing secret returns no secret provided error`() {
3651
val url = "otpauth://totp/Example:alice@google.com?issuer=Example"

0 commit comments

Comments
 (0)