summaryrefslogtreecommitdiff
path: root/format-common
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2022-12-19 16:47:17 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2022-12-19 16:47:17 +0530
commit8cfe6ec84c1bd8119fd59fbb76d729da284ced27 (patch)
tree7035f03675f8924bfc3870b68b6345faf020aaac /format-common
parent39a0284cd55298eb4930db681e813260aea58867 (diff)
refactor: merge format-common-impl into format-common
Also converts the format-common module into an Android library since UriTotpFinder requires the Android SDK.
Diffstat (limited to 'format-common')
-rw-r--r--format-common/build.gradle.kts18
-rw-r--r--format-common/src/main/kotlin/app/passwordstore/util/totp/UriTotpFinder.kt52
-rw-r--r--format-common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt221
-rw-r--r--format-common/src/test/kotlin/app/passwordstore/util/time/TestUserClock.kt33
-rw-r--r--format-common/src/test/kotlin/app/passwordstore/util/totp/OtpTest.kt168
-rw-r--r--format-common/src/test/kotlin/app/passwordstore/util/totp/UriTotpFinderTest.kt60
6 files changed, 549 insertions, 3 deletions
diff --git a/format-common/build.gradle.kts b/format-common/build.gradle.kts
index a42a2404..34b5c69e 100644
--- a/format-common/build.gradle.kts
+++ b/format-common/build.gradle.kts
@@ -5,15 +5,27 @@
@file:Suppress("DSL_SCOPE_VIOLATION", "UnstableApiUsage")
plugins {
- kotlin("jvm")
+ id("com.github.android-password-store.android-library")
+ id("com.github.android-password-store.kotlin-android")
id("com.github.android-password-store.kotlin-library")
}
+android {
+ namespace = "app.passwordstore.format.common"
+ compileOptions { isCoreLibraryDesugaringEnabled = true }
+}
+
dependencies {
+ api(libs.kotlin.coroutines.core)
api(libs.thirdparty.kotlinResult)
+ compileOnly(libs.androidx.annotation)
+ coreLibraryDesugaring(libs.android.desugarJdkLibs)
implementation(projects.coroutineUtils)
- implementation(libs.androidx.annotation)
implementation(libs.dagger.hilt.core)
implementation(libs.thirdparty.commons.codec)
- implementation(libs.kotlin.coroutines.core)
+ testImplementation(projects.coroutineUtilsTesting)
+ testImplementation(libs.bundles.testDependencies)
+ testImplementation(libs.kotlin.coroutines.test)
+ testImplementation(libs.testing.robolectric)
+ testImplementation(libs.testing.turbine)
}
diff --git a/format-common/src/main/kotlin/app/passwordstore/util/totp/UriTotpFinder.kt b/format-common/src/main/kotlin/app/passwordstore/util/totp/UriTotpFinder.kt
new file mode 100644
index 00000000..741a21a7
--- /dev/null
+++ b/format-common/src/main/kotlin/app/passwordstore/util/totp/UriTotpFinder.kt
@@ -0,0 +1,52 @@
+package app.passwordstore.util.totp
+
+import android.net.Uri
+import javax.inject.Inject
+
+/** [Uri] backed TOTP URL parser. */
+public class UriTotpFinder @Inject constructor() : TotpFinder {
+
+ private companion object {
+ private const val DEFAULT_TOTP_PERIOD = 30L
+ }
+
+ override fun findSecret(content: String): String? {
+ content.split("\n".toRegex()).forEach { line ->
+ if (line.startsWith(TotpFinder.TOTP_FIELDS[0])) {
+ return Uri.parse(line).getQueryParameter("secret")
+ }
+ if (line.startsWith(TotpFinder.TOTP_FIELDS[1], ignoreCase = true)) {
+ return line.split(": *".toRegex(), 2).toTypedArray()[1]
+ }
+ }
+ return null
+ }
+
+ override fun findDigits(content: String): String {
+ return getQueryParameter(content, "digits") ?: "6"
+ }
+
+ override fun findPeriod(content: String): Long {
+ return getQueryParameter(content, "period")?.toLongOrNull() ?: DEFAULT_TOTP_PERIOD
+ }
+
+ override fun findAlgorithm(content: String): String {
+ return getQueryParameter(content, "algorithm") ?: "sha1"
+ }
+
+ override fun findIssuer(content: String): String? {
+ return getQueryParameter(content, "issuer") ?: Uri.parse(content).authority
+ }
+
+ private fun getQueryParameter(content: String, parameterName: String): String? {
+ content.split("\n".toRegex()).forEach { line ->
+ val uri = Uri.parse(line)
+ if (
+ line.startsWith(TotpFinder.TOTP_FIELDS[0]) && uri.getQueryParameter(parameterName) != null
+ ) {
+ return uri.getQueryParameter(parameterName)
+ }
+ }
+ return null
+ }
+}
diff --git a/format-common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt b/format-common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt
new file mode 100644
index 00000000..31ced9f3
--- /dev/null
+++ b/format-common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.data.passfile
+
+import app.cash.turbine.Event
+import app.cash.turbine.test
+import app.passwordstore.test.CoroutineTestRule
+import app.passwordstore.util.time.TestUserClock
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import java.util.Locale
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.ExperimentalTime
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
+@RunWith(RobolectricTestRunner::class)
+class PasswordEntryTest {
+
+ @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
+ private val totpFinder = UriTotpFinder()
+
+ private fun makeEntry(content: String, clock: UserClock = fakeClock) =
+ PasswordEntry(
+ clock,
+ totpFinder,
+ content.encodeToByteArray(),
+ )
+
+ @Test
+ fun getPassword() {
+ assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
+ assertEquals("fooooo", makeEntry("fooooo\nbla").password)
+ assertEquals("fooooo", makeEntry("fooooo\n").password)
+ assertEquals("fooooo", makeEntry("fooooo").password)
+ assertEquals("", makeEntry("\nblubb\n").password)
+ assertEquals("", makeEntry("\nblubb").password)
+ assertEquals("", makeEntry("\n").password)
+ assertEquals("", makeEntry("").password)
+ for (field in PasswordEntry.PASSWORD_FIELDS) {
+ assertEquals("fooooo", makeEntry("\n$field fooooo").password)
+ assertEquals("fooooo", makeEntry("\n${field.uppercase(Locale.getDefault())} fooooo").password)
+ assertEquals("fooooo", makeEntry("GOPASS-SECRET-1.0\n$field fooooo").password)
+ assertEquals("fooooo", makeEntry("someFirstLine\nUsername: bar\n$field fooooo").password)
+ }
+ }
+
+ @Test
+ fun getExtraContent() {
+ assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContentString)
+ assertEquals("bla", makeEntry("fooooo\nbla").extraContentString)
+ assertEquals("", makeEntry("fooooo\n").extraContentString)
+ assertEquals("", makeEntry("fooooo").extraContentString)
+ assertEquals("blubb\n", makeEntry("\nblubb\n").extraContentString)
+ assertEquals("blubb", makeEntry("\nblubb").extraContentString)
+ assertEquals("blubb", makeEntry("blubb\npassword: foo").extraContentString)
+ assertEquals("blubb", makeEntry("password: foo\nblubb").extraContentString)
+ assertEquals(
+ "blubb\nusername: bar",
+ makeEntry("blubb\npassword: foo\nusername: bar").extraContentString
+ )
+ assertEquals("", makeEntry("\n").extraContentString)
+ assertEquals("", makeEntry("").extraContentString)
+ }
+
+ @Test
+ fun parseExtraContentWithoutAuth() {
+ var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef")
+ assertEquals(1, entry.extraContent.size)
+ assertTrue(entry.extraContent.containsKey("test"))
+ assertEquals("abcdef", entry.extraContent["test"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:")
+ assertEquals(1, entry.extraContent.size)
+ assertTrue(entry.extraContent.containsKey("test"))
+ assertEquals(":abcdef:", entry.extraContent["test"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::")
+ assertEquals(1, entry.extraContent.size)
+ assertTrue(entry.extraContent.containsKey("test"))
+ assertEquals("::abc:def::", entry.extraContent["test"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl")
+ assertEquals(2, entry.extraContent.size)
+ assertTrue(entry.extraContent.containsKey("test2"))
+ assertEquals("ghijkl", entry.extraContent["test2"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:")
+ assertEquals(2, entry.extraContent.size)
+ assertTrue(entry.extraContent.containsKey("Extra Content"))
+ assertEquals(": ghijkl\n mnopqr:", entry.extraContent["Extra Content"])
+
+ entry = makeEntry("username: abc\npassword: abc\n:\n\n")
+ assertEquals(1, entry.extraContent.size)
+ assertTrue(entry.extraContent.containsKey("Extra Content"))
+ assertEquals(":", entry.extraContent["Extra Content"])
+ }
+
+ @Test
+ fun getUsername() {
+ for (field in PasswordEntry.USERNAME_FIELDS) {
+ assertEquals("username", makeEntry("\n$field username").username)
+ assertEquals(
+ "username",
+ makeEntry("\n${field.uppercase(Locale.getDefault())} username").username
+ )
+ }
+ assertEquals("username", makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
+ assertEquals("username", makeEntry("\nextra\nusername: username\ncontent\n").username)
+ assertEquals("username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
+ assertEquals("username", makeEntry("\nlogin: username").username)
+ assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
+ assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
+ assertEquals("username", makeEntry("\nLOGiN:username").username)
+ assertEquals("foo@example.com", makeEntry("pass\nmail: foo@example.com").username)
+ assertNull(makeEntry("secret\nextra\ncontent\n").username)
+ }
+
+ @Test
+ fun hasUsername() {
+ assertNotNull(makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
+ assertNull(makeEntry("secret\nextra\ncontent\n").username)
+ assertNull(makeEntry("secret\nlogin failed\n").username)
+ assertNull(makeEntry("\n").username)
+ assertNull(makeEntry("").username)
+ }
+
+ @Test
+ fun generatesOtpFromTotpUri() = runTest {
+ val entry = makeEntry("secret\nextra\n$TOTP_URI")
+ assertTrue(entry.hasTotp())
+ entry.totp.test {
+ val otp = expectMostRecentItem()
+ assertEquals("818800", otp.value)
+ assertEquals(30.seconds, otp.remainingTime)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ /**
+ * Same as [generatesOtpFromTotpUri], 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://github.com/Android-Password-Store/Android-Password-Store/issues/1550.
+ */
+ @Test
+ fun generatedOtpHasCorrectRemainingTime() = runTest {
+ val entry = makeEntry("secret\nextra\n$TOTP_URI", TestUserClock.withAddedSeconds(5))
+ assertTrue(entry.hasTotp())
+ entry.totp.test {
+ val otp = expectMostRecentItem()
+ assertEquals("818800", otp.value)
+ assertEquals(25.seconds, otp.remainingTime)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun generatesOtpWithOnlyUriInFile() = runTest {
+ val entry = makeEntry(TOTP_URI)
+ assertNull(entry.password)
+ entry.totp.test {
+ val otp = expectMostRecentItem()
+ assertEquals("818800", otp.value)
+ assertEquals(30.seconds, otp.remainingTime)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun emitsTotpEverySecond() = runTest {
+ val entry = makeEntry(TOTP_URI)
+ entry.totp.test {
+ delay(3000L)
+ val events = cancelAndConsumeRemainingEvents()
+ assertEquals(3, events.size)
+ assertTrue { events.all { event -> event is Event.Item<Totp> } }
+ }
+ }
+
+ @Test
+ fun onlyLooksForUriInFirstLine() {
+ val entry = makeEntry("id:\n$TOTP_URI")
+ assertNotNull(entry.password)
+ assertTrue(entry.hasTotp())
+ assertNull(entry.username)
+ }
+
+ // https://github.com/android-password-store/Android-Password-Store/issues/1190
+ @Test
+ fun extraContentWithMultipleUsernameFields() {
+ val entry = makeEntry("pass\nuser: user\nid: id\n$TOTP_URI")
+ assertTrue(entry.extraContent.isNotEmpty())
+ assertTrue(entry.hasTotp())
+ assertNotNull(entry.username)
+ assertEquals("pass", entry.password)
+ assertEquals("user", entry.username)
+ assertEquals(mapOf("id" to "id"), entry.extraContent)
+ }
+
+ companion object {
+
+ @Suppress("MaxLineLength")
+ const val TOTP_URI =
+ "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
+
+ val fakeClock = TestUserClock()
+ }
+}
diff --git a/format-common/src/test/kotlin/app/passwordstore/util/time/TestUserClock.kt b/format-common/src/test/kotlin/app/passwordstore/util/time/TestUserClock.kt
new file mode 100644
index 00000000..8a860a39
--- /dev/null
+++ b/format-common/src/test/kotlin/app/passwordstore/util/time/TestUserClock.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.time
+
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZoneOffset.UTC
+
+/**
+ * Implementation of [UserClock] that is fixed to [Instant.EPOCH] for deterministic time-based tests
+ */
+class TestUserClock(instant: Instant) : UserClock() {
+
+ constructor() : this(Instant.EPOCH)
+
+ private var clock = fixed(instant, UTC)
+
+ override fun withZone(zone: ZoneId): Clock = clock.withZone(zone)
+
+ override fun getZone(): ZoneId = UTC
+
+ override fun instant(): Instant = clock.instant()
+
+ companion object {
+ fun withAddedSeconds(secondsToAdd: Long): TestUserClock {
+ return TestUserClock(Instant.EPOCH.plusSeconds(secondsToAdd))
+ }
+ }
+}
diff --git a/format-common/src/test/kotlin/app/passwordstore/util/totp/OtpTest.kt b/format-common/src/test/kotlin/app/passwordstore/util/totp/OtpTest.kt
new file mode 100644
index 00000000..54ac492d
--- /dev/null
+++ b/format-common/src/test/kotlin/app/passwordstore/util/totp/OtpTest.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.totp
+
+import com.github.michaelbull.result.get
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class OtpTest {
+
+ private fun generateOtp(
+ counter: Long,
+ secret: String = "JBSWY3DPEHPK3PXP",
+ algorithm: String = "SHA1",
+ digits: String = "6",
+ issuer: String? = null,
+ ): String? {
+ return Otp.calculateCode(secret, counter, algorithm, digits, issuer).get()
+ }
+
+ @Test
+ fun otpGeneration6Digits() {
+ assertEquals(
+ "953550",
+ generateOtp(
+ counter = 1593333298159 / (1000 * 30),
+ )
+ )
+ assertEquals(
+ "275379",
+ generateOtp(
+ counter = 1593333571918 / (1000 * 30),
+ )
+ )
+ assertEquals(
+ "867507",
+ generateOtp(
+ counter = 1593333600517 / (1000 * 57),
+ )
+ )
+ }
+
+ @Test
+ fun otpGeneration10Digits() {
+ assertEquals(
+ "0740900914",
+ generateOtp(
+ counter = 1593333655044 / (1000 * 30),
+ digits = "10",
+ )
+ )
+ assertEquals(
+ "0070632029",
+ generateOtp(
+ counter = 1593333691405 / (1000 * 30),
+ digits = "10",
+ )
+ )
+ assertEquals(
+ "1017265882",
+ generateOtp(
+ counter = 1593333728893 / (1000 * 83),
+ digits = "10",
+ )
+ )
+ }
+
+ @Test
+ fun otpGenerationIllegalInput() {
+ assertNull(
+ generateOtp(
+ counter = 10000,
+ algorithm = "SHA0",
+ digits = "10",
+ )
+ )
+ assertNull(
+ generateOtp(
+ counter = 10000,
+ digits = "a",
+ )
+ )
+ assertNull(
+ generateOtp(
+ counter = 10000,
+ algorithm = "SHA1",
+ digits = "5",
+ )
+ )
+ assertNull(
+ generateOtp(
+ counter = 10000,
+ digits = "11",
+ )
+ )
+ assertNull(
+ generateOtp(
+ counter = 10000,
+ secret = "JBSWY3DPEHPK3PXPAAAAB",
+ digits = "6",
+ )
+ )
+ }
+
+ @Test
+ fun otpGenerationUnusualSecrets() {
+ assertEquals(
+ "127764",
+ generateOtp(
+ counter = 1593367111963 / (1000 * 30),
+ secret = "JBSWY3DPEHPK3PXPAAAAAAAA",
+ )
+ )
+ assertEquals(
+ "047515",
+ generateOtp(
+ counter = 1593367171420 / (1000 * 30),
+ secret = "JBSWY3DPEHPK3PXPAAAAA",
+ )
+ )
+ }
+
+ @Test
+ fun otpGenerationUnpaddedSecrets() {
+ // Secret was generated with `echo 'string with some padding needed' | base32`
+ // We don't care for the resultant OTP's actual value, we just want both the padded and
+ // unpadded variant to generate the same one.
+ val unpaddedOtp =
+ generateOtp(
+ counter = 1593367171420 / (1000 * 30),
+ secret = "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA",
+ )
+ val paddedOtp =
+ generateOtp(
+ counter = 1593367171420 / (1000 * 30),
+ secret = "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====",
+ )
+
+ assertNotNull(unpaddedOtp)
+ assertNotNull(paddedOtp)
+ assertEquals(unpaddedOtp, paddedOtp)
+ }
+
+ @Test
+ fun generateSteamTotp() {
+ val issuerOtp =
+ generateOtp(
+ counter = 48297900 / (1000 * 30),
+ secret = "STK7746GVMCHMNH5FBIAQXGPV3I7ZHRG",
+ issuer = "Steam",
+ )
+ val digitsOtp =
+ generateOtp(
+ counter = 48297900 / (1000 * 30),
+ secret = "STK7746GVMCHMNH5FBIAQXGPV3I7ZHRG",
+ digits = "s",
+ )
+ assertNotNull(issuerOtp)
+ assertNotNull(digitsOtp)
+ assertEquals("6M3CT", issuerOtp)
+ assertEquals("6M3CT", digitsOtp)
+ }
+}
diff --git a/format-common/src/test/kotlin/app/passwordstore/util/totp/UriTotpFinderTest.kt b/format-common/src/test/kotlin/app/passwordstore/util/totp/UriTotpFinderTest.kt
new file mode 100644
index 00000000..c62df0e7
--- /dev/null
+++ b/format-common/src/test/kotlin/app/passwordstore/util/totp/UriTotpFinderTest.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.totp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [23])
+class UriTotpFinderTest {
+
+ private val totpFinder = UriTotpFinder()
+
+ @Test
+ fun findSecret() {
+ assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
+ assertEquals(
+ "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
+ totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")
+ )
+ assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
+ }
+
+ @Test
+ fun findDigits() {
+ assertEquals("12", totpFinder.findDigits(TOTP_URI))
+ assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
+ }
+
+ @Test
+ fun findPeriod() {
+ assertEquals(25, totpFinder.findPeriod(TOTP_URI))
+ assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
+ }
+
+ @Test
+ fun findAlgorithm() {
+ assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
+ assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
+ }
+
+ @Test
+ fun findIssuer() {
+ assertEquals("ACME Co", totpFinder.findIssuer(TOTP_URI))
+ assertEquals("ACME Co", totpFinder.findIssuer(PASS_FILE_CONTENT))
+ }
+
+ companion object {
+
+ const val TOTP_URI =
+ "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
+ const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
+ }
+}