mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 02:15:53 +03:00
Add logic for parting a TOTP code from a Uri or Intent (#4032)
This commit is contained in:
parent
78d14547e4
commit
a5cf4f49d7
5 changed files with 276 additions and 0 deletions
|
@ -0,0 +1,55 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the data for TOTP deeplink.
|
||||||
|
*
|
||||||
|
* @property uri The raw uri as a string.
|
||||||
|
* @property issuer The issuer parameter is a string value indicating the provider or service this
|
||||||
|
* account is associated with, URL-encoded according to RFC 3986.
|
||||||
|
* @property accountName The users email address.
|
||||||
|
* @property secret The secret parameter is an arbitrary key value encoded in Base32 according to
|
||||||
|
* RFC 3548. The padding specified in RFC 3548 section 2.2 is not required and should be omitted.
|
||||||
|
* @property digits The digits parameter may have the values 6 or 8, and determines how long of a
|
||||||
|
* one-time passcode to display to the user.
|
||||||
|
* @property period The period parameter defines a period that a TOTP code will be valid for, in
|
||||||
|
* seconds.
|
||||||
|
* @property algorithm The algorithm may have the values.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class TotpData(
|
||||||
|
val uri: String,
|
||||||
|
val issuer: String?,
|
||||||
|
val accountName: String?,
|
||||||
|
val secret: String,
|
||||||
|
val digits: Int,
|
||||||
|
val period: Int,
|
||||||
|
val algorithm: CryptoHashAlgorithm,
|
||||||
|
) : Parcelable {
|
||||||
|
/**
|
||||||
|
* A representation of the various cryptographic hash algorithms used by TOTP.
|
||||||
|
*/
|
||||||
|
enum class CryptoHashAlgorithm(val value: String) {
|
||||||
|
SHA_1(value = "sha1"),
|
||||||
|
SHA_256(value = "sha256"),
|
||||||
|
SHA_512(value = "sha512"),
|
||||||
|
MD_5(value = "md5"),
|
||||||
|
;
|
||||||
|
|
||||||
|
@Suppress("UndocumentedPublicClass")
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Attempts to convert the string [value] to a valid [CryptoHashAlgorithm] or null if
|
||||||
|
* a match could not be found.
|
||||||
|
*/
|
||||||
|
fun parse(
|
||||||
|
value: String?,
|
||||||
|
): CryptoHashAlgorithm? =
|
||||||
|
CryptoHashAlgorithm
|
||||||
|
.entries
|
||||||
|
.firstOrNull { it.value.equals(other = value, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.util
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import com.x8bit.bitwarden.ui.vault.model.TotpData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given [Intent] contains data for a TOTP. The [TotpData] will be returned when the
|
||||||
|
* correct data is present or `null` if data is invalid or missing.
|
||||||
|
*/
|
||||||
|
fun Intent.getTotpDataOrNull(): TotpData? = this.data?.getTotpDataOrNull()
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.util
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.x8bit.bitwarden.ui.vault.model.TotpData
|
||||||
|
|
||||||
|
private const val TOTP_HOST_NAME: String = "totp"
|
||||||
|
private const val TOTP_SCHEME_NAME: String = "otpauth"
|
||||||
|
private const val PARAM_NAME_ALGORITHM: String = "algorithm"
|
||||||
|
private const val PARAM_NAME_DIGITS: String = "digits"
|
||||||
|
private const val PARAM_NAME_ISSUER: String = "issuer"
|
||||||
|
private const val PARAM_NAME_PERIOD: String = "period"
|
||||||
|
private const val PARAM_NAME_SECRET: String = "secret"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given [Uri] contains valid data for a TOTP. The [TotpData] will be returned when
|
||||||
|
* the correct data is present or `null` if data is invalid or missing.
|
||||||
|
*/
|
||||||
|
fun Uri.getTotpDataOrNull(): TotpData? {
|
||||||
|
// Must be a "otpauth" scheme
|
||||||
|
if (!this.scheme.equals(other = TOTP_SCHEME_NAME, ignoreCase = true)) return null
|
||||||
|
// Must be a "totp" host
|
||||||
|
if (!this.host.equals(other = TOTP_HOST_NAME, ignoreCase = true)) return null
|
||||||
|
// Must contain a "secret"
|
||||||
|
val secret = this.getQueryParameter(PARAM_NAME_SECRET)?.trim() ?: return null
|
||||||
|
val segments = this.pathSegments?.firstOrNull()?.split(":")
|
||||||
|
val segmentCount = segments?.size ?: 0
|
||||||
|
return TotpData(
|
||||||
|
uri = this.toString(),
|
||||||
|
issuer = this.getQueryParameter(PARAM_NAME_ISSUER)
|
||||||
|
?: segments?.firstOrNull()?.trim()?.takeIf { segmentCount > 1 },
|
||||||
|
accountName = if (segmentCount > 1) {
|
||||||
|
segments?.getOrNull(index = 1)?.trim()
|
||||||
|
} else {
|
||||||
|
segments?.firstOrNull()?.trim()
|
||||||
|
},
|
||||||
|
secret = secret,
|
||||||
|
digits = this.getQueryParameter(PARAM_NAME_DIGITS)?.trim()?.toIntOrNull() ?: 6,
|
||||||
|
period = this
|
||||||
|
.getQueryParameter(PARAM_NAME_PERIOD)
|
||||||
|
?.trim()
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.takeUnless { it <= 0 }
|
||||||
|
?: 30,
|
||||||
|
algorithm = TotpData.CryptoHashAlgorithm
|
||||||
|
.parse(value = this.getQueryParameter(PARAM_NAME_ALGORITHM)?.trim())
|
||||||
|
?: TotpData.CryptoHashAlgorithm.SHA_1,
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.util
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import com.x8bit.bitwarden.ui.vault.model.TotpData
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkStatic
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class TotpIntentUtilsTest {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
mockkStatic(Uri::getTotpDataOrNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkStatic(Uri::getTotpDataOrNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with null data should return null`() {
|
||||||
|
val intent = mockk<Intent> {
|
||||||
|
every { data } returns null
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(intent.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with null uri getTotpDataOrNull should return null`() {
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { getTotpDataOrNull() } returns null
|
||||||
|
}
|
||||||
|
val intent = mockk<Intent> {
|
||||||
|
every { data } returns uri
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(intent.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with valid uri getTotpDataOrNull should return totpData`() {
|
||||||
|
val totpData = mockk<TotpData>()
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { getTotpDataOrNull() } returns totpData
|
||||||
|
}
|
||||||
|
val intent = mockk<Intent> {
|
||||||
|
every { data } returns uri
|
||||||
|
}
|
||||||
|
println(intent)
|
||||||
|
|
||||||
|
assertEquals(totpData, intent.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
package com.x8bit.bitwarden.ui.vault.util
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.x8bit.bitwarden.ui.vault.model.TotpData
|
||||||
|
import com.x8bit.bitwarden.ui.vault.model.TotpData.CryptoHashAlgorithm
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class TotpUriUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with incorrect scheme returns null`() {
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { scheme } returns "wrong"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(uri.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with incorrect host returns null`() {
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { scheme } returns "otpauth"
|
||||||
|
every { host } returns "hotp"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(uri.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull without secret returns null`() {
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { scheme } returns "otpauth"
|
||||||
|
every { host } returns "totp"
|
||||||
|
every { getQueryParameter("secret") } returns null
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull(uri.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with minimum required values returns TotpData with defaults`() {
|
||||||
|
val secret = "secret"
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { scheme } returns "otpauth"
|
||||||
|
every { host } returns "totp"
|
||||||
|
every { pathSegments } returns emptyList()
|
||||||
|
every { getQueryParameter("secret") } returns secret
|
||||||
|
every { getQueryParameter("digits") } returns null
|
||||||
|
every { getQueryParameter("issuer") } returns null
|
||||||
|
every { getQueryParameter("period") } returns null
|
||||||
|
every { getQueryParameter("algorithm") } returns null
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedResult = TotpData(
|
||||||
|
uri = uri.toString(),
|
||||||
|
issuer = null,
|
||||||
|
accountName = null,
|
||||||
|
secret = secret,
|
||||||
|
digits = 6,
|
||||||
|
period = 30,
|
||||||
|
algorithm = CryptoHashAlgorithm.SHA_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedResult, uri.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTotpDataOrNull with complete values returns custom TotpData`() {
|
||||||
|
val secret = "secret"
|
||||||
|
val digits = 8
|
||||||
|
val issuer = "Bitwarden"
|
||||||
|
val period = 25
|
||||||
|
val algorithm = "sha256"
|
||||||
|
val accountName = "test@bitwarden.com"
|
||||||
|
val uri = mockk<Uri> {
|
||||||
|
every { scheme } returns "otpauth"
|
||||||
|
every { host } returns "totp"
|
||||||
|
every { pathSegments } returns listOf("$issuer:$accountName")
|
||||||
|
every { getQueryParameter("secret") } returns secret
|
||||||
|
every { getQueryParameter("digits") } returns digits.toString()
|
||||||
|
every { getQueryParameter("issuer") } returns issuer
|
||||||
|
every { getQueryParameter("period") } returns period.toString()
|
||||||
|
every { getQueryParameter("algorithm") } returns algorithm
|
||||||
|
}
|
||||||
|
val expectedResult = TotpData(
|
||||||
|
uri = uri.toString(),
|
||||||
|
issuer = issuer,
|
||||||
|
accountName = accountName,
|
||||||
|
secret = secret,
|
||||||
|
digits = digits,
|
||||||
|
period = period,
|
||||||
|
algorithm = CryptoHashAlgorithm.SHA_256,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedResult, uri.getTotpDataOrNull())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue