Reintroduce TOTP support (#890)
Co-authored-by: Fabian Henneke <fabian@henneke.me>
This commit is contained in:
parent
56c301dc7c
commit
063c1a1144
23 changed files with 575 additions and 163 deletions
|
@ -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
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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:"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
113
app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt
Normal file
113
app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt
Normal 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:"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
88
app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt
Normal file
88
app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt
Normal file
32
app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt
Normal 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
|
||||||
|
}
|
57
app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt
Normal file
57
app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 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="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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
107
app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt
Normal file
107
app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt
Normal file
50
app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
|
|
Loading…
Reference in a new issue