Reintroduce TOTP support (#890)

Co-authored-by: Fabian Henneke <fabian@henneke.me>
This commit is contained in:
Harsh Shandilya 2020-06-29 12:08:59 +05:30 committed by GitHub
parent 56c301dc7c
commit 063c1a1144
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 575 additions and 163 deletions

View file

@ -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 - Folder names that were very long did not look right
- Error message for wrong SSH/HTTPS password now looks cleaner - 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 ## [1.9.1] - 2020-06-28
### Fixed ### Fixed

View file

@ -92,6 +92,7 @@ dependencies {
implementation deps.kotlin.coroutines.android implementation deps.kotlin.coroutines.android
implementation deps.kotlin.coroutines.core implementation deps.kotlin.coroutines.core
implementation deps.third_party.commons_codec
implementation deps.third_party.fastscroll implementation deps.third_party.fastscroll
implementation(deps.third_party.jgit) { implementation(deps.third_party.jgit) {
exclude group: 'org.apache.httpcomponents', module: 'httpclient' exclude group: 'org.apache.httpcomponents', module: 'httpclient'

View file

@ -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"
}
}

View file

@ -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:"
)
}
}

View file

@ -28,8 +28,8 @@ import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.i import com.github.ajalt.timberkt.i
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.splitLines import com.zeapo.pwdstore.utils.splitLines
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View file

@ -19,8 +19,8 @@ import androidx.annotation.RequiresApi
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.Timber.tag
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.model.PasswordEntry
import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PasswordRepository
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest

View file

@ -16,12 +16,12 @@ import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.autofill.oreo.AutofillAction import com.zeapo.pwdstore.autofill.oreo.AutofillAction
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.Credentials import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.autofill.oreo.FillableForm import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.model.PasswordEntry
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob

View file

@ -17,6 +17,7 @@ import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.Timber.tag
@ -163,6 +164,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
} }
/** /**
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses * 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. * 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 * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
* [showSnackbar] as false. * [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 clipboard = clipboard ?: return
val clip = ClipData.newPlainText("pgp_handler_result_pm", text) val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
clipboard.setPrimaryClip(clip) clipboard.setPrimaryClip(clip)
if (showSnackbar) { if (showSnackbar) {
snackbar(message = resources.getString(R.string.clipboard_copied_text)) snackbar(message = resources.getString(snackbarTextRes))
} }
} }

View file

@ -17,17 +17,23 @@ import androidx.activity.result.contract.ActivityResultContracts.StartActivityFo
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.databinding.DecryptLayoutBinding 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 com.zeapo.pwdstore.utils.viewBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.msfjarvis.openpgpktx.util.OpenPgpApi import me.msfjarvis.openpgpktx.util.OpenPgpApi
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
import org.openintents.openpgp.IOpenPgpService2 import org.openintents.openpgp.IOpenPgpService2
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.time.seconds
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
private val binding by viewBinding(DecryptLayoutBinding::inflate) 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))) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
} }
@OptIn(ExperimentalTime::class)
private fun decryptAndVerify(receivedIntent: Intent? = null) { private fun decryptAndVerify(receivedIntent: Intent? = null) {
if (api == null) { if (api == null) {
bindToOpenKeychain(this, openKeychainResult) bindToOpenKeychain(this, openKeychainResult)
@ -163,14 +170,16 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
} }
if (entry.hasExtraContent()) { if (entry.hasExtraContent()) {
if (entry.extraContentWithoutAuthData.isNotEmpty()) {
extraContentContainer.visibility = View.VISIBLE extraContentContainer.visibility = View.VISIBLE
extraContent.typeface = monoTypeface extraContent.typeface = monoTypeface
extraContent.setText(entry.extraContentWithoutUsername) extraContent.setText(entry.extraContentWithoutAuthData)
if (!showExtraContent) { if (!showExtraContent) {
extraContent.transformationMethod = PasswordTransformationMethod.getInstance() extraContent.transformationMethod = PasswordTransformationMethod.getInstance()
} }
extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
}
if (entry.hasUsername()) { if (entry.hasUsername()) {
usernameText.typeface = monoTypeface usernameText.typeface = monoTypeface
@ -180,6 +189,30 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
} else { } else {
usernameTextContainer.visibility = View.GONE 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)
}
}
}
} }
} }

View file

@ -17,16 +17,16 @@ import androidx.core.widget.doOnTextChanged
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e import com.github.ajalt.timberkt.e
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.R
import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences
import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure
import com.zeapo.pwdstore.databinding.PasswordCreationActivityBinding 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.PasswordGeneratorDialogFragment
import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment
import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PasswordRepository
import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.commitChange
import com.zeapo.pwdstore.utils.isInsideRepository
import com.zeapo.pwdstore.utils.snackbar import com.zeapo.pwdstore.utils.snackbar
import com.zeapo.pwdstore.utils.viewBinding import com.zeapo.pwdstore.utils.viewBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -108,7 +108,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
// input lag. // input lag.
if (username != null) { if (username != null) {
filename.setText(username) filename.setText(username)
extraContent.setText(entry.extraContentWithoutUsername) extraContent.setText(entry.extraContentWithoutAuthData)
} }
} }
updateEncryptUsernameState() updateEncryptUsernameState()

View file

@ -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:"
)
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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
}

View file

@ -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"
}
}

View file

@ -10,7 +10,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?android:attr/windowBackground" android:background="?android:attr/windowBackground"
android:orientation="vertical" android:orientation="vertical"
tools:context="com.zeapo.pwdstore.crypto.PgpActivity"> tools:context="com.zeapo.pwdstore.crypto.DecryptActivity">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="fill_parent" android:layout_width="fill_parent"
@ -90,6 +90,29 @@
</com.google.android.material.textfield.TextInputLayout> </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 <com.google.android.material.textfield.TextInputLayout
android:id="@+id/username_text_container" android:id="@+id/username_text_container"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -99,7 +122,7 @@
android:visibility="gone" android:visibility="gone"
app:endIconDrawable="@drawable/ic_content_copy" app:endIconDrawable="@drawable/ic_content_copy"
app:endIconMode="custom" app:endIconMode="custom"
app:layout_constraintTop_toBottomOf="@id/password_text_container" app:layout_constraintTop_toBottomOf="@id/otp_text_container"
tools:visibility="visible"> tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText

View file

@ -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_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="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="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="git_operation_remember_passphrase">Recordar contraseñagit (inseguro)</string>
<string name="hackish_tools">Hackish tools</string> <string name="hackish_tools">Hackish tools</string>
<string name="abort_rebase">Abortar rebase</string> <string name="abort_rebase">Abortar rebase</string>

View file

@ -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_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="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="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 lincré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="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="hackish_tools">Outils de hack</string>
<string name="commit_hash">Commettre la clé</string> <string name="commit_hash">Commettre la clé</string>

View file

@ -253,7 +253,6 @@
<string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина:</string> <string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина:</string>
<string name="git_push_other_error">Удаленный репозиторий отклонил запись изменений без быстрой перемотки вперед. Проверьте переменную receive.denyNonFastForwards в файле конфигурации репозитория назначения.</string> <string name="git_push_other_error">Удаленный репозиторий отклонил запись изменений без быстрой перемотки вперед. Проверьте переменную receive.denyNonFastForwards в файле конфигурации репозитория назначения.</string>
<string name="jgit_error_push_dialog_text">В хоте операции записи изменений возникла ошибка:</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="git_operation_remember_passphrase">Заполнить парольную фразу в конфигурации приложнеия (небезопасно)</string>
<string name="hackish_tools">Костыльные инструменты</string> <string name="hackish_tools">Костыльные инструменты</string>
<string name="abort_rebase">Прервать перебазирование и записать изменения в новую ветку</string> <string name="abort_rebase">Прервать перебазирование и записать изменения в новую ветку</string>

View file

@ -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_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_password_no_clear_toast_text">Password copied to clipboard</string>
<string name="clipboard_copied_text">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="file_toast_text">Please provide a file name</string>
<string name="path_toast_text">Please provide a file path</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> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string>
@ -111,6 +112,7 @@
<!-- DECRYPT Layout --> <!-- DECRYPT Layout -->
<string name="action_search">Search</string> <string name="action_search">Search</string>
<string name="password">Password:</string> <string name="password">Password:</string>
<string name="otp">OTP:</string>
<string name="extra_content">Extra content:</string> <string name="extra_content">Extra content:</string>
<string name="username">Username:</string> <string name="username">Username:</string>
<string name="edit_password">Edit password</string> <string name="edit_password">Edit password</string>
@ -118,6 +120,7 @@
<string name="copy_username">Copy username</string> <string name="copy_username">Copy username</string>
<string name="share_as_plaintext">Share as plaintext</string> <string name="share_as_plaintext">Share as plaintext</string>
<string name="last_changed">Last changed %s</string> <string name="last_changed">Last changed %s</string>
<string name="view_otp">View OTP</string>
<!-- Preferences --> <!-- Preferences -->
<string name="pref_repository_title">Repository</string> <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="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_ssh">Clear saved passphrase for local SSH key</string>
<string name="clear_saved_passphrase_https">Clear saved HTTPS password</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="git_operation_remember_passphrase">Remember key passphrase</string>
<string name="hackish_tools">Hackish tools</string> <string name="hackish_tools">Hackish tools</string>
<string name="abort_rebase">Abort rebase and push new branch</string> <string name="abort_rebase">Abort rebase and push new branch</string>

View file

@ -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())
}
}

View file

@ -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"
}
}
}
}

View file

@ -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)
}
}

View file

@ -45,6 +45,7 @@ ext.deps = [
third_party: [ third_party: [
bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65.01', 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', fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.4',
jsch: 'com.jcraft:jsch:0.1.55', jsch: 'com.jcraft:jsch:0.1.55',
jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r', jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r',