format-common: initial API for PasswordEntry
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
931cc052a8
commit
77abe7ee2c
9 changed files with 660 additions and 0 deletions
30
format-common/api/format-common.api
Normal file
30
format-common/api/format-common.api
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
public final class dev/msfjarvis/aps/data/passfile/PasswordEntry {
|
||||||
|
public static final field Companion Ldev/msfjarvis/aps/data/passfile/PasswordEntry$Companion;
|
||||||
|
public fun <init> (Ldev/msfjarvis/aps/util/time/UserClock;Ldev/msfjarvis/aps/util/totp/TotpFinder;Lkotlinx/coroutines/CoroutineScope;[B)V
|
||||||
|
public final fun getExtraContent ()Ljava/util/Map;
|
||||||
|
public final fun getExtraContentWithoutAuthData ()Ljava/lang/String;
|
||||||
|
public final fun getPassword ()Ljava/lang/String;
|
||||||
|
public final fun getTotp ()Lkotlinx/coroutines/flow/StateFlow;
|
||||||
|
public final fun getUsername ()Ljava/lang/String;
|
||||||
|
public final fun hasTotp ()Z
|
||||||
|
}
|
||||||
|
|
||||||
|
public class dev/msfjarvis/aps/util/time/UserClock : java/time/Clock {
|
||||||
|
public fun <init> ()V
|
||||||
|
public fun getZone ()Ljava/time/ZoneId;
|
||||||
|
public fun instant ()Ljava/time/Instant;
|
||||||
|
public fun withZone (Ljava/time/ZoneId;)Ljava/time/Clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract interface class dev/msfjarvis/aps/util/totp/TotpFinder {
|
||||||
|
public static final field Companion Ldev/msfjarvis/aps/util/totp/TotpFinder$Companion;
|
||||||
|
public abstract fun findAlgorithm (Ljava/lang/String;)Ljava/lang/String;
|
||||||
|
public abstract fun findDigits (Ljava/lang/String;)Ljava/lang/String;
|
||||||
|
public abstract fun findPeriod (Ljava/lang/String;)J
|
||||||
|
public abstract fun findSecret (Ljava/lang/String;)Ljava/lang/String;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class dev/msfjarvis/aps/util/totp/TotpFinder$Companion {
|
||||||
|
public final fun getTOTP_FIELDS ()[Ljava/lang/String;
|
||||||
|
}
|
||||||
|
|
|
@ -6,3 +6,13 @@ plugins {
|
||||||
kotlin("jvm")
|
kotlin("jvm")
|
||||||
`aps-plugin`
|
`aps-plugin`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(libs.androidx.annotation)
|
||||||
|
implementation(libs.dagger.hilt.core)
|
||||||
|
implementation(libs.thirdparty.commons.codec)
|
||||||
|
implementation(libs.thirdparty.kotlinResult)
|
||||||
|
implementation(libs.kotlin.coroutines.core)
|
||||||
|
testImplementation(libs.bundles.testDependencies)
|
||||||
|
testImplementation(libs.kotlin.coroutines.test)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.data.passfile
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.github.michaelbull.result.mapBoth
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dev.msfjarvis.aps.util.time.UserClock
|
||||||
|
import dev.msfjarvis.aps.util.totp.Otp
|
||||||
|
import dev.msfjarvis.aps.util.totp.TotpFinder
|
||||||
|
import kotlin.collections.set
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Represents a single entry in the password store. */
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
public class PasswordEntry
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
/** A time source used to calculate the TOTP */
|
||||||
|
clock: UserClock,
|
||||||
|
/** [TotpFinder] implementation to extract data from a TOTP URI */
|
||||||
|
totpFinder: TotpFinder,
|
||||||
|
/**
|
||||||
|
* A cancellable [CoroutineScope] inside which we constantly emit new TOTP values as time elapses
|
||||||
|
*/
|
||||||
|
@Assisted scope: CoroutineScope,
|
||||||
|
/** The content of this entry, as an array of bytes. */
|
||||||
|
@Assisted bytes: ByteArray,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val _totp = MutableStateFlow("")
|
||||||
|
private val content = bytes.decodeToString()
|
||||||
|
|
||||||
|
/** The password text for this entry. Can be null. */
|
||||||
|
public val password: String?
|
||||||
|
|
||||||
|
/** The username for this entry. Can be null. */
|
||||||
|
public val username: String?
|
||||||
|
|
||||||
|
/** A [String] to [String] [Map] of the extra content of this entry, in a key:value fashion. */
|
||||||
|
public val extraContent: Map<String, String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [StateFlow] providing the current TOTP. It will emit a single empty string on initialization
|
||||||
|
* which is replaced with a real TOTP if applicable. Call [hasTotp] to verify whether or not you
|
||||||
|
* need to observe this value.
|
||||||
|
*/
|
||||||
|
public val totp: StateFlow<String> = _totp.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String representation of [extraContent] but with authentication related data such as TOTP URIs
|
||||||
|
* and usernames stripped.
|
||||||
|
*/
|
||||||
|
public val extraContentWithoutAuthData: String
|
||||||
|
private val digits: String
|
||||||
|
private val totpSecret: String?
|
||||||
|
private val totpPeriod: Long
|
||||||
|
private val totpAlgorithm: String
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val extraContentString: String
|
||||||
|
|
||||||
|
init {
|
||||||
|
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
|
||||||
|
password = foundPassword
|
||||||
|
extraContentString = passContent.joinToString("\n")
|
||||||
|
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
|
||||||
|
extraContent = generateExtraContentPairs()
|
||||||
|
username = findUsername()
|
||||||
|
digits = totpFinder.findDigits(content)
|
||||||
|
totpSecret = totpFinder.findSecret(content)
|
||||||
|
totpPeriod = totpFinder.findPeriod(content)
|
||||||
|
totpAlgorithm = totpFinder.findAlgorithm(content)
|
||||||
|
if (totpSecret != null) {
|
||||||
|
scope.launch {
|
||||||
|
updateTotp(clock.millis())
|
||||||
|
val remainingTime = totpPeriod - (System.currentTimeMillis() % totpPeriod)
|
||||||
|
delay(remainingTime)
|
||||||
|
repeat(Int.MAX_VALUE) {
|
||||||
|
updateTotp(clock.millis())
|
||||||
|
delay(totpPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun hasTotp(): Boolean {
|
||||||
|
return totpSecret != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAndStripPassword(passContent: List<String>): Pair<String?, List<String>> {
|
||||||
|
if (TotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair(null, 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 generateExtraContentWithoutAuthData(): String {
|
||||||
|
var foundUsername = false
|
||||||
|
return extraContentString
|
||||||
|
.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? {
|
||||||
|
extraContentString.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 updateTotp(millis: Long) {
|
||||||
|
if (totpSecret != null) {
|
||||||
|
Otp.calculateCode(totpSecret, millis / (1000 * totpPeriod), totpAlgorithm, digits)
|
||||||
|
.mapBoth({ code -> _totp.value = code }, { throwable -> throw throwable })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal companion object {
|
||||||
|
|
||||||
|
private const val EXTRA_CONTENT = "Extra Content"
|
||||||
|
internal val USERNAME_FIELDS =
|
||||||
|
arrayOf(
|
||||||
|
"login:",
|
||||||
|
"username:",
|
||||||
|
"user:",
|
||||||
|
"account:",
|
||||||
|
"email:",
|
||||||
|
"name:",
|
||||||
|
"handle:",
|
||||||
|
"id:",
|
||||||
|
"identity:",
|
||||||
|
)
|
||||||
|
internal val PASSWORD_FIELDS =
|
||||||
|
arrayOf(
|
||||||
|
"password:",
|
||||||
|
"secret:",
|
||||||
|
"pass:",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.util.time
|
||||||
|
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subclass of [Clock] that uses [Clock.systemDefaultZone] to get a clock that works for the
|
||||||
|
* user's current time zone.
|
||||||
|
*/
|
||||||
|
public open class UserClock @Inject constructor() : Clock() {
|
||||||
|
|
||||||
|
private val clock = systemDefaultZone()
|
||||||
|
|
||||||
|
override fun withZone(zone: ZoneId): Clock = clock.withZone(zone)
|
||||||
|
|
||||||
|
override fun getZone(): ZoneId = clock.zone
|
||||||
|
|
||||||
|
override fun instant(): Instant = clock.instant()
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
internal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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. */
|
||||||
|
public interface TotpFinder {
|
||||||
|
|
||||||
|
/** Get the TOTP secret from the given extra content. */
|
||||||
|
public fun findSecret(content: String): String?
|
||||||
|
|
||||||
|
/** Get the number of digits required in the final OTP. */
|
||||||
|
public fun findDigits(content: String): String
|
||||||
|
|
||||||
|
/** Get the TOTP timeout period. */
|
||||||
|
public fun findPeriod(content: String): Long
|
||||||
|
|
||||||
|
/** Get the algorithm for the TOTP secret. */
|
||||||
|
public fun findAlgorithm(content: String): String
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public val TOTP_FIELDS: Array<String> = arrayOf("otpauth://totp", "totp:")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,183 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.data.passfile
|
||||||
|
|
||||||
|
import dev.msfjarvis.aps.util.time.TestUserClock
|
||||||
|
import dev.msfjarvis.aps.util.totp.TotpFinder
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestCoroutineScope
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
|
||||||
|
internal class PasswordEntryTest {
|
||||||
|
|
||||||
|
private fun makeEntry(content: String) = PasswordEntry(fakeClock, testFinder, testScope, content.encodeToByteArray())
|
||||||
|
|
||||||
|
@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").extraContentString)
|
||||||
|
assertEquals("bla", makeEntry("fooooo\nbla").extraContentString)
|
||||||
|
assertEquals("", makeEntry("fooooo\n").extraContentString)
|
||||||
|
assertEquals("", makeEntry("fooooo").extraContentString)
|
||||||
|
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContentString)
|
||||||
|
assertEquals("blubb", makeEntry("\nblubb").extraContentString)
|
||||||
|
assertEquals("blubb", makeEntry("blubb\npassword: foo").extraContentString)
|
||||||
|
assertEquals("blubb", makeEntry("password: foo\nblubb").extraContentString)
|
||||||
|
assertEquals("blubb\nusername: bar", makeEntry("blubb\npassword: foo\nusername: bar").extraContentString)
|
||||||
|
assertEquals("", makeEntry("\n").extraContentString)
|
||||||
|
assertEquals("", makeEntry("").extraContentString)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseExtraContentWithoutAuth() {
|
||||||
|
var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef")
|
||||||
|
assertEquals(1, entry.extraContent.size)
|
||||||
|
assertTrue(entry.extraContent.containsKey("test"))
|
||||||
|
assertEquals("abcdef", entry.extraContent["test"])
|
||||||
|
|
||||||
|
entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:")
|
||||||
|
assertEquals(1, entry.extraContent.size)
|
||||||
|
assertTrue(entry.extraContent.containsKey("test"))
|
||||||
|
assertEquals(":abcdef:", entry.extraContent["test"])
|
||||||
|
|
||||||
|
entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::")
|
||||||
|
assertEquals(1, entry.extraContent.size)
|
||||||
|
assertTrue(entry.extraContent.containsKey("test"))
|
||||||
|
assertEquals("::abc:def::", entry.extraContent["test"])
|
||||||
|
|
||||||
|
entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl")
|
||||||
|
assertEquals(2, entry.extraContent.size)
|
||||||
|
assertTrue(entry.extraContent.containsKey("test2"))
|
||||||
|
assertEquals("ghijkl", entry.extraContent["test2"])
|
||||||
|
|
||||||
|
entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:")
|
||||||
|
assertEquals(2, entry.extraContent.size)
|
||||||
|
assertTrue(entry.extraContent.containsKey("Extra Content"))
|
||||||
|
assertEquals(": ghijkl\n mnopqr:", entry.extraContent["Extra Content"])
|
||||||
|
|
||||||
|
entry = makeEntry("username: abc\npassword: abc\n:\n\n")
|
||||||
|
assertEquals(1, entry.extraContent.size)
|
||||||
|
assertTrue(entry.extraContent.containsKey("Extra Content"))
|
||||||
|
assertEquals(":", entry.extraContent["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() {
|
||||||
|
assertNotNull(makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
|
||||||
|
assertNull(makeEntry("secret\nextra\ncontent\n").username)
|
||||||
|
assertNull(makeEntry("secret\nlogin failed\n").username)
|
||||||
|
assertNull(makeEntry("\n").username)
|
||||||
|
assertNull(makeEntry("").username)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGeneratesOtpFromTotpUri() {
|
||||||
|
val entry = makeEntry("secret\nextra\n$TOTP_URI")
|
||||||
|
assertTrue(entry.hasTotp())
|
||||||
|
val code = entry.totp.value
|
||||||
|
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||||
|
assertEquals("818800", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGeneratesOtpWithOnlyUriInFile() {
|
||||||
|
val entry = makeEntry(TOTP_URI)
|
||||||
|
assertNull(entry.password)
|
||||||
|
assertTrue(entry.hasTotp())
|
||||||
|
val code = entry.totp.value
|
||||||
|
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||||
|
assertEquals("818800", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOnlyLooksForUriInFirstLine() {
|
||||||
|
val entry = makeEntry("id:\n$TOTP_URI")
|
||||||
|
assertNotNull(entry.password)
|
||||||
|
assertTrue(entry.hasTotp())
|
||||||
|
assertNull(entry.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.extraContent.isNotEmpty())
|
||||||
|
assertTrue(entry.hasTotp())
|
||||||
|
assertNotNull(entry.username)
|
||||||
|
assertEquals("pass", entry.password)
|
||||||
|
assertEquals("user", entry.username)
|
||||||
|
assertEquals(mapOf("id" to "id"), entry.extraContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val TOTP_URI =
|
||||||
|
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
||||||
|
|
||||||
|
val testScope = TestCoroutineScope()
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.msfjarvis.aps.util.time
|
||||||
|
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset.UTC
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of [UserClock] that is fixed to [Instant.EPOCH] for deterministic time-based tests
|
||||||
|
*/
|
||||||
|
internal class TestUserClock(instant: Instant) : UserClock() {
|
||||||
|
|
||||||
|
constructor() : this(Instant.EPOCH)
|
||||||
|
|
||||||
|
private var clock = fixed(instant, UTC)
|
||||||
|
|
||||||
|
override fun withZone(zone: ZoneId): Clock = clock.withZone(zone)
|
||||||
|
|
||||||
|
override fun getZone(): ZoneId = UTC
|
||||||
|
|
||||||
|
override fun instant(): Instant = clock.instant()
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
internal class OtpTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOtpGeneration6Digits() {
|
||||||
|
assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get())
|
||||||
|
assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get())
|
||||||
|
assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOtpGeneration10Digits() {
|
||||||
|
assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get())
|
||||||
|
assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get())
|
||||||
|
assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOtpGenerationIllegalInput() {
|
||||||
|
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10").get())
|
||||||
|
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a").get())
|
||||||
|
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5").get())
|
||||||
|
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11").get())
|
||||||
|
assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOtpGenerationUnusualSecrets() {
|
||||||
|
assertEquals(
|
||||||
|
"127764",
|
||||||
|
Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get()
|
||||||
|
)
|
||||||
|
assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testOtpGenerationUnpaddedSecrets() {
|
||||||
|
// Secret was generated with `echo 'string with some padding needed' | base32`
|
||||||
|
// We don't care for the resultant OTP's actual value, we just want both the padded and
|
||||||
|
// unpadded variant to generate the same one.
|
||||||
|
val unpaddedOtp =
|
||||||
|
Otp.calculateCode(
|
||||||
|
"ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA",
|
||||||
|
1593367171420 / (1000 * 30),
|
||||||
|
"SHA1",
|
||||||
|
"6"
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
val paddedOtp =
|
||||||
|
Otp.calculateCode(
|
||||||
|
"ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====",
|
||||||
|
1593367171420 / (1000 * 30),
|
||||||
|
"SHA1",
|
||||||
|
"6"
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
assertNotNull(unpaddedOtp)
|
||||||
|
assertNotNull(paddedOtp)
|
||||||
|
assertEquals(unpaddedOtp, paddedOtp)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue