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
- Error message for wrong SSH/HTTPS password now looks cleaner
### Added
- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged.
## [1.9.1] - 2020-06-28
### Fixed

View file

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

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.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

View file

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

View file

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

View file

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

View file

@ -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()) {
if (entry.extraContentWithoutAuthData.isNotEmpty()) {
extraContentContainer.visibility = View.VISIBLE
extraContent.typeface = monoTypeface
extraContent.setText(entry.extraContentWithoutUsername)
extraContent.setText(entry.extraContentWithoutAuthData)
if (!showExtraContent) {
extraContent.transformationMethod = PasswordTransformationMethod.getInstance()
}
extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) }
extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) }
extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) }
}
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)
}
}
}
}
}

View file

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

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: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"
@ -90,6 +90,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"
@ -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

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_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>

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_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 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="hackish_tools">Outils de hack</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_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>

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_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>

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: [
bouncycastle: 'org.bouncycastle:bcprov-jdk15on:1.65.01',
commons_codec: 'commons-codec:commons-codec:1.13',
fastscroll: 'me.zhanghai.android.fastscroll:library:1.1.4',
jsch: 'com.jcraft:jsch:0.1.55',
jgit: 'org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r',