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")
|
||||
`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