app: switch to format-common's PasswordEntry

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Harsh Shandilya 2021-04-18 04:12:13 +05:30
parent a0fdd6ddc3
commit a3ebcfcc62
No known key found for this signature in database
GPG key ID: 366D7BBAD1031E80
11 changed files with 39 additions and 714 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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