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 /app/src/main/java/com | |
parent | 56c301dc7c5353d7f7021e04441104cfe42c063f (diff) |
Reintroduce TOTP support (#890)
Co-authored-by: Fabian Henneke <fabian@henneke.me>
Diffstat (limited to 'app/src/main/java/com')
11 files changed, 345 insertions, 94 deletions
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" + } +} |