diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/TotpData.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/TotpData.kt new file mode 100644 index 000000000..b6e3dccf5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/model/TotpData.kt @@ -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) } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/util/TotpIntentUtils.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/util/TotpIntentUtils.kt new file mode 100644 index 000000000..d19fd0ef8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/util/TotpIntentUtils.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/util/TotpUriUtils.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/util/TotpUriUtils.kt new file mode 100644 index 000000000..ca9ee78b1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/util/TotpUriUtils.kt @@ -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, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/util/TotpIntentUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/util/TotpIntentUtilsTest.kt new file mode 100644 index 000000000..ace7c1a3f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/util/TotpIntentUtilsTest.kt @@ -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 { + every { data } returns null + } + + assertNull(intent.getTotpDataOrNull()) + } + + @Test + fun `getTotpDataOrNull with null uri getTotpDataOrNull should return null`() { + val uri = mockk { + every { getTotpDataOrNull() } returns null + } + val intent = mockk { + every { data } returns uri + } + + assertNull(intent.getTotpDataOrNull()) + } + + @Test + fun `getTotpDataOrNull with valid uri getTotpDataOrNull should return totpData`() { + val totpData = mockk() + val uri = mockk { + every { getTotpDataOrNull() } returns totpData + } + val intent = mockk { + every { data } returns uri + } + println(intent) + + assertEquals(totpData, intent.getTotpDataOrNull()) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/util/TotpUriUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/util/TotpUriUtilsTest.kt new file mode 100644 index 000000000..f5ac5773a --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/util/TotpUriUtilsTest.kt @@ -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 { + every { scheme } returns "wrong" + } + + assertNull(uri.getTotpDataOrNull()) + } + + @Test + fun `getTotpDataOrNull with incorrect host returns null`() { + val uri = mockk { + every { scheme } returns "otpauth" + every { host } returns "hotp" + } + + assertNull(uri.getTotpDataOrNull()) + } + + @Test + fun `getTotpDataOrNull without secret returns null`() { + val uri = mockk { + 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 { + 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 { + 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()) + } +}