diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41c561f4..8b2b18f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { compileOnly(libs.androidx.annotation) coreLibraryDesugaring(libs.android.desugarJdkLibs) implementation(projects.autofillParser) + implementation(projects.formatCommon) implementation(projects.openpgpKtx) implementation(libs.androidx.activityKtx) implementation(libs.androidx.appcompat) @@ -74,7 +75,6 @@ dependencies { implementation(libs.aps.zxingAndroidEmbedded) implementation(libs.thirdparty.bouncycastle) - implementation(libs.thirdparty.commons.codec) implementation(libs.thirdparty.eddsa) implementation(libs.thirdparty.fastscroll) implementation(libs.thirdparty.jgit) { diff --git a/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt b/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt deleted file mode 100644 index 97f3539c..00000000 --- a/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.data.password - -import com.github.michaelbull.result.get -import dev.msfjarvis.aps.util.totp.Otp -import dev.msfjarvis.aps.util.totp.UriTotpFinder -import java.util.Date -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import org.junit.Test - -class PasswordEntryAndroidTest { - - private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder()) - - @Test - fun testGetPassword() { - 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) - } - - @Test - fun testGetExtraContent() { - assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent) - assertEquals("bla", makeEntry("fooooo\nbla").extraContent) - assertEquals("", makeEntry("fooooo\n").extraContent) - assertEquals("", makeEntry("fooooo").extraContent) - assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent) - assertEquals("blubb", makeEntry("\nblubb").extraContent) - assertEquals("", makeEntry("\n").extraContent) - assertEquals("", makeEntry("").extraContent) - } - - @Test - fun testGetUsername() { - for (field in PasswordEntry.USERNAME_FIELDS) { - assertEquals("username", makeEntry("\n$field username").username) - assertEquals("username", makeEntry("\n${field.toUpperCase()} 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) - assertNull(makeEntry("secret\nextra\ncontent\n").username) - } - - @Test - fun testHasUsername() { - assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) - assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername()) - assertFalse(makeEntry("secret\nlogin failed\n").hasUsername()) - assertFalse(makeEntry("\n").hasUsername()) - assertFalse(makeEntry("").hasUsername()) - } - - @Test - fun testGeneratesOtpFromTotpUri() { - val entry = makeEntry("secret\nextra\n$TOTP_URI") - assertTrue(entry.hasTotp()) - val code = - Otp.calculateCode( - entry.totpSecret!!, - // The hardcoded date value allows this test to stay reproducible. - Date(8640000).time / (1000 * entry.totpPeriod), - entry.totpAlgorithm, - entry.digits - ) - .get() - assertNotNull(code) { "Generated OTP cannot be null" } - assertEquals(entry.digits.toInt(), code.length) - assertEquals("545293", code) - } - - @Test - fun testGeneratesOtpWithOnlyUriInFile() { - val entry = makeEntry(TOTP_URI) - assertTrue(entry.password.isEmpty()) - assertTrue(entry.hasTotp()) - val code = - Otp.calculateCode( - entry.totpSecret!!, - // The hardcoded date value allows this test to stay reproducible. - Date(8640000).time / (1000 * entry.totpPeriod), - entry.totpAlgorithm, - entry.digits - ) - .get() - assertNotNull(code) { "Generated OTP cannot be null" } - assertEquals(entry.digits.toInt(), code.length) - assertEquals("545293", code) - } - - @Test - fun testOnlyLooksForUriInFirstLine() { - val entry = makeEntry("id:\n$TOTP_URI") - assertTrue(entry.password.isNotEmpty()) - assertTrue(entry.hasTotp()) - assertFalse(entry.hasUsername()) - } - - companion object { - - const val TOTP_URI = - "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt deleted file mode 100644 index 8a2ca3c6..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.data.password - -import androidx.annotation.VisibleForTesting -import com.github.michaelbull.result.get -import dev.msfjarvis.aps.util.totp.Otp -import dev.msfjarvis.aps.util.totp.TotpFinder -import dev.msfjarvis.aps.util.totp.UriTotpFinder -import java.io.ByteArrayOutputStream -import java.util.Date - -/** - * A single entry in password store. [totpFinder] is an implementation of [TotpFinder] that let's us - * abstract out the Android-specific part and continue testing the class in the JVM. - */ -class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) { - - val password: String - val username: String? - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String? - val totpPeriod: Long - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String - val extraContent: String - val extraContentWithoutAuthData: String - val extraContentMap: Map - - constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder()) - - init { - val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex())) - password = foundPassword - extraContent = passContent.joinToString("\n") - extraContentWithoutAuthData = generateExtraContentWithoutAuthData() - extraContentMap = generateExtraContentPairs() - username = findUsername() - digits = findOtpDigits(content) - totpSecret = findTotpSecret(content) - totpPeriod = findTotpPeriod(content) - totpAlgorithm = findTotpAlgorithm(content) - } - - fun hasExtraContent(): Boolean { - return extraContent.isNotEmpty() - } - - fun hasExtraContentWithoutAuthData(): Boolean { - return extraContentWithoutAuthData.isNotEmpty() - } - - fun hasTotp(): Boolean { - return totpSecret != null - } - - fun hasUsername(): Boolean { - return username != null - } - - fun calculateTotpCode(): String? { - if (totpSecret == null) return null - return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get() - } - - private fun generateExtraContentWithoutAuthData(): String { - var foundUsername = false - return extraContent - .lineSequence() - .filter { line -> - return@filter when { - USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> { - foundUsername = true - false - } - line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> { - false - } - else -> { - true - } - } - } - .joinToString(separator = "\n") - } - - private fun generateExtraContentPairs(): Map { - fun MutableMap.putOrAppend(key: String, value: String) { - if (value.isEmpty()) return - val existing = this[key] - this[key] = - if (existing == null) { - value - } else { - "$existing\n$value" - } - } - - val items = mutableMapOf() - // Take extraContentWithoutAuthData and onEach line perform the following tasks - extraContentWithoutAuthData.lines().forEach { line -> - // Split the line on ':' and save all the parts into an array - // "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"] - val splitArray = line.split(":") - // Take the first element of the array. This will be the key for the key-value pair. - // ["ABC ", " DEF", "GHI"] -> key = "ABC" - val key = splitArray.first().trimEnd() - // Remove the first element from the array and join the rest of the string again with - // ':' as separator. - // ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI" - val value = splitArray.drop(1).joinToString(":").trimStart() - - if (key.isNotEmpty() && value.isNotEmpty()) { - // If both key and value are not empty, we can form a pair with this so add it to - // the map. - // key = "ABC", value = "DEF:GHI" - items[key] = value - } else { - // If either key or value is empty, we were not able to form proper key-value pair. - // So append the original line into an "EXTRA CONTENT" map entry - items.putOrAppend(EXTRA_CONTENT, line) - } - } - - return items - } - - private fun findUsername(): String? { - extraContent.splitToSequence("\n").forEach { line -> - for (prefix in USERNAME_FIELDS) { - if (line.startsWith(prefix, ignoreCase = true)) return line.substring(prefix.length).trimStart() - } - } - return null - } - - private fun findAndStripPassword(passContent: List): Pair> { - if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent) - for (line in passContent) { - for (prefix in PASSWORD_FIELDS) { - if (line.startsWith(prefix, ignoreCase = true)) { - return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line)) - } - } - } - return Pair(passContent[0], passContent.minus(passContent[0])) - } - - private fun findTotpSecret(decryptedContent: String): String? { - return totpFinder.findSecret(decryptedContent) - } - - private fun findOtpDigits(decryptedContent: String): String { - return totpFinder.findDigits(decryptedContent) - } - - private fun findTotpPeriod(decryptedContent: String): Long { - return totpFinder.findPeriod(decryptedContent) - } - - private fun findTotpAlgorithm(decryptedContent: String): String { - return totpFinder.findAlgorithm(decryptedContent) - } - - companion object { - - private const val EXTRA_CONTENT = "Extra Content" - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - val USERNAME_FIELDS = - arrayOf( - "login:", - "username:", - "user:", - "account:", - "email:", - "name:", - "handle:", - "id:", - "identity:", - ) - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - val PASSWORD_FIELDS = - arrayOf( - "password:", - "secret:", - "pass:", - ) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt index ab1f402d..7a36899b 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt @@ -16,6 +16,7 @@ import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e import com.github.androidpasswordstore.autofillparser.AutofillAction @@ -24,7 +25,8 @@ import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess import com.github.michaelbull.result.runCatching -import dev.msfjarvis.aps.data.password.PasswordEntry +import dagger.hilt.android.AndroidEntryPoint +import dev.msfjarvis.aps.injection.password.PasswordEntryFactory import dev.msfjarvis.aps.util.autofill.AutofillPreferences import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder import dev.msfjarvis.aps.util.autofill.DirectoryStructure @@ -33,6 +35,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.InputStream import java.io.OutputStream +import javax.inject.Inject import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -49,6 +52,7 @@ import org.openintents.openpgp.IOpenPgpService2 import org.openintents.openpgp.OpenPgpError @RequiresApi(Build.VERSION_CODES.O) +@AndroidEntryPoint class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { companion object { @@ -77,6 +81,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { } } + @Inject lateinit var passwordEntryFactory: PasswordEntryFactory + private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result -> if (continueAfterUserInteraction != null) { @@ -183,7 +189,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { runCatching { val entry = withContext(Dispatchers.IO) { - @Suppress("BlockingMethodInNonBlockingContext") (PasswordEntry(decryptedOutput)) + @Suppress("BlockingMethodInNonBlockingContext") + passwordEntryFactory.create(lifecycleScope, decryptedOutput.toByteArray()) } AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) } diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt index fe688b40..f50410d4 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt @@ -16,28 +16,34 @@ import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.runCatching +import dagger.hilt.android.AndroidEntryPoint import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.passfile.PasswordEntry import dev.msfjarvis.aps.data.password.FieldItem -import dev.msfjarvis.aps.data.password.PasswordEntry import dev.msfjarvis.aps.databinding.DecryptLayoutBinding +import dev.msfjarvis.aps.injection.password.PasswordEntryFactory import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter import dev.msfjarvis.aps.util.extensions.viewBinding import dev.msfjarvis.aps.util.settings.PreferenceKeys import java.io.ByteArrayOutputStream import java.io.File +import javax.inject.Inject import kotlin.time.ExperimentalTime import kotlin.time.seconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.msfjarvis.openpgpktx.util.OpenPgpApi import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection import org.openintents.openpgp.IOpenPgpService2 +@AndroidEntryPoint class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { private val binding by viewBinding(DecryptLayoutBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntryFactory private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) } private var passwordEntry: PasswordEntry? = null @@ -85,7 +91,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { passwordEntry?.let { entry -> if (menu != null) { menu.findItem(R.id.edit_password).isVisible = true - if (entry.password.isNotEmpty()) { + if (entry.password.isNullOrBlank()) { menu.findItem(R.id.share_password_as_plaintext).isVisible = true menu.findItem(R.id.copy_password).isVisible = true } @@ -136,7 +142,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { intent.putExtra("REPO_PATH", repoPath) intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) - intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent) + intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentWithoutAuthData) intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) startActivity(intent) finish() @@ -172,7 +178,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { startAutoDismissTimer() runCatching { val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) - val entry = PasswordEntry(outputStream) + val entry = passwordEntryFactory.create(lifecycleScope, outputStream.toByteArray()) val items = arrayListOf() val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) } @@ -183,37 +189,25 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { passwordEntry = entry invalidateOptionsMenu() - if (entry.password.isNotEmpty()) { - items.add(FieldItem.createPasswordField(entry.password)) + if (entry.password.isNullOrBlank()) { + items.add(FieldItem.createPasswordField(entry.password!!)) } if (entry.hasTotp()) { launch(Dispatchers.IO) { - // Calculate the actual remaining time for the first pass - // then return to the standard rotation. - val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod) withContext(Dispatchers.Main) { - val code = entry.calculateTotpCode() ?: "Error" + val code = entry.totp.value items.add(FieldItem.createOtpField(code)) } - delay(remainingTime.seconds) - repeat(Int.MAX_VALUE) { - val code = entry.calculateTotpCode() ?: "Error" - withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } - delay(entry.totpPeriod.seconds) - } + entry.totp.collect { code -> withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } } } } - if (!entry.username.isNullOrEmpty()) { - items.add(FieldItem.createUsernameField(entry.username)) + if (!entry.username.isNullOrBlank()) { + items.add(FieldItem.createUsernameField(entry.username!!)) } - if (entry.hasExtraContentWithoutAuthData()) { - entry.extraContentMap.forEach { (key, value) -> - items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) - } - } + entry.extraContent.forEach { (key, value) -> items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) } binding.recyclerView.adapter = adapter adapter.updateItems(items) diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt index 7966628f..462dc388 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt @@ -28,10 +28,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import dagger.hilt.android.AndroidEntryPoint import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.password.PasswordEntry import dev.msfjarvis.aps.data.repo.PasswordRepository import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding +import dev.msfjarvis.aps.injection.password.PasswordEntryFactory import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment @@ -49,15 +50,18 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.msfjarvis.openpgpktx.util.OpenPgpApi import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +@AndroidEntryPoint class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntryFactory private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) } @@ -221,7 +225,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB } else { // User wants to disable username encryption, so we extract the // username from the encrypted extras and use it as the filename. - val entry = PasswordEntry("PASSWORD\n${extraContent.text}") + val entry = + passwordEntryFactory.create(lifecycleScope, "PASSWORD\n${extraContent.text}".encodeToByteArray()) val username = entry.username // username should not be null here by the logic in @@ -288,11 +293,11 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB private fun updateViewState() = with(binding) { // Use PasswordEntry to parse extras for username - val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") + val entry = passwordEntryFactory.create(lifecycleScope, "PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) encryptUsername.apply { if (visibility != View.VISIBLE) return@apply val hasUsernameInFileName = filename.text.toString().isNotBlank() - val hasUsernameInExtras = entry.hasUsername() + val hasUsernameInExtras = !entry.username.isNullOrBlank() isEnabled = hasUsernameInFileName xor hasUsernameInExtras isChecked = hasUsernameInExtras } @@ -430,7 +435,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB if (shouldGeneratePassword) { val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) - val entry = PasswordEntry(content) + val entry = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray()) returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) val username = entry.username ?: directoryStructure.getUsernameFor(file) returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt index 6e1fe464..c6cdffed 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt @@ -8,7 +8,7 @@ import android.content.Context import android.os.Build import androidx.annotation.RequiresApi import com.github.androidpasswordstore.autofillparser.Credentials -import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.data.passfile.PasswordEntry import dev.msfjarvis.aps.util.extensions.getString import dev.msfjarvis.aps.util.extensions.sharedPrefs import dev.msfjarvis.aps.util.services.getDefaultUsername @@ -139,6 +139,6 @@ object AutofillPreferences { ): Credentials { // Always give priority to a username stored in the encrypted extras val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername() - return Credentials(username, entry.password, entry.calculateTotpCode()) + return Credentials(username, entry.password, entry.totp.value) } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt deleted file mode 100644 index 1ef155a5..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.totp - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.runCatching -import java.nio.ByteBuffer -import java.util.Locale -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec -import kotlin.experimental.and -import org.apache.commons.codec.binary.Base32 - -object Otp { - - private val BASE_32 = Base32() - private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() - - init { - check(STEAM_ALPHABET.size == 26) - } - - fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching { - val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" - val decodedSecret = BASE_32.decode(secret) - val secretKey = SecretKeySpec(decodedSecret, algo) - val digest = - Mac.getInstance(algo).run { - init(secretKey) - doFinal(ByteBuffer.allocate(8).putLong(counter).array()) - } - // Least significant 4 bits are used as an offset into the digest. - val offset = (digest.last() and 0xf).toInt() - // Extract 32 bits at the offset and clear the most significant bit. - val code = digest.copyOfRange(offset, offset + 4) - code[0] = (0x7f and code[0].toInt()).toByte() - val codeInt = ByteBuffer.wrap(code).int - check(codeInt > 0) - if (digits == "s") { - // Steam - var remainingCodeInt = codeInt - buildString { - repeat(5) { - append(STEAM_ALPHABET[remainingCodeInt % 26]) - remainingCodeInt /= 26 - } - } - } else { - // Base 10, 6 to 10 digits - val numDigits = digits.toIntOrNull() - when { - numDigits == null -> { - return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric")) - } - numDigits < 6 -> { - return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long")) - } - numDigits > 10 -> { - return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long")) - } - else -> { - // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one - // always being 0, 1, or 2. Pad with leading zeroes. - val codeStringBase10 = codeInt.toString(10).padStart(10, '0') - check(codeStringBase10.length == 10) - codeStringBase10.takeLast(numDigits) - } - } - } - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt deleted file mode 100644 index e787fea5..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.totp - -/** Defines a class that can extract relevant parts of a TOTP URL for use by the app. */ -interface TotpFinder { - - /** Get the TOTP secret from the given extra content. */ - fun findSecret(content: String): String? - - /** Get the number of digits required in the final OTP. */ - fun findDigits(content: String): String - - /** Get the TOTP timeout period. */ - fun findPeriod(content: String): Long - - /** Get the algorithm for the TOTP secret. */ - fun findAlgorithm(content: String): String -} diff --git a/app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt b/app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt deleted file mode 100644 index afbe9289..00000000 --- a/app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.data.password - -import com.github.michaelbull.result.get -import dev.msfjarvis.aps.util.totp.Otp -import dev.msfjarvis.aps.util.totp.TotpFinder -import java.util.Date -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import org.junit.Test - -class PasswordEntryTest { - - private fun makeEntry(content: String) = PasswordEntry(content, testFinder) - - @Test - fun testGetPassword() { - 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.toUpperCase()} 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 testGetExtraContent() { - assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent) - assertEquals("bla", makeEntry("fooooo\nbla").extraContent) - assertEquals("", makeEntry("fooooo\n").extraContent) - assertEquals("", makeEntry("fooooo").extraContent) - assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent) - assertEquals("blubb", makeEntry("\nblubb").extraContent) - assertEquals("blubb", makeEntry("blubb\npassword: foo").extraContent) - assertEquals("blubb", makeEntry("password: foo\nblubb").extraContent) - assertEquals("blubb\nusername: bar", makeEntry("blubb\npassword: foo\nusername: bar").extraContent) - assertEquals("", makeEntry("\n").extraContent) - assertEquals("", makeEntry("").extraContent) - } - - @Test - fun parseExtraContentWithoutAuth() { - var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef") - assertEquals(1, entry.extraContentMap.size) - assertTrue(entry.extraContentMap.containsKey("test")) - assertEquals("abcdef", entry.extraContentMap["test"]) - - entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:") - assertEquals(1, entry.extraContentMap.size) - assertTrue(entry.extraContentMap.containsKey("test")) - assertEquals(":abcdef:", entry.extraContentMap["test"]) - - entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::") - assertEquals(1, entry.extraContentMap.size) - assertTrue(entry.extraContentMap.containsKey("test")) - assertEquals("::abc:def::", entry.extraContentMap["test"]) - - entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl") - assertEquals(2, entry.extraContentMap.size) - assertTrue(entry.extraContentMap.containsKey("test2")) - assertEquals("ghijkl", entry.extraContentMap["test2"]) - - entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:") - assertEquals(2, entry.extraContentMap.size) - assertTrue(entry.extraContentMap.containsKey("Extra Content")) - assertEquals(": ghijkl\n mnopqr:", entry.extraContentMap["Extra Content"]) - - entry = makeEntry("username: abc\npassword: abc\n:\n\n") - assertEquals(1, entry.extraContentMap.size) - assertTrue(entry.extraContentMap.containsKey("Extra Content")) - assertEquals(":", entry.extraContentMap["Extra Content"]) - } - - @Test - fun testGetUsername() { - for (field in PasswordEntry.USERNAME_FIELDS) { - assertEquals("username", makeEntry("\n$field username").username) - assertEquals("username", makeEntry("\n${field.toUpperCase()} 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) - assertNull(makeEntry("secret\nextra\ncontent\n").username) - } - - @Test - fun testHasUsername() { - assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) - assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername()) - assertFalse(makeEntry("secret\nlogin failed\n").hasUsername()) - assertFalse(makeEntry("\n").hasUsername()) - assertFalse(makeEntry("").hasUsername()) - } - - @Test - fun testGeneratesOtpFromTotpUri() { - val entry = makeEntry("secret\nextra\n$TOTP_URI") - assertTrue(entry.hasTotp()) - val code = - Otp.calculateCode( - entry.totpSecret!!, - // The hardcoded date value allows this test to stay reproducible. - Date(8640000).time / (1000 * entry.totpPeriod), - entry.totpAlgorithm, - entry.digits - ) - .get() - assertNotNull(code) { "Generated OTP cannot be null" } - assertEquals(entry.digits.toInt(), code.length) - assertEquals("545293", code) - } - - @Test - fun testGeneratesOtpWithOnlyUriInFile() { - val entry = makeEntry(TOTP_URI) - assertTrue(entry.password.isEmpty()) - assertTrue(entry.hasTotp()) - val code = - Otp.calculateCode( - entry.totpSecret!!, - // The hardcoded date value allows this test to stay reproducible. - Date(8640000).time / (1000 * entry.totpPeriod), - entry.totpAlgorithm, - entry.digits - ) - .get() - assertNotNull(code) { "Generated OTP cannot be null" } - assertEquals(entry.digits.toInt(), code.length) - assertEquals("545293", code) - } - - @Test - fun testOnlyLooksForUriInFirstLine() { - val entry = makeEntry("id:\n$TOTP_URI") - assertTrue(entry.password.isNotEmpty()) - assertTrue(entry.hasTotp()) - assertFalse(entry.hasUsername()) - } - - // 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.hasExtraContent()) - assertTrue(entry.hasTotp()) - assertTrue(entry.hasUsername()) - assertEquals("pass", entry.password) - assertEquals("user", entry.username) - assertEquals("id: id", entry.extraContentWithoutAuthData) - } - - companion object { - - const val TOTP_URI = - "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30" - - // This implementation is hardcoded for the URI above. - val testFinder = - object : TotpFinder { - override fun findSecret(content: String): String { - return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ" - } - - override fun findDigits(content: String): String { - return "6" - } - - override fun findPeriod(content: String): Long { - return 30 - } - - override fun findAlgorithm(content: String): String { - return "SHA1" - } - } - } -} diff --git a/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt b/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt deleted file mode 100644 index d41c3be9..00000000 --- a/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.util.totp - -import com.github.michaelbull.result.get -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Test - -class OtpTest { - - @Test - fun testOtpGeneration6Digits() { - assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get()) - assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get()) - assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get()) - } - - @Test - fun testOtpGeneration10Digits() { - assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get()) - assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get()) - assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get()) - } - - @Test - fun testOtpGenerationIllegalInput() { - assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10").get()) - assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a").get()) - assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5").get()) - assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11").get()) - assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6").get()) - } - - @Test - fun testOtpGenerationUnusualSecrets() { - assertEquals( - "127764", - Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get() - ) - assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get()) - } - - @Test - fun testOtpGenerationUnpaddedSecrets() { - // 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 = - Otp.calculateCode( - "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA", - 1593367171420 / (1000 * 30), - "SHA1", - "6" - ) - .get() - val paddedOtp = - Otp.calculateCode( - "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====", - 1593367171420 / (1000 * 30), - "SHA1", - "6" - ) - .get() - assertNotNull(unpaddedOtp) - assertNotNull(paddedOtp) - assertEquals(unpaddedOtp, paddedOtp) - } -}