Refactor format-common
module (#2196)
* fix: touch up `PasswordEntryTest` KDoc * feat: add format-common-impl module * refactor: switch app to format-common-impl * refactor: move `format-common` tests to `format-common-impl` * feat: add a test for Steam OTP
This commit is contained in:
parent
df764932f7
commit
cdf0f30c61
12 changed files with 73 additions and 49 deletions
|
@ -56,7 +56,7 @@ dependencies {
|
||||||
implementation(projects.autofillParser)
|
implementation(projects.autofillParser)
|
||||||
implementation(projects.coroutineUtils)
|
implementation(projects.coroutineUtils)
|
||||||
implementation(projects.cryptoPgpainless)
|
implementation(projects.cryptoPgpainless)
|
||||||
implementation(projects.formatCommon)
|
implementation(projects.formatCommonImpl)
|
||||||
implementation(projects.passgen.diceware)
|
implementation(projects.passgen.diceware)
|
||||||
implementation(projects.passgen.random)
|
implementation(projects.passgen.random)
|
||||||
implementation(projects.uiCompose)
|
implementation(projects.uiCompose)
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
<ID>ComplexMethod:PasswordCreationActivity.kt$PasswordCreationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
|
<ID>ComplexMethod:PasswordCreationActivity.kt$PasswordCreationActivity$override fun onCreate(savedInstanceState: Bundle?)</ID>
|
||||||
<ID>ComplexMethod:PasswordCreationActivity.kt$PasswordCreationActivity$private fun encrypt()</ID>
|
<ID>ComplexMethod:PasswordCreationActivity.kt$PasswordCreationActivity$private fun encrypt()</ID>
|
||||||
<ID>ComplexMethod:PasswordFragment.kt$PasswordFragment$private fun initializePasswordList()</ID>
|
<ID>ComplexMethod:PasswordFragment.kt$PasswordFragment$private fun initializePasswordList()</ID>
|
||||||
<ID>EmptyDoWhileBlock:PasswordDialog.kt$PasswordDialog${}</ID>
|
|
||||||
<ID>EmptyFunctionBlock:BasicBottomSheet.kt$BasicBottomSheet.<no name provided>${}</ID>
|
<ID>EmptyFunctionBlock:BasicBottomSheet.kt$BasicBottomSheet.<no name provided>${}</ID>
|
||||||
<ID>EmptyFunctionBlock:ItemCreationBottomSheet.kt$ItemCreationBottomSheet.<no name provided>${}</ID>
|
<ID>EmptyFunctionBlock:ItemCreationBottomSheet.kt$ItemCreationBottomSheet.<no name provided>${}</ID>
|
||||||
<ID>EmptyFunctionBlock:PasswordFragment.kt$PasswordFragment.<no name provided>.<no name provided>${}</ID>
|
<ID>EmptyFunctionBlock:PasswordFragment.kt$PasswordFragment.<no name provided>.<no name provided>${}</ID>
|
||||||
|
@ -58,7 +57,6 @@
|
||||||
<ID>MagicNumber:SshKey.kt$SshKey.Algorithm.Ecdsa$256</ID>
|
<ID>MagicNumber:SshKey.kt$SshKey.Algorithm.Ecdsa$256</ID>
|
||||||
<ID>MagicNumber:SshKey.kt$SshKey.Algorithm.Rsa$3072</ID>
|
<ID>MagicNumber:SshKey.kt$SshKey.Algorithm.Rsa$3072</ID>
|
||||||
<ID>MagicNumber:SshjSessionFactory.kt$SshjSession$22</ID>
|
<ID>MagicNumber:SshjSessionFactory.kt$SshjSession$22</ID>
|
||||||
<ID>MagicNumber:UriTotpFinder.kt$UriTotpFinder$30</ID>
|
|
||||||
<ID>MatchingDeclarationName:AutofillViewUtils.kt$DatasetMetadata</ID>
|
<ID>MatchingDeclarationName:AutofillViewUtils.kt$DatasetMetadata</ID>
|
||||||
<ID>MaxLineLength:BaseGitActivity.kt$BaseGitActivity$"The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."</ID>
|
<ID>MaxLineLength:BaseGitActivity.kt$BaseGitActivity$"The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."</ID>
|
||||||
<ID>MaxLineLength:BaseGitActivity.kt$BaseGitActivity$"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."</ID>
|
<ID>MaxLineLength:BaseGitActivity.kt$BaseGitActivity$"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."</ID>
|
||||||
|
@ -82,7 +80,6 @@
|
||||||
<ID>ReturnCount:OreoAutofillService.kt$OreoAutofillService$override fun onSaveRequest(request: SaveRequest, callback: SaveCallback)</ID>
|
<ID>ReturnCount:OreoAutofillService.kt$OreoAutofillService$override fun onSaveRequest(request: SaveRequest, callback: SaveCallback)</ID>
|
||||||
<ID>ReturnCount:PasswordStore.kt$PasswordStore$override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean</ID>
|
<ID>ReturnCount:PasswordStore.kt$PasswordStore$override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean</ID>
|
||||||
<ID>ReturnCount:ShortcutHandler.kt$ShortcutHandler$fun addPinnedShortcut(item: PasswordItem, intent: Intent)</ID>
|
<ID>ReturnCount:ShortcutHandler.kt$ShortcutHandler$fun addPinnedShortcut(item: PasswordItem, intent: Intent)</ID>
|
||||||
<ID>ReturnCount:UriTotpFinder.kt$UriTotpFinder$override fun findSecret(content: String): String?</ID>
|
|
||||||
<ID>SpreadOperator:Api30AutofillResponseBuilder.kt$Api30AutofillResponseBuilder$(*ignoredIds.toTypedArray())</ID>
|
<ID>SpreadOperator:Api30AutofillResponseBuilder.kt$Api30AutofillResponseBuilder$(*ignoredIds.toTypedArray())</ID>
|
||||||
<ID>SpreadOperator:AutofillResponseBuilder.kt$AutofillResponseBuilder$(*ignoredIds.toTypedArray())</ID>
|
<ID>SpreadOperator:AutofillResponseBuilder.kt$AutofillResponseBuilder$(*ignoredIds.toTypedArray())</ID>
|
||||||
<ID>SpreadOperator:BreakOutOfDetached.kt$BreakOutOfDetached$( // abort the rebase git.rebase().setOperation(RebaseCommand.Operation.ABORT), *resetCommands, )</ID>
|
<ID>SpreadOperator:BreakOutOfDetached.kt$BreakOutOfDetached$( // abort the rebase git.rebase().setOperation(RebaseCommand.Operation.ABORT), *resetCommands, )</ID>
|
||||||
|
|
7
detekt-baselines/format-common-impl.xml
Normal file
7
detekt-baselines/format-common-impl.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" ?>
|
||||||
|
<SmellBaseline>
|
||||||
|
<ManuallySuppressedIssues></ManuallySuppressedIssues>
|
||||||
|
<CurrentIssues>
|
||||||
|
<ID>ReturnCount:UriTotpFinder.kt$UriTotpFinder$override fun findSecret(content: String): String?</ID>
|
||||||
|
</CurrentIssues>
|
||||||
|
</SmellBaseline>
|
17
format-common-impl/build.gradle.kts
Normal file
17
format-common-impl/build.gradle.kts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
plugins {
|
||||||
|
id("com.github.android-password-store.android-library")
|
||||||
|
id("com.github.android-password-store.kotlin-android")
|
||||||
|
id("com.github.android-password-store.kotlin-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android { namespace = "app.passwordstore.format.common.impl" }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.formatCommon)
|
||||||
|
implementation(libs.dagger.hilt.core)
|
||||||
|
testImplementation(projects.coroutineUtilsTesting)
|
||||||
|
testImplementation(libs.bundles.testDependencies)
|
||||||
|
testImplementation(libs.kotlin.coroutines.test)
|
||||||
|
testImplementation(libs.testing.robolectric)
|
||||||
|
testImplementation(libs.testing.turbine)
|
||||||
|
}
|
|
@ -1,15 +1,14 @@
|
||||||
/*
|
|
||||||
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package app.passwordstore.util.totp
|
package app.passwordstore.util.totp
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** [Uri] backed TOTP URL parser. */
|
/** [Uri] backed TOTP URL parser. */
|
||||||
class UriTotpFinder @Inject constructor() : TotpFinder {
|
public class UriTotpFinder @Inject constructor() : TotpFinder {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val DEFAULT_TOTP_PERIOD = 30L
|
||||||
|
}
|
||||||
|
|
||||||
override fun findSecret(content: String): String? {
|
override fun findSecret(content: String): String? {
|
||||||
content.split("\n".toRegex()).forEach { line ->
|
content.split("\n".toRegex()).forEach { line ->
|
||||||
|
@ -28,7 +27,7 @@ class UriTotpFinder @Inject constructor() : TotpFinder {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findPeriod(content: String): Long {
|
override fun findPeriod(content: String): Long {
|
||||||
return getQueryParameter(content, "period")?.toLongOrNull() ?: 30
|
return getQueryParameter(content, "period")?.toLongOrNull() ?: DEFAULT_TOTP_PERIOD
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAlgorithm(content: String): String {
|
override fun findAlgorithm(content: String): String {
|
|
@ -9,7 +9,7 @@ import app.cash.turbine.test
|
||||||
import app.passwordstore.test.CoroutineTestRule
|
import app.passwordstore.test.CoroutineTestRule
|
||||||
import app.passwordstore.util.time.TestUserClock
|
import app.passwordstore.util.time.TestUserClock
|
||||||
import app.passwordstore.util.time.UserClock
|
import app.passwordstore.util.time.UserClock
|
||||||
import app.passwordstore.util.totp.TotpFinder
|
import app.passwordstore.util.totp.UriTotpFinder
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
@ -21,15 +21,20 @@ import kotlin.time.ExperimentalTime
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
|
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class PasswordEntryTest {
|
class PasswordEntryTest {
|
||||||
|
|
||||||
@get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
|
@get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
|
||||||
|
private val totpFinder = UriTotpFinder()
|
||||||
|
|
||||||
private fun makeEntry(content: String, clock: UserClock = fakeClock) =
|
private fun makeEntry(content: String, clock: UserClock = fakeClock) =
|
||||||
PasswordEntry(
|
PasswordEntry(
|
||||||
clock,
|
clock,
|
||||||
testFinder,
|
totpFinder,
|
||||||
content.encodeToByteArray(),
|
content.encodeToByteArray(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -144,7 +149,7 @@ class PasswordEntryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as [testGeneratesOtpFromTotpUri], but advances the clock by 5 seconds. This exercises the
|
* Same as [generatesOtpFromTotpUri], but advances the clock by 5 seconds. This exercises the
|
||||||
* [Totp.remainingTime] calculation logic, and acts as a regression test to resolve the bug which
|
* [Totp.remainingTime] calculation logic, and acts as a regression test to resolve the bug which
|
||||||
* blocked https://msfjarvis.dev/aps/issue/1550.
|
* blocked https://msfjarvis.dev/aps/issue/1550.
|
||||||
*/
|
*/
|
||||||
|
@ -199,28 +204,5 @@ class PasswordEntryTest {
|
||||||
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
||||||
|
|
||||||
val fakeClock = TestUserClock()
|
val fakeClock = TestUserClock()
|
||||||
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
override fun findIssuer(content: String): String {
|
|
||||||
return "ACME Co"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -137,7 +137,7 @@ class OtpTest {
|
||||||
)
|
)
|
||||||
val paddedOtp =
|
val paddedOtp =
|
||||||
generateOtp(
|
generateOtp(
|
||||||
1593367171420 / (1000 * 30),
|
counter = 1593367171420 / (1000 * 30),
|
||||||
secret = "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====",
|
secret = "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -145,4 +145,24 @@ class OtpTest {
|
||||||
assertNotNull(paddedOtp)
|
assertNotNull(paddedOtp)
|
||||||
assertEquals(unpaddedOtp, paddedOtp)
|
assertEquals(unpaddedOtp, paddedOtp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun generateSteamTotp() {
|
||||||
|
val issuerOtp =
|
||||||
|
generateOtp(
|
||||||
|
counter = 48297900 / (1000 * 30),
|
||||||
|
secret = "STK7746GVMCHMNH5FBIAQXGPV3I7ZHRG",
|
||||||
|
issuer = "Steam",
|
||||||
|
)
|
||||||
|
val digitsOtp =
|
||||||
|
generateOtp(
|
||||||
|
counter = 48297900 / (1000 * 30),
|
||||||
|
secret = "STK7746GVMCHMNH5FBIAQXGPV3I7ZHRG",
|
||||||
|
digits = "s",
|
||||||
|
)
|
||||||
|
assertNotNull(issuerOtp)
|
||||||
|
assertNotNull(digitsOtp)
|
||||||
|
assertEquals("6M3CT", issuerOtp)
|
||||||
|
assertEquals("6M3CT", digitsOtp)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -9,14 +9,10 @@ plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
api(libs.thirdparty.kotlinResult)
|
||||||
implementation(projects.coroutineUtils)
|
implementation(projects.coroutineUtils)
|
||||||
implementation(libs.androidx.annotation)
|
implementation(libs.androidx.annotation)
|
||||||
implementation(libs.dagger.hilt.core)
|
implementation(libs.dagger.hilt.core)
|
||||||
implementation(libs.thirdparty.commons.codec)
|
implementation(libs.thirdparty.commons.codec)
|
||||||
implementation(libs.thirdparty.kotlinResult)
|
|
||||||
implementation(libs.kotlin.coroutines.core)
|
implementation(libs.kotlin.coroutines.core)
|
||||||
testImplementation(projects.coroutineUtilsTesting)
|
|
||||||
testImplementation(libs.bundles.testDependencies)
|
|
||||||
testImplementation(libs.kotlin.coroutines.test)
|
|
||||||
testImplementation(libs.testing.turbine)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
package app.passwordstore.data.passfile
|
package app.passwordstore.data.passfile
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
import app.passwordstore.util.time.UserClock
|
import app.passwordstore.util.time.UserClock
|
||||||
import app.passwordstore.util.totp.Otp
|
import app.passwordstore.util.totp.Otp
|
||||||
import app.passwordstore.util.totp.TotpFinder
|
import app.passwordstore.util.totp.TotpFinder
|
||||||
|
@ -202,10 +203,11 @@ constructor(
|
||||||
public fun create(bytes: ByteArray): PasswordEntry
|
public fun create(bytes: ByteArray): PasswordEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
internal companion object {
|
public companion object {
|
||||||
|
|
||||||
private const val EXTRA_CONTENT = "Extra Content"
|
private const val EXTRA_CONTENT = "Extra Content"
|
||||||
internal val USERNAME_FIELDS =
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
|
public val USERNAME_FIELDS: Array<String> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"login:",
|
"login:",
|
||||||
"username:",
|
"username:",
|
||||||
|
@ -218,7 +220,8 @@ constructor(
|
||||||
"id:",
|
"id:",
|
||||||
"identity:",
|
"identity:",
|
||||||
)
|
)
|
||||||
internal val PASSWORD_FIELDS =
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
|
public val PASSWORD_FIELDS: Array<String> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"password:",
|
"password:",
|
||||||
"secret:",
|
"secret:",
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
package app.passwordstore.util.totp
|
package app.passwordstore.util.totp
|
||||||
|
|
||||||
import com.github.michaelbull.result.Err
|
import com.github.michaelbull.result.Err
|
||||||
|
import com.github.michaelbull.result.Result
|
||||||
import com.github.michaelbull.result.runCatching
|
import com.github.michaelbull.result.runCatching
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
@ -14,7 +15,7 @@ import javax.crypto.spec.SecretKeySpec
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
import org.apache.commons.codec.binary.Base32
|
import org.apache.commons.codec.binary.Base32
|
||||||
|
|
||||||
internal object Otp {
|
public object Otp {
|
||||||
|
|
||||||
private val BASE_32 = Base32()
|
private val BASE_32 = Base32()
|
||||||
private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray()
|
private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray()
|
||||||
|
@ -26,13 +27,13 @@ internal object Otp {
|
||||||
private const val ALPHABET_LENGTH = 26
|
private const val ALPHABET_LENGTH = 26
|
||||||
private const val MOST_SIGNIFICANT_BYTE = 0x7f
|
private const val MOST_SIGNIFICANT_BYTE = 0x7f
|
||||||
|
|
||||||
fun calculateCode(
|
public fun calculateCode(
|
||||||
secret: String,
|
secret: String,
|
||||||
counter: Long,
|
counter: Long,
|
||||||
algorithm: String,
|
algorithm: String,
|
||||||
digits: String,
|
digits: String,
|
||||||
issuer: String?,
|
issuer: String?,
|
||||||
) = runCatching {
|
): Result<String, Throwable> = runCatching {
|
||||||
val algo = "Hmac${algorithm.uppercase(Locale.ROOT)}"
|
val algo = "Hmac${algorithm.uppercase(Locale.ROOT)}"
|
||||||
val decodedSecret = BASE_32.decode(secret)
|
val decodedSecret = BASE_32.decode(secret)
|
||||||
val secretKey = SecretKeySpec(decodedSecret, algo)
|
val secretKey = SecretKeySpec(decodedSecret, algo)
|
||||||
|
|
|
@ -178,6 +178,8 @@ include("crypto-pgpainless")
|
||||||
|
|
||||||
include("format-common")
|
include("format-common")
|
||||||
|
|
||||||
|
include("format-common-impl")
|
||||||
|
|
||||||
include("passgen:diceware")
|
include("passgen:diceware")
|
||||||
|
|
||||||
include("passgen:random")
|
include("passgen:random")
|
||||||
|
|
Loading…
Reference in a new issue