mirror of
https://github.com/android-password-store/Android-Password-Store
synced 2025-08-30 13:57:47 +00:00
app: switch to format-common's PasswordEntry
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
@@ -49,6 +49,7 @@ dependencies {
|
|||||||
compileOnly(libs.androidx.annotation)
|
compileOnly(libs.androidx.annotation)
|
||||||
coreLibraryDesugaring(libs.android.desugarJdkLibs)
|
coreLibraryDesugaring(libs.android.desugarJdkLibs)
|
||||||
implementation(projects.autofillParser)
|
implementation(projects.autofillParser)
|
||||||
|
implementation(projects.formatCommon)
|
||||||
implementation(projects.openpgpKtx)
|
implementation(projects.openpgpKtx)
|
||||||
implementation(libs.androidx.activityKtx)
|
implementation(libs.androidx.activityKtx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
@@ -74,7 +75,6 @@ dependencies {
|
|||||||
implementation(libs.aps.zxingAndroidEmbedded)
|
implementation(libs.aps.zxingAndroidEmbedded)
|
||||||
|
|
||||||
implementation(libs.thirdparty.bouncycastle)
|
implementation(libs.thirdparty.bouncycastle)
|
||||||
implementation(libs.thirdparty.commons.codec)
|
|
||||||
implementation(libs.thirdparty.eddsa)
|
implementation(libs.thirdparty.eddsa)
|
||||||
implementation(libs.thirdparty.fastscroll)
|
implementation(libs.thirdparty.fastscroll)
|
||||||
implementation(libs.thirdparty.jgit) {
|
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.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.github.ajalt.timberkt.d
|
import com.github.ajalt.timberkt.d
|
||||||
import com.github.ajalt.timberkt.e
|
import com.github.ajalt.timberkt.e
|
||||||
import com.github.androidpasswordstore.autofillparser.AutofillAction
|
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.onFailure
|
||||||
import com.github.michaelbull.result.onSuccess
|
import com.github.michaelbull.result.onSuccess
|
||||||
import com.github.michaelbull.result.runCatching
|
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.AutofillPreferences
|
||||||
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
|
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
|
||||||
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
import dev.msfjarvis.aps.util.autofill.DirectoryStructure
|
||||||
@@ -33,6 +35,7 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
@@ -49,6 +52,7 @@ import org.openintents.openpgp.IOpenPgpService2
|
|||||||
import org.openintents.openpgp.OpenPgpError
|
import org.openintents.openpgp.OpenPgpError
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
@AndroidEntryPoint
|
||||||
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -77,6 +81,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
|
||||||
private val decryptInteractionRequiredAction =
|
private val decryptInteractionRequiredAction =
|
||||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||||
if (continueAfterUserInteraction != null) {
|
if (continueAfterUserInteraction != null) {
|
||||||
@@ -183,7 +189,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val entry =
|
val entry =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext") (PasswordEntry(decryptedOutput))
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
passwordEntryFactory.create(lifecycleScope, decryptedOutput.toByteArray())
|
||||||
}
|
}
|
||||||
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
||||||
}
|
}
|
||||||
|
@@ -16,28 +16,34 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.github.ajalt.timberkt.e
|
import com.github.ajalt.timberkt.e
|
||||||
import com.github.michaelbull.result.onFailure
|
import com.github.michaelbull.result.onFailure
|
||||||
import com.github.michaelbull.result.runCatching
|
import com.github.michaelbull.result.runCatching
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.msfjarvis.aps.R
|
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.FieldItem
|
||||||
import dev.msfjarvis.aps.data.password.PasswordEntry
|
|
||||||
import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
|
import dev.msfjarvis.aps.databinding.DecryptLayoutBinding
|
||||||
|
import dev.msfjarvis.aps.injection.password.PasswordEntryFactory
|
||||||
import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
|
import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter
|
||||||
import dev.msfjarvis.aps.util.extensions.viewBinding
|
import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlin.time.seconds
|
import kotlin.time.seconds
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||||
import org.openintents.openpgp.IOpenPgpService2
|
import org.openintents.openpgp.IOpenPgpService2
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
|
|
||||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||||
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
|
||||||
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
||||||
private var passwordEntry: PasswordEntry? = null
|
private var passwordEntry: PasswordEntry? = null
|
||||||
@@ -85,7 +91,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||||||
passwordEntry?.let { entry ->
|
passwordEntry?.let { entry ->
|
||||||
if (menu != null) {
|
if (menu != null) {
|
||||||
menu.findItem(R.id.edit_password).isVisible = true
|
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.share_password_as_plaintext).isVisible = true
|
||||||
menu.findItem(R.id.copy_password).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("REPO_PATH", repoPath)
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
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)
|
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
finish()
|
finish()
|
||||||
@@ -172,7 +178,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||||||
startAutoDismissTimer()
|
startAutoDismissTimer()
|
||||||
runCatching {
|
runCatching {
|
||||||
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
||||||
val entry = PasswordEntry(outputStream)
|
val entry = passwordEntryFactory.create(lifecycleScope, outputStream.toByteArray())
|
||||||
val items = arrayListOf<FieldItem>()
|
val items = arrayListOf<FieldItem>()
|
||||||
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
|
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
|
||||||
|
|
||||||
@@ -183,37 +189,25 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
|||||||
passwordEntry = entry
|
passwordEntry = entry
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
|
|
||||||
if (entry.password.isNotEmpty()) {
|
if (entry.password.isNullOrBlank()) {
|
||||||
items.add(FieldItem.createPasswordField(entry.password))
|
items.add(FieldItem.createPasswordField(entry.password!!))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.hasTotp()) {
|
if (entry.hasTotp()) {
|
||||||
launch(Dispatchers.IO) {
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
val code = entry.calculateTotpCode() ?: "Error"
|
val code = entry.totp.value
|
||||||
items.add(FieldItem.createOtpField(code))
|
items.add(FieldItem.createOtpField(code))
|
||||||
}
|
}
|
||||||
delay(remainingTime.seconds)
|
entry.totp.collect { code -> withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } }
|
||||||
repeat(Int.MAX_VALUE) {
|
|
||||||
val code = entry.calculateTotpCode() ?: "Error"
|
|
||||||
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
|
|
||||||
delay(entry.totpPeriod.seconds)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entry.username.isNullOrEmpty()) {
|
if (!entry.username.isNullOrBlank()) {
|
||||||
items.add(FieldItem.createUsernameField(entry.username))
|
items.add(FieldItem.createUsernameField(entry.username!!))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.hasExtraContentWithoutAuthData()) {
|
entry.extraContent.forEach { (key, value) -> items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) }
|
||||||
entry.extraContentMap.forEach { (key, value) ->
|
|
||||||
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
adapter.updateItems(items)
|
adapter.updateItems(items)
|
||||||
|
@@ -28,10 +28,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
|
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dev.msfjarvis.aps.R
|
import dev.msfjarvis.aps.R
|
||||||
import dev.msfjarvis.aps.data.password.PasswordEntry
|
|
||||||
import dev.msfjarvis.aps.data.repo.PasswordRepository
|
import dev.msfjarvis.aps.data.repo.PasswordRepository
|
||||||
import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding
|
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.OtpImportDialogFragment
|
||||||
import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
|
import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment
|
||||||
import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment
|
import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment
|
||||||
@@ -49,15 +50,18 @@ import java.io.ByteArrayInputStream
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||||
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
|
|
||||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
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 suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||||
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
||||||
@@ -221,7 +225,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
|||||||
} else {
|
} else {
|
||||||
// User wants to disable username encryption, so we extract the
|
// User wants to disable username encryption, so we extract the
|
||||||
// username from the encrypted extras and use it as the filename.
|
// 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
|
val username = entry.username
|
||||||
|
|
||||||
// username should not be null here by the logic in
|
// username should not be null here by the logic in
|
||||||
@@ -288,11 +293,11 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
|||||||
private fun updateViewState() =
|
private fun updateViewState() =
|
||||||
with(binding) {
|
with(binding) {
|
||||||
// Use PasswordEntry to parse extras for username
|
// 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 {
|
encryptUsername.apply {
|
||||||
if (visibility != View.VISIBLE) return@apply
|
if (visibility != View.VISIBLE) return@apply
|
||||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||||
val hasUsernameInExtras = entry.hasUsername()
|
val hasUsernameInExtras = !entry.username.isNullOrBlank()
|
||||||
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||||
isChecked = hasUsernameInExtras
|
isChecked = hasUsernameInExtras
|
||||||
}
|
}
|
||||||
@@ -430,7 +435,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
|||||||
|
|
||||||
if (shouldGeneratePassword) {
|
if (shouldGeneratePassword) {
|
||||||
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
||||||
val entry = PasswordEntry(content)
|
val entry = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray())
|
||||||
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
||||||
val username = entry.username ?: directoryStructure.getUsernameFor(file)
|
val username = entry.username ?: directoryStructure.getUsernameFor(file)
|
||||||
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||||
|
@@ -8,7 +8,7 @@ import android.content.Context
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.github.androidpasswordstore.autofillparser.Credentials
|
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.getString
|
||||||
import dev.msfjarvis.aps.util.extensions.sharedPrefs
|
import dev.msfjarvis.aps.util.extensions.sharedPrefs
|
||||||
import dev.msfjarvis.aps.util.services.getDefaultUsername
|
import dev.msfjarvis.aps.util.services.getDefaultUsername
|
||||||
@@ -139,6 +139,6 @@ object AutofillPreferences {
|
|||||||
): Credentials {
|
): Credentials {
|
||||||
// Always give priority to a username stored in the encrypted extras
|
// Always give priority to a username stored in the encrypted extras
|
||||||
val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
Reference in New Issue
Block a user