summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorHarsh Shandilya <msfjarvis@gmail.com>2020-06-29 12:08:59 +0530
committerGitHub <noreply@github.com>2020-06-29 12:08:59 +0530
commit063c1a1144bb50845ecfb7d56eea16e4db4540e4 (patch)
treeeb237a470953264a54f2854f0e534bb2ab682927 /app/src
parent56c301dc7c5353d7f7021e04441104cfe42c063f (diff)
Reintroduce TOTP support (#890)
Co-authored-by: Fabian Henneke <fabian@henneke.me>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt39
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/PasswordEntry.kt78
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/AutofillService.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillHelper.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt2
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt10
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt49
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt6
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt113
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt88
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt32
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt57
-rw-r--r--app/src/main/res/layout/decrypt_layout.xml27
-rw-r--r--app/src/main/res/values-es/strings.xml1
-rw-r--r--app/src/main/res/values-fr/strings.xml1
-rw-r--r--app/src/main/res/values-ru/strings.xml1
-rw-r--r--app/src/main/res/values/strings.xml4
-rw-r--r--app/src/test/java/com/zeapo/pwdstore/PasswordEntryTest.kt63
-rw-r--r--app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt107
-rw-r--r--app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt50
20 files changed, 569 insertions, 163 deletions
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)
+ }
+}