format-common: initial API for PasswordEntry

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Harsh Shandilya 2021-04-18 02:48:59 +05:30
parent 931cc052a8
commit 77abe7ee2c
No known key found for this signature in database
GPG key ID: 366D7BBAD1031E80
9 changed files with 660 additions and 0 deletions

View 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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