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
|
||||
- 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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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.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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
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: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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -194,7 +194,6 @@
|
|||
<string name="git_push_generic_error">Poussée rejetée par le dépôt distant, raison:</string>
|
||||
<string name="git_push_other_error">Pousser au dépôt distant sans avance rapide rejetée. Vérifiez la variable receive.denyNonFastForwards dans le fichier de configuration du répertoire de destination.</string>
|
||||
<string name="jgit_error_push_dialog_text">Une erreur s\'est produite lors de l\'opération de poussée:</string>
|
||||
<string name="hotp_remember_clear_choice">Effacer les préférences enregistrées pour l’incrémentation HOTP</string>
|
||||
<string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string>
|
||||
<string name="hackish_tools">Outils de hack</string>
|
||||
<string name="commit_hash">Commettre la clé</string>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: [
|
||||
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',
|
||||
|
|
Loading…
Reference in a new issue