app: switch to format-common's PasswordEntry
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
a0fdd6ddc3
commit
a3ebcfcc62
11 changed files with 39 additions and 714 deletions
|
@ -49,6 +49,7 @@ dependencies {
|
|||
compileOnly(libs.androidx.annotation)
|
||||
coreLibraryDesugaring(libs.android.desugarJdkLibs)
|
||||
implementation(projects.autofillParser)
|
||||
implementation(projects.formatCommon)
|
||||
implementation(projects.openpgpKtx)
|
||||
implementation(libs.androidx.activityKtx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
@ -74,7 +75,6 @@ dependencies {
|
|||
implementation(libs.aps.zxingAndroidEmbedded)
|
||||
|
||||
implementation(libs.thirdparty.bouncycastle)
|
||||
implementation(libs.thirdparty.commons.codec)
|
||||
implementation(libs.thirdparty.eddsa)
|
||||
implementation(libs.thirdparty.fastscroll)
|
||||
implementation(libs.thirdparty.jgit) {
|
||||
|
|
|
@ -1,122 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package dev.msfjarvis.aps.data.password
|
||||
|
||||
import com.github.michaelbull.result.get
|
||||
import dev.msfjarvis.aps.util.totp.Otp
|
||||
import dev.msfjarvis.aps.util.totp.UriTotpFinder
|
||||
import java.util.Date
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PasswordEntryAndroidTest {
|
||||
|
||||
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
|
||||
|
||||
@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
|
||||
)
|
||||
.get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGeneratesOtpWithOnlyUriInFile() {
|
||||
val entry = makeEntry(TOTP_URI)
|
||||
assertTrue(entry.password.isEmpty())
|
||||
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
|
||||
)
|
||||
.get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnlyLooksForUriInFirstLine() {
|
||||
val entry = makeEntry("id:\n$TOTP_URI")
|
||||
assertTrue(entry.password.isNotEmpty())
|
||||
assertTrue(entry.hasTotp())
|
||||
assertFalse(entry.hasUsername())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TOTP_URI =
|
||||
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
package dev.msfjarvis.aps.data.password
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.github.michaelbull.result.get
|
||||
import dev.msfjarvis.aps.util.totp.Otp
|
||||
import dev.msfjarvis.aps.util.totp.TotpFinder
|
||||
import dev.msfjarvis.aps.util.totp.UriTotpFinder
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* 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?
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String?
|
||||
val totpPeriod: Long
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String
|
||||
val extraContent: String
|
||||
val extraContentWithoutAuthData: String
|
||||
val extraContentMap: Map<String, String>
|
||||
|
||||
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
|
||||
|
||||
init {
|
||||
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
|
||||
password = foundPassword
|
||||
extraContent = passContent.joinToString("\n")
|
||||
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
|
||||
extraContentMap = generateExtraContentPairs()
|
||||
username = findUsername()
|
||||
digits = findOtpDigits(content)
|
||||
totpSecret = findTotpSecret(content)
|
||||
totpPeriod = findTotpPeriod(content)
|
||||
totpAlgorithm = findTotpAlgorithm(content)
|
||||
}
|
||||
|
||||
fun hasExtraContent(): Boolean {
|
||||
return extraContent.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasExtraContentWithoutAuthData(): Boolean {
|
||||
return extraContentWithoutAuthData.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasTotp(): Boolean {
|
||||
return totpSecret != null
|
||||
}
|
||||
|
||||
fun hasUsername(): Boolean {
|
||||
return username != null
|
||||
}
|
||||
|
||||
fun calculateTotpCode(): String? {
|
||||
if (totpSecret == null) return null
|
||||
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
|
||||
}
|
||||
|
||||
private fun generateExtraContentWithoutAuthData(): String {
|
||||
var foundUsername = false
|
||||
return extraContent
|
||||
.lineSequence()
|
||||
.filter { line ->
|
||||
return@filter when {
|
||||
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
|
||||
foundUsername = true
|
||||
false
|
||||
}
|
||||
line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> {
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
private fun generateExtraContentPairs(): Map<String, String> {
|
||||
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
|
||||
if (value.isEmpty()) return
|
||||
val existing = this[key]
|
||||
this[key] =
|
||||
if (existing == null) {
|
||||
value
|
||||
} else {
|
||||
"$existing\n$value"
|
||||
}
|
||||
}
|
||||
|
||||
val items = mutableMapOf<String, String>()
|
||||
// Take extraContentWithoutAuthData and onEach line perform the following tasks
|
||||
extraContentWithoutAuthData.lines().forEach { line ->
|
||||
// Split the line on ':' and save all the parts into an array
|
||||
// "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
|
||||
val splitArray = line.split(":")
|
||||
// Take the first element of the array. This will be the key for the key-value pair.
|
||||
// ["ABC ", " DEF", "GHI"] -> key = "ABC"
|
||||
val key = splitArray.first().trimEnd()
|
||||
// Remove the first element from the array and join the rest of the string again with
|
||||
// ':' as separator.
|
||||
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
|
||||
val value = splitArray.drop(1).joinToString(":").trimStart()
|
||||
|
||||
if (key.isNotEmpty() && value.isNotEmpty()) {
|
||||
// If both key and value are not empty, we can form a pair with this so add it to
|
||||
// the map.
|
||||
// key = "ABC", value = "DEF:GHI"
|
||||
items[key] = value
|
||||
} else {
|
||||
// If either key or value is empty, we were not able to form proper key-value pair.
|
||||
// So append the original line into an "EXTRA CONTENT" map entry
|
||||
items.putOrAppend(EXTRA_CONTENT, line)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
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 findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
|
||||
if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent)
|
||||
for (line in passContent) {
|
||||
for (prefix in PASSWORD_FIELDS) {
|
||||
if (line.startsWith(prefix, ignoreCase = true)) {
|
||||
return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Pair(passContent[0], passContent.minus(passContent[0]))
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
private const val EXTRA_CONTENT = "Extra Content"
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val USERNAME_FIELDS =
|
||||
arrayOf(
|
||||
"login:",
|
||||
"username:",
|
||||
"user:",
|
||||
"account:",
|
||||
"email:",
|
||||
"name:",
|
||||
"handle:",
|
||||
"id:",
|
||||
"identity:",
|
||||
)
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val PASSWORD_FIELDS =
|
||||
arrayOf(
|
||||
"password:",
|
||||
"secret:",
|
||||
"pass:",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ import androidx.activity.result.IntentSenderRequest
|
|||
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.ajalt.timberkt.d
|
||||
import com.github.ajalt.timberkt.e
|
||||
import com.github.androidpasswordstore.autofillparser.AutofillAction
|
||||
|
@ -24,7 +25,8 @@ import com.github.michaelbull.result.getOrElse
|
|||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.onSuccess
|
||||
import com.github.michaelbull.result.runCatching
|
||||
import dev.msfjarvis.aps.data.password.PasswordEntry
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||
import dev.msfjarvis.aps.util.autofill.AutofillPreferences
|
||||
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
|
||||
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
||||
|
@ -33,6 +35,7 @@ import java.io.ByteArrayOutputStream
|
|||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
@ -49,6 +52,7 @@ import org.openintents.openpgp.IOpenPgpService2
|
|||
import org.openintents.openpgp.OpenPgpError
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
@AndroidEntryPoint
|
||||
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
||||
|
||||
companion object {
|
||||
|
@ -77,6 +81,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
|||
}
|
||||
}
|
||||
|
||||
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||
|
||||
private val decryptInteractionRequiredAction =
|
||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (continueAfterUserInteraction != null) {
|
||||
|
@ -183,7 +189,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
|||
runCatching {
|
||||
val entry =
|
||||
withContext(Dispatchers.IO) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext") (PasswordEntry(decryptedOutput))
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
passwordEntryFactory.create(lifecycleScope, decryptedOutput.toByteArray())
|
||||
}
|
||||
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
||||
}
|
||||
|
|
|
@ -16,28 +16,34 @@ import androidx.lifecycle.lifecycleScope
|
|||
import com.github.ajalt.timberkt.e
|
||||
import com.github.michaelbull.result.onFailure
|
||||
import com.github.michaelbull.result.runCatching
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.msfjarvis.aps.R
|
||||
import dev.msfjarvis.aps.data.passfile.PasswordEntry
|
||||
import dev.msfjarvis.aps.data.password.FieldItem
|
||||
import dev.msfjarvis.aps.data.password.PasswordEntry
|
||||
import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
|
||||
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||
import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
|
||||
import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.seconds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
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
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||
|
||||
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
||||
private var passwordEntry: PasswordEntry? = null
|
||||
|
@ -85,7 +91,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||
passwordEntry?.let { entry ->
|
||||
if (menu != null) {
|
||||
menu.findItem(R.id.edit_password).isVisible = true
|
||||
if (entry.password.isNotEmpty()) {
|
||||
if (entry.password.isNullOrBlank()) {
|
||||
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
|
||||
menu.findItem(R.id.copy_password).isVisible = true
|
||||
}
|
||||
|
@ -136,7 +142,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||
intent.putExtra("REPO_PATH", repoPath)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentWithoutAuthData)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
|
@ -172,7 +178,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||
startAutoDismissTimer()
|
||||
runCatching {
|
||||
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
||||
val entry = PasswordEntry(outputStream)
|
||||
val entry = passwordEntryFactory.create(lifecycleScope, outputStream.toByteArray())
|
||||
val items = arrayListOf<FieldItem>()
|
||||
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
|
||||
|
||||
|
@ -183,37 +189,25 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||
passwordEntry = entry
|
||||
invalidateOptionsMenu()
|
||||
|
||||
if (entry.password.isNotEmpty()) {
|
||||
items.add(FieldItem.createPasswordField(entry.password))
|
||||
if (entry.password.isNullOrBlank()) {
|
||||
items.add(FieldItem.createPasswordField(entry.password!!))
|
||||
}
|
||||
|
||||
if (entry.hasTotp()) {
|
||||
launch(Dispatchers.IO) {
|
||||
// Calculate the actual remaining time for the first pass
|
||||
// then return to the standard rotation.
|
||||
val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
|
||||
withContext(Dispatchers.Main) {
|
||||
val code = entry.calculateTotpCode() ?: "Error"
|
||||
val code = entry.totp.value
|
||||
items.add(FieldItem.createOtpField(code))
|
||||
}
|
||||
delay(remainingTime.seconds)
|
||||
repeat(Int.MAX_VALUE) {
|
||||
val code = entry.calculateTotpCode() ?: "Error"
|
||||
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
|
||||
delay(entry.totpPeriod.seconds)
|
||||
}
|
||||
entry.totp.collect { code -> withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } }
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.username.isNullOrEmpty()) {
|
||||
items.add(FieldItem.createUsernameField(entry.username))
|
||||
if (!entry.username.isNullOrBlank()) {
|
||||
items.add(FieldItem.createUsernameField(entry.username!!))
|
||||
}
|
||||
|
||||
if (entry.hasExtraContentWithoutAuthData()) {
|
||||
entry.extraContentMap.forEach { (key, value) ->
|
||||
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||
}
|
||||
}
|
||||
entry.extraContent.forEach { (key, value) -> items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) }
|
||||
|
||||
binding.recyclerView.adapter = adapter
|
||||
adapter.updateItems(items)
|
||||
|
|
|
@ -28,10 +28,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.zxing.integration.android.IntentIntegrator
|
||||
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.msfjarvis.aps.R
|
||||
import dev.msfjarvis.aps.data.password.PasswordEntry
|
||||
import dev.msfjarvis.aps.data.repo.PasswordRepository
|
||||
import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
|
||||
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||
import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment
|
||||
import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
|
||||
import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment
|
||||
|
@ -49,15 +50,18 @@ import java.io.ByteArrayInputStream
|
|||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||
|
||||
@AndroidEntryPoint
|
||||
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||
|
||||
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
||||
|
@ -221,7 +225,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
|||
} else {
|
||||
// User wants to disable username encryption, so we extract the
|
||||
// username from the encrypted extras and use it as the filename.
|
||||
val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
|
||||
val entry =
|
||||
passwordEntryFactory.create(lifecycleScope, "PASSWORD\n${extraContent.text}".encodeToByteArray())
|
||||
val username = entry.username
|
||||
|
||||
// username should not be null here by the logic in
|
||||
|
@ -288,11 +293,11 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
|||
private fun updateViewState() =
|
||||
with(binding) {
|
||||
// Use PasswordEntry to parse extras for username
|
||||
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
||||
val entry = passwordEntryFactory.create(lifecycleScope, "PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
|
||||
encryptUsername.apply {
|
||||
if (visibility != View.VISIBLE) return@apply
|
||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||
val hasUsernameInExtras = entry.hasUsername()
|
||||
val hasUsernameInExtras = !entry.username.isNullOrBlank()
|
||||
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||
isChecked = hasUsernameInExtras
|
||||
}
|
||||
|
@ -430,7 +435,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
|||
|
||||
if (shouldGeneratePassword) {
|
||||
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
||||
val entry = PasswordEntry(content)
|
||||
val entry = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray())
|
||||
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
||||
val username = entry.username ?: directoryStructure.getUsernameFor(file)
|
||||
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||
|
|
|
@ -8,7 +8,7 @@ import android.content.Context
|
|||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.github.androidpasswordstore.autofillparser.Credentials
|
||||
import dev.msfjarvis.aps.data.password.PasswordEntry
|
||||
import dev.msfjarvis.aps.data.passfile.PasswordEntry
|
||||
import dev.msfjarvis.aps.util.extensions.getString
|
||||
import dev.msfjarvis.aps.util.extensions.sharedPrefs
|
||||
import dev.msfjarvis.aps.util.services.getDefaultUsername
|
||||
|
@ -139,6 +139,6 @@ object AutofillPreferences {
|
|||
): Credentials {
|
||||
// Always give priority to a username stored in the encrypted extras
|
||||
val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
|
||||
return Credentials(username, entry.password, entry.calculateTotpCode())
|
||||
return Credentials(username, entry.password, entry.totp.value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package dev.msfjarvis.aps.util.totp
|
||||
|
||||
import com.github.michaelbull.result.Err
|
||||
import com.github.michaelbull.result.runCatching
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.experimental.and
|
||||
import org.apache.commons.codec.binary.Base32
|
||||
|
||||
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) = runCatching {
|
||||
val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}"
|
||||
val decodedSecret = BASE_32.decode(secret)
|
||||
val secretKey = SecretKeySpec(decodedSecret, algo)
|
||||
val digest =
|
||||
Mac.getInstance(algo).run {
|
||||
init(secretKey)
|
||||
doFinal(ByteBuffer.allocate(8).putLong(counter).array())
|
||||
}
|
||||
// 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)
|
||||
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 -> {
|
||||
return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric"))
|
||||
}
|
||||
numDigits < 6 -> {
|
||||
return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long"))
|
||||
}
|
||||
numDigits > 10 -> {
|
||||
return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long"))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package dev.msfjarvis.aps.util.totp
|
||||
|
||||
/** 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
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
package dev.msfjarvis.aps.data.password
|
||||
|
||||
import com.github.michaelbull.result.get
|
||||
import dev.msfjarvis.aps.util.totp.Otp
|
||||
import dev.msfjarvis.aps.util.totp.TotpFinder
|
||||
import java.util.Date
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
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)
|
||||
for (field in PasswordEntry.PASSWORD_FIELDS) {
|
||||
assertEquals("fooooo", makeEntry("\n$field fooooo").password)
|
||||
assertEquals("fooooo", makeEntry("\n${field.toUpperCase()} fooooo").password)
|
||||
assertEquals("fooooo", makeEntry("GOPASS-SECRET-1.0\n$field fooooo").password)
|
||||
assertEquals("fooooo", makeEntry("someFirstLine\nUsername: bar\n$field fooooo").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("blubb", makeEntry("blubb\npassword: foo").extraContent)
|
||||
assertEquals("blubb", makeEntry("password: foo\nblubb").extraContent)
|
||||
assertEquals("blubb\nusername: bar", makeEntry("blubb\npassword: foo\nusername: bar").extraContent)
|
||||
assertEquals("", makeEntry("\n").extraContent)
|
||||
assertEquals("", makeEntry("").extraContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseExtraContentWithoutAuth() {
|
||||
var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef")
|
||||
assertEquals(1, entry.extraContentMap.size)
|
||||
assertTrue(entry.extraContentMap.containsKey("test"))
|
||||
assertEquals("abcdef", entry.extraContentMap["test"])
|
||||
|
||||
entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:")
|
||||
assertEquals(1, entry.extraContentMap.size)
|
||||
assertTrue(entry.extraContentMap.containsKey("test"))
|
||||
assertEquals(":abcdef:", entry.extraContentMap["test"])
|
||||
|
||||
entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::")
|
||||
assertEquals(1, entry.extraContentMap.size)
|
||||
assertTrue(entry.extraContentMap.containsKey("test"))
|
||||
assertEquals("::abc:def::", entry.extraContentMap["test"])
|
||||
|
||||
entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl")
|
||||
assertEquals(2, entry.extraContentMap.size)
|
||||
assertTrue(entry.extraContentMap.containsKey("test2"))
|
||||
assertEquals("ghijkl", entry.extraContentMap["test2"])
|
||||
|
||||
entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:")
|
||||
assertEquals(2, entry.extraContentMap.size)
|
||||
assertTrue(entry.extraContentMap.containsKey("Extra Content"))
|
||||
assertEquals(": ghijkl\n mnopqr:", entry.extraContentMap["Extra Content"])
|
||||
|
||||
entry = makeEntry("username: abc\npassword: abc\n:\n\n")
|
||||
assertEquals(1, entry.extraContentMap.size)
|
||||
assertTrue(entry.extraContentMap.containsKey("Extra Content"))
|
||||
assertEquals(":", entry.extraContentMap["Extra Content"])
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
.get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGeneratesOtpWithOnlyUriInFile() {
|
||||
val entry = makeEntry(TOTP_URI)
|
||||
assertTrue(entry.password.isEmpty())
|
||||
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
|
||||
)
|
||||
.get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnlyLooksForUriInFirstLine() {
|
||||
val entry = makeEntry("id:\n$TOTP_URI")
|
||||
assertTrue(entry.password.isNotEmpty())
|
||||
assertTrue(entry.hasTotp())
|
||||
assertFalse(entry.hasUsername())
|
||||
}
|
||||
|
||||
// https://github.com/android-password-store/Android-Password-Store/issues/1190
|
||||
@Test
|
||||
fun extraContentWithMultipleUsernameFields() {
|
||||
val entry = makeEntry("pass\nuser: user\nid: id\n$TOTP_URI")
|
||||
assertTrue(entry.hasExtraContent())
|
||||
assertTrue(entry.hasTotp())
|
||||
assertTrue(entry.hasUsername())
|
||||
assertEquals("pass", entry.password)
|
||||
assertEquals("user", entry.username)
|
||||
assertEquals("id: id", entry.extraContentWithoutAuthData)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
/*
|
||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package dev.msfjarvis.aps.util.totp
|
||||
|
||||
import com.github.michaelbull.result.get
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OtpTest {
|
||||
|
||||
@Test
|
||||
fun testOtpGeneration6Digits() {
|
||||
assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get())
|
||||
assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get())
|
||||
assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOtpGeneration10Digits() {
|
||||
assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get())
|
||||
assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get())
|
||||
assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOtpGenerationIllegalInput() {
|
||||
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10").get())
|
||||
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a").get())
|
||||
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5").get())
|
||||
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11").get())
|
||||
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6").get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOtpGenerationUnusualSecrets() {
|
||||
assertEquals(
|
||||
"127764",
|
||||
Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get()
|
||||
)
|
||||
assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get())
|
||||
}
|
||||
|
||||
@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"
|
||||
)
|
||||
.get()
|
||||
val paddedOtp =
|
||||
Otp.calculateCode(
|
||||
"ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====",
|
||||
1593367171420 / (1000 * 30),
|
||||
"SHA1",
|
||||
"6"
|
||||
)
|
||||
.get()
|
||||
assertNotNull(unpaddedOtp)
|
||||
assertNotNull(paddedOtp)
|
||||
assertEquals(unpaddedOtp, paddedOtp)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue