diff options
author | Harsh Shandilya <msfjarvis@gmail.com> | 2020-06-29 12:08:59 +0530 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-06-29 12:08:59 +0530 |
commit | 063c1a1144bb50845ecfb7d56eea16e4db4540e4 (patch) | |
tree | eb237a470953264a54f2854f0e534bb2ab682927 | |
parent | 56c301dc7c5353d7f7021e04441104cfe42c063f (diff) |
Reintroduce TOTP support (#890)
Co-authored-by: Fabian Henneke <fabian@henneke.me>
23 files changed, 575 insertions, 163 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 59fe0722..eaf175e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. - Folder names that were very long did not look right - Error message for wrong SSH/HTTPS password now looks cleaner +### Added + +- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged. + ## [1.9.1] - 2020-06-28 ### Fixed diff --git a/app/build.gradle b/app/build.gradle index cff5b193..d9092896 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -92,6 +92,7 @@ dependencies { implementation deps.kotlin.coroutines.android implementation deps.kotlin.coroutines.core + implementation deps.third_party.commons_codec implementation deps.third_party.fastscroll implementation(deps.third_party.jgit) { exclude group: 'org.apache.httpcomponents', module: 'httpclient' diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt new file mode 100644 index 00000000..3397ed0d --- /dev/null +++ b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +import org.junit.Test +import kotlin.test.assertEquals + +class UriTotpFinderTest { + + private val totpFinder = UriTotpFinder() + + @Test + fun findSecret() { + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI)) + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")) + } + + @Test + fun findDigits() { + assertEquals("12", totpFinder.findDigits(TOTP_URI)) + } + + @Test + fun findPeriod() { + assertEquals(25, totpFinder.findPeriod(TOTP_URI)) + } + + @Test + fun findAlgorithm() { + assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI)) + } + + companion object { + const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25" + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt deleted file mode 100644 index d9168d39..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import java.io.ByteArrayOutputStream -import java.io.UnsupportedEncodingException - -/** - * A single entry in password store. - */ -class PasswordEntry(content: String) { - - val password: String - val username: String? - var extraContent: String - private set - - @Throws(UnsupportedEncodingException::class) - constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8")) - - init { - val passContent = content.split("\n".toRegex(), 2).toTypedArray() - password = passContent[0] - extraContent = findExtraContent(passContent) - username = findUsername() - } - - fun hasExtraContent(): Boolean { - return extraContent.isNotEmpty() - } - - fun hasUsername(): Boolean { - return username != null - } - - val extraContentWithoutUsername by lazy { - var usernameFound = false - extraContent.splitToSequence("\n").filter { line -> - if (usernameFound) - return@filter true - if (USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) }) { - usernameFound = true - return@filter false - } - true - }.joinToString(separator = "\n") - } - - 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 findExtraContent(passContent: Array<String>): String { - return if (passContent.size > 1) passContent[1] else "" - } - - companion object { - val USERNAME_FIELDS = arrayOf( - "login:", - "username:", - "user:", - "account:", - "email:", - "name:", - "handle:", - "id:", - "identity:" - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt index 26c86fc8..a58bde69 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt @@ -28,8 +28,8 @@ import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.i import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.splitLines import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt index 101f96a0..d417484b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt @@ -19,8 +19,8 @@ import androidx.annotation.RequiresApi import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.utils.PasswordRepository import java.io.File import java.security.MessageDigest diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt index 4c806dff..b66d068b 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt @@ -16,12 +16,12 @@ import android.widget.Toast import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.Credentials import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.FillableForm +import com.zeapo.pwdstore.model.PasswordEntry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt index 5206a15f..fcadf119 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt @@ -17,6 +17,7 @@ import android.view.WindowManager import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.CallSuper +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.tag @@ -163,6 +164,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) } + /** * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses * can use this when they want to default to sane error handling. @@ -190,12 +192,16 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing * [showSnackbar] as false. */ - fun copyTextToClipboard(text: String?, showSnackbar: Boolean = true) { + fun copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text + ) { val clipboard = clipboard ?: return val clip = ClipData.newPlainText("pgp_handler_result_pm", text) clipboard.setPrimaryClip(clip) if (showSnackbar) { - snackbar(message = resources.getString(R.string.clipboard_copied_text)) + snackbar(message = resources.getString(snackbarTextRes)) } } diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt index b7d7adcd..ad0b16f7 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt @@ -17,17 +17,23 @@ import androidx.activity.result.contract.ActivityResultContracts.StartActivityFo import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.PasswordEntry import com.zeapo.pwdstore.R import com.zeapo.pwdstore.databinding.DecryptLayoutBinding +import com.zeapo.pwdstore.model.PasswordEntry +import com.zeapo.pwdstore.utils.Otp import com.zeapo.pwdstore.utils.viewBinding import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay 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 import java.io.ByteArrayOutputStream import java.io.File +import java.util.Date +import kotlin.time.ExperimentalTime +import kotlin.time.seconds class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { private val binding by viewBinding(DecryptLayoutBinding::inflate) @@ -125,6 +131,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))) } + @OptIn(ExperimentalTime::class) private fun decryptAndVerify(receivedIntent: Intent? = null) { if (api == null) { bindToOpenKeychain(this, openKeychainResult) @@ -163,14 +170,16 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { } if (entry.hasExtraContent()) { - extraContentContainer.visibility = View.VISIBLE - extraContent.typeface = monoTypeface - extraContent.setText(entry.extraContentWithoutUsername) - if (!showExtraContent) { - extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + if (entry.extraContentWithoutAuthData.isNotEmpty()) { + extraContentContainer.visibility = View.VISIBLE + extraContent.typeface = monoTypeface + extraContent.setText(entry.extraContentWithoutAuthData) + if (!showExtraContent) { + extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + } + extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } } - extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } - extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } if (entry.hasUsername()) { usernameText.typeface = monoTypeface @@ -180,6 +189,30 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { } else { usernameTextContainer.visibility = View.GONE } + + if (entry.hasTotp()) { + otpTextContainer.visibility = View.VISIBLE + otpTextContainer.setEndIconOnClickListener { + copyTextToClipboard( + otpText.text.toString(), + snackbarTextRes = R.string.clipboard_otp_copied_text + ) + } + launch(Dispatchers.IO) { + repeat(Int.MAX_VALUE) { + val code = Otp.calculateCode( + entry.totpSecret!!, + Date().time / (1000 * entry.totpPeriod), + entry.totpAlgorithm, + entry.digits + ) ?: "Error" + withContext(Dispatchers.Main) { + otpText.setText(code) + } + delay(30.seconds) + } + } + } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt index 67bf9926..81a73988 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt @@ -17,16 +17,16 @@ import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.PasswordEntry -import com.zeapo.pwdstore.utils.isInsideRepository import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.databinding.PasswordCreationActivityBinding +import com.zeapo.pwdstore.model.PasswordEntry import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.commitChange +import com.zeapo.pwdstore.utils.isInsideRepository import com.zeapo.pwdstore.utils.snackbar import com.zeapo.pwdstore.utils.viewBinding import kotlinx.coroutines.Dispatchers @@ -108,7 +108,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB // input lag. if (username != null) { filename.setText(username) - extraContent.setText(entry.extraContentWithoutUsername) + extraContent.setText(entry.extraContentWithoutAuthData) } } updateEncryptUsernameState() diff --git a/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt new file mode 100644 index 00000000..da2d57c6 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt @@ -0,0 +1,113 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.model + +import com.zeapo.pwdstore.utils.TotpFinder +import com.zeapo.pwdstore.utils.UriTotpFinder +import java.io.ByteArrayOutputStream +import java.io.UnsupportedEncodingException + +/** + * 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? + val digits: String + val totpSecret: String? + val totpPeriod: Long + val totpAlgorithm: String + var extraContent: String + private set + + @Throws(UnsupportedEncodingException::class) + constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder()) + + init { + val passContent = content.split("\n".toRegex(), 2).toTypedArray() + password = passContent[0] + extraContent = findExtraContent(passContent) + username = findUsername() + digits = findOtpDigits(content) + totpSecret = findTotpSecret(content) + totpPeriod = findTotpPeriod(content) + totpAlgorithm = findTotpAlgorithm(content) + } + + fun hasExtraContent(): Boolean { + return extraContent.isNotEmpty() + } + + fun hasTotp(): Boolean { + return totpSecret != null + } + + fun hasUsername(): Boolean { + return username != null + } + + val extraContentWithoutAuthData by lazy { + extraContent.splitToSequence("\n").filter { line -> + return@filter when { + USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } -> { + false + } + line.startsWith("otpauth://", ignoreCase = true) || + line.startsWith("totp:", ignoreCase = true) -> { + false + } + else -> { + true + } + } + }.joinToString(separator = "\n") + } + + 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 findExtraContent(passContent: Array<String>): String { + return if (passContent.size > 1) passContent[1] else "" + } + + 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 { + val USERNAME_FIELDS = arrayOf( + "login:", + "username:", + "user:", + "account:", + "email:", + "name:", + "handle:", + "id:", + "identity:" + ) + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt new file mode 100644 index 00000000..b95b9902 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt @@ -0,0 +1,88 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +import com.github.ajalt.timberkt.e +import org.apache.commons.codec.binary.Base32 +import java.nio.ByteBuffer +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import java.util.Locale +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.and + +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): String? { + val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" + val decodedSecret = try { + BASE_32.decode(secret) + } catch (e: Exception) { + e(e) { "Failed to decode secret" } + return null + } + val secretKey = SecretKeySpec(decodedSecret, algo) + val digest = try { + Mac.getInstance(algo).run { + init(secretKey) + doFinal(ByteBuffer.allocate(8).putLong(counter).array()) + } + } catch (e: NoSuchAlgorithmException) { + e(e) + return null + } catch (e: InvalidKeyException) { + e(e) { "Key is malformed" } + return null + } + // 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) + return 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 -> { + e { "Digits specifier has to be either 's' or numeric" } + return null + } + numDigits < 6 -> { + e { "TOTP codes have to be at least 6 digits long" } + return null + } + numDigits > 10 -> { + e { "TOTP codes can be at most 10 digits long" } + return null + } + 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/com/zeapo/pwdstore/utils/TotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt new file mode 100644 index 00000000..13a47543 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt @@ -0,0 +1,32 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +/** + * 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/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt new file mode 100644 index 00000000..faa349d1 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt @@ -0,0 +1,57 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +import android.net.Uri + +/** + * [Uri] backed TOTP URL parser. + */ +class UriTotpFinder : TotpFinder { + override fun findSecret(content: String): String? { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/")) { + return Uri.parse(line).getQueryParameter("secret") + } + if (line.startsWith("totp:", ignoreCase = true)) { + return line.split(": *".toRegex(), 2).toTypedArray()[1] + } + } + return null + } + + override fun findDigits(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/") && + Uri.parse(line).getQueryParameter("digits") != null) { + return Uri.parse(line).getQueryParameter("digits")!! + } + } + return "6" + } + + override fun findPeriod(content: String): Long { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/") && + Uri.parse(line).getQueryParameter("period") != null) { + val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() + if (period != null && period > 0) + return period + } + } + return 30 + } + + override fun findAlgorithm(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith("otpauth://totp/") && + Uri.parse(line).getQueryParameter("algorithm") != null) { + return Uri.parse(line).getQueryParameter("algorithm")!! + } + } + return "sha1" + } +} diff --git a/app/src/main/res/layout/decrypt_layout.xml b/app/src/main/res/layout/decrypt_layout.xml index 664cb482..6e2bf14c 100644 --- a/app/src/main/res/layout/decrypt_layout.xml +++ b/app/src/main/res/layout/decrypt_layout.xml @@ -10,7 +10,7 @@ android:layout_height="match_parent" android:background="?android:attr/windowBackground" android:orientation="vertical" - tools:context="com.zeapo.pwdstore.crypto.PgpActivity"> + tools:context="com.zeapo.pwdstore.crypto.DecryptActivity"> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="fill_parent" @@ -91,6 +91,29 @@ </com.google.android.material.textfield.TextInputLayout> <com.google.android.material.textfield.TextInputLayout + android:id="@+id/otp_text_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:hint="@string/otp" + android:visibility="gone" + app:endIconDrawable="@drawable/ic_content_copy" + app:endIconMode="custom" + app:layout_constraintTop_toBottomOf="@id/password_text_container" + tools:visibility="visible"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/otp_text" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:editable="false" + android:fontFamily="@font/sourcecodepro" + android:textIsSelectable="true" + tools:text="123456" /> + + </com.google.android.material.textfield.TextInputLayout> + + <com.google.android.material.textfield.TextInputLayout android:id="@+id/username_text_container" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -99,7 +122,7 @@ android:visibility="gone" app:endIconDrawable="@drawable/ic_content_copy" app:endIconMode="custom" - app:layout_constraintTop_toBottomOf="@id/password_text_container" + app:layout_constraintTop_toBottomOf="@id/otp_text_container" tools:visibility="visible"> <com.google.android.material.textfield.TextInputEditText diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 558b7a26..4eecf660 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -194,7 +194,6 @@ <string name="git_push_nff_error">La subida fue rechazada por el servidor, Ejecuta \'Descargar desde servidor\' antes de subir o pulsa \'Sincronizar con servidor\' para realizar ambas acciones.</string> <string name="git_push_generic_error">El envío fue rechazado por el servidor, la razón:</string> <string name="jgit_error_push_dialog_text">Ocurrió un error durante el envío:</string> - <string name="hotp_remember_clear_choice">Limpiar preferencia para incremento HOTP</string> <string name="git_operation_remember_passphrase">Recordar contraseñagit (inseguro)</string> <string name="hackish_tools">Hackish tools</string> <string name="abort_rebase">Abortar rebase</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 76edb963..c4c75b55 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -194,7 +194,6 @@ <string name="git_push_generic_error">Poussée rejetée par le dépôt distant, raison:</string> <string name="git_push_other_error">Pousser au dépôt distant sans avance rapide rejetée. Vérifiez la variable receive.denyNonFastForwards dans le fichier de configuration du répertoire de destination.</string> <string name="jgit_error_push_dialog_text">Une erreur s\'est produite lors de l\'opération de poussée:</string> - <string name="hotp_remember_clear_choice">Effacer les préférences enregistrées pour l’incrémentation HOTP</string> <string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string> <string name="hackish_tools">Outils de hack</string> <string name="commit_hash">Commettre la clé</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 99217355..ff36585b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -253,7 +253,6 @@ <string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина:</string> <string name="git_push_other_error">Удаленный репозиторий отклонил запись изменений без быстрой перемотки вперед. Проверьте переменную receive.denyNonFastForwards в файле конфигурации репозитория назначения.</string> <string name="jgit_error_push_dialog_text">В хоте операции записи изменений возникла ошибка:</string> - <string name="hotp_remember_clear_choice">Очистить сохраненные настройки для увеличения HOTP</string> <string name="git_operation_remember_passphrase">Заполнить парольную фразу в конфигурации приложнеия (небезопасно)</string> <string name="hackish_tools">Костыльные инструменты</string> <string name="abort_rebase">Прервать перебазирование и записать изменения в новую ветку</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f38a9f..2f04b388 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ <string name="clipboard_password_toast_text">Password copied to clipboard, you have %d seconds to paste it somewhere.</string> <string name="clipboard_password_no_clear_toast_text">Password copied to clipboard</string> <string name="clipboard_copied_text">Copied to clipboard</string> + <string name="clipboard_otp_copied_text">OTP code copied to clipboard</string> <string name="file_toast_text">Please provide a file name</string> <string name="path_toast_text">Please provide a file path</string> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string> @@ -111,6 +112,7 @@ <!-- DECRYPT Layout --> <string name="action_search">Search</string> <string name="password">Password:</string> + <string name="otp">OTP:</string> <string name="extra_content">Extra content:</string> <string name="username">Username:</string> <string name="edit_password">Edit password</string> @@ -118,6 +120,7 @@ <string name="copy_username">Copy username</string> <string name="share_as_plaintext">Share as plaintext</string> <string name="last_changed">Last changed %s</string> + <string name="view_otp">View OTP</string> <!-- Preferences --> <string name="pref_repository_title">Repository</string> @@ -297,7 +300,6 @@ <string name="jgit_error_push_dialog_text">Error occurred during the push operation:</string> <string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string> <string name="clear_saved_passphrase_https">Clear saved HTTPS password</string> - <string name="hotp_remember_clear_choice">Clear saved preference for HOTP incrementing</string> <string name="git_operation_remember_passphrase">Remember key passphrase</string> <string name="hackish_tools">Hackish tools</string> <string name="abort_rebase">Abort rebase and push new branch</string> diff --git a/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt b/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt deleted file mode 100644 index 2074f40b..00000000 --- a/app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class PasswordEntryTest { - @Test fun testGetPassword() { - assertEquals("fooooo", PasswordEntry("fooooo\nbla\n").password) - assertEquals("fooooo", PasswordEntry("fooooo\nbla").password) - assertEquals("fooooo", PasswordEntry("fooooo\n").password) - assertEquals("fooooo", PasswordEntry("fooooo").password) - assertEquals("", PasswordEntry("\nblubb\n").password) - assertEquals("", PasswordEntry("\nblubb").password) - assertEquals("", PasswordEntry("\n").password) - assertEquals("", PasswordEntry("").password) - } - - @Test fun testGetExtraContent() { - assertEquals("bla\n", PasswordEntry("fooooo\nbla\n").extraContent) - assertEquals("bla", PasswordEntry("fooooo\nbla").extraContent) - assertEquals("", PasswordEntry("fooooo\n").extraContent) - assertEquals("", PasswordEntry("fooooo").extraContent) - assertEquals("blubb\n", PasswordEntry("\nblubb\n").extraContent) - assertEquals("blubb", PasswordEntry("\nblubb").extraContent) - assertEquals("", PasswordEntry("\n").extraContent) - assertEquals("", PasswordEntry("").extraContent) - } - - @Test fun testGetUsername() { - for (field in PasswordEntry.USERNAME_FIELDS) { - assertEquals("username", PasswordEntry("\n$field username").username) - assertEquals("username", PasswordEntry("\n${field.toUpperCase()} username").username) - } - assertEquals( - "username", - PasswordEntry("secret\nextra\nlogin: username\ncontent\n").username) - assertEquals( - "username", - PasswordEntry("\nextra\nusername: username\ncontent\n").username) - assertEquals( - "username", PasswordEntry("\nUSERNaMe: username\ncontent\n").username) - assertEquals("username", PasswordEntry("\nlogin: username").username) - assertEquals("foo@example.com", PasswordEntry("\nemail: foo@example.com").username) - assertEquals("username", PasswordEntry("\nidentity: username\nlogin: another_username").username) - assertEquals("username", PasswordEntry("\nLOGiN:username").username) - assertNull(PasswordEntry("secret\nextra\ncontent\n").username) - } - - @Test fun testHasUsername() { - assertTrue(PasswordEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) - assertFalse(PasswordEntry("secret\nextra\ncontent\n").hasUsername()) - assertFalse(PasswordEntry("secret\nlogin failed\n").hasUsername()) - assertFalse(PasswordEntry("\n").hasUsername()) - assertFalse(PasswordEntry("").hasUsername()) - } -} diff --git a/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt b/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt new file mode 100644 index 00000000..f31709df --- /dev/null +++ b/app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.model + +import com.zeapo.pwdstore.utils.Otp +import com.zeapo.pwdstore.utils.TotpFinder +import org.junit.Test +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +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) + } + + @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 + ) + assertNotNull(code) { "Generated OTP cannot be null" } + assertEquals(entry.digits.toInt(), code.length) + assertEquals("545293", code) + } + + 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/com/zeapo/pwdstore/utils/OtpTest.kt b/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt new file mode 100644 index 00000000..710b0845 --- /dev/null +++ b/app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt @@ -0,0 +1,50 @@ +package com.zeapo.pwdstore.utils + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class OtpTest { + + @Test + fun testOtpGeneration6Digits() { + assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6")) + assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6")) + assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6")) + } + + @Test + fun testOtpGeneration10Digits() { + assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10")) + assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10")) + assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10")) + } + + @Test + fun testOtpGenerationIllegalInput() { + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11")) + assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6")) + } + + @Test + fun testOtpGenerationUnusualSecrets() { + assertEquals("127764", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6")) + assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6")) + } + + @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") + val paddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====", 1593367171420 / (1000 * 30), "SHA1", "6") + assertNotNull(unpaddedOtp) + assertNotNull(paddedOtp) + assertEquals(unpaddedOtp, paddedOtp) + } +} diff --git a/dependencies.gradle b/dependencies.gradle index 4030659c..8d9215f0 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -45,6 +45,7 @@ ext.deps = [ third_party: [ bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65.01', + commons_codec: 'commons-codec:commons-codec:1.13', fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.4', jsch: 'com.jcraft:jsch:0.1.55', jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r', |