From dc520677aa53d4b0f1d16425dae345fbbd493232 Mon Sep 17 00:00:00 2001 From: Marcel Kliemannel Date: Tue, 23 Aug 2022 20:34:06 +0200 Subject: [PATCH] Add an OTP Auth URI builder; Update of dependencies --- README.md | 38 ++- build.gradle.kts | 12 +- .../GoogleAuthenticator.kt | 16 +- .../HmacOneTimePasswordConfig.kt | 4 +- .../HmacOneTimePasswordGenerator.kt | 13 +- .../OtpAuthUriBuilder.kt | 296 ++++++++++++++++++ .../TimeBasedOneTimePasswordGenerator.kt | 27 +- .../GoogleAuthenticatorTest.kt | 9 +- .../HmacOneTimePasswordGeneratorTest.kt | 22 +- .../OtherLibrariesComparisonTest.kt | 6 +- .../OtpAuthUriBuilderTest.kt | 154 +++++++++ .../TimeBasedOneTimePasswordGeneratorTest.kt | 33 +- 12 files changed, 584 insertions(+), 46 deletions(-) create mode 100644 src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilder.kt create mode 100644 src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilderTest.kt diff --git a/README.md b/README.md index f20c397..38b1793 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,10 @@ This library is available at [Maven Central](https://mvnrepository.com/artifact/ ```java // Groovy -implementation 'dev.turingcomplete:kotlin-onetimepassword:2.3.0' +implementation 'dev.turingcomplete:kotlin-onetimepassword:2.4.0' // Kotlin -implementation("dev.turingcomplete:kotlin-onetimepassword:2.3.0") +implementation("dev.turingcomplete:kotlin-onetimepassword:2.4.0") ``` ### Maven @@ -37,7 +37,7 @@ implementation("dev.turingcomplete:kotlin-onetimepassword:2.3.0") dev.turingcomplete kotlin-onetimepassword - 2.2.0 + 2.4.0 ``` @@ -173,12 +173,38 @@ There is also a helper method ```GoogleAuthenticator.createRandomSecretAsByteArr Some generators limit the length of the **plain text secret** or set a fixed number of characters. So the "Google way", which has a fixed value of 10 characters. Anything outside this range will not be handled correctly by some generators. -#### QR Code +#### Key URI Format and QR Code -QR codes must use a URI that follows the definition in [Key Uri Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format). The secret in this URI is the Base32-encoded one. +The [Key Uri Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format) specification defines a URI which can carry all generator configuration values. This URI can be embedded inside a QR code, which makes the setup of an OTP account in OTP Apps easy and error-free. +This library provides the `OtpAuthUriBuilder` do generate such a URI. For example: +```kotlin +OtpAuthUriBuilder.forTotp(Base32().encode("secret".toByteArray())) + .label("John", "Company") + .issuer("Company") + .digits(8) + .buildToString() +``` +Would generate the URI: +```text +otpauth://totp/Company:John/?issuer=Company&digits=8&secret=ONSWG4TFOQ +``` + +Note that according to the specification, the Base32 padding character `=` will be removed in the `secret` parameter value. + +All three generator are providing the method `otpAuthUriBuilder()` to create an `OtpAuthUriBuilder` which already has all the configuration values set. For example: +```kotlin +GoogleAuthenticator(Base32().encode("secret".toByteArray())) + .otpAuthUriBuilder() + .issuer("Company") + .buildToString() +``` +Would generate the URI: +```text +otpauth://totp/?algorithm=SHA1&digits=6&period=30&issuer=Company&secret=ONSWG4TFOQ +``` -#### Simulate the Google Authenticator +### Simulate the Google Authenticator The directory ```example/googleauthenticator``` contains a simple JavaFX application to simulate the Google Authenticator: diff --git a/build.gradle.kts b/build.gradle.kts index 0c95047..1408054 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,8 +2,8 @@ import java.net.URI plugins { `java-library` - kotlin("jvm") version "1.3.41" - id("org.jetbrains.dokka") version "1.4.32" + kotlin("jvm") version "1.7.10" + id("org.jetbrains.dokka") version "1.7.10" signing `maven-publish` @@ -11,7 +11,7 @@ plugins { allprojects { group = "dev.turingcomplete" - version = "2.3.0" + version = "2.4.0" repositories { mavenLocal() @@ -56,15 +56,15 @@ dependencies { implementation(kotlin("stdlib")) implementation("commons-codec:commons-codec:1.15") - val jUnitVersion = "5.8.2" + val jUnitVersion = "5.9.0" testImplementation("org.junit.jupiter:junit-jupiter-params:$jUnitVersion") testImplementation("org.junit.jupiter:junit-jupiter-api:$jUnitVersion") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$jUnitVersion") - testImplementation("com.github.bastiaanjansen:otp-java:1.3.0") { + testImplementation("com.github.bastiaanjansen:otp-java:1.3.2") { because("For `OtherLibrariesComparisonTest`") } - testImplementation("com.eatthepath:java-otp:0.3.1") { + testImplementation("com.eatthepath:java-otp:0.4.0") { because("For `OtherLibrariesComparisonTest`") } testImplementation("com.j256.two-factor-auth:two-factor-auth:1.3") { diff --git a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticator.kt b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticator.kt index ad232b6..505902d 100644 --- a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticator.kt +++ b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticator.kt @@ -13,7 +13,7 @@ import java.util.concurrent.TimeUnit * * @param base32secret the shared Base32-encoded-encoded secret. */ -class GoogleAuthenticator(base32secret: ByteArray) { +class GoogleAuthenticator(private val base32secret: ByteArray) { // -- Companion Object -------------------------------------------------------------------------------------------- // companion object { @@ -40,6 +40,8 @@ class GoogleAuthenticator(base32secret: ByteArray) { val randomSecret = RandomSecretGenerator().createRandomSecret(10) return Base32().encode(randomSecret) } + + val CONFIG = TimeBasedOneTimePasswordConfig(30, TimeUnit.SECONDS, 6, HmacAlgorithm.SHA1) } // -- Properties -------------------------------------------------------------------------------------------------- // @@ -49,10 +51,7 @@ class GoogleAuthenticator(base32secret: ByteArray) { // -- Initialization ---------------------------------------------------------------------------------------------- // init { - val hmacAlgorithm = HmacAlgorithm.SHA1 - val config = TimeBasedOneTimePasswordConfig(30, TimeUnit.SECONDS, 6, hmacAlgorithm) - - timeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(Base32().decode(base32secret), config) + timeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(Base32().decode(base32secret), CONFIG) } @Deprecated("Use ByteArray representation", @@ -82,6 +81,13 @@ class GoogleAuthenticator(base32secret: ByteArray) { return code == generate(timestamp) } + /** + * Creates an [OtpAuthUriBuilder], which pre-configured with the secret, as + * well as the fixed Google authenticator configuration for the algorithm, + * code digits and time step. + */ + fun otpAuthUriBuilder(): OtpAuthUriBuilder.Totp = timeBasedOneTimePasswordGenerator.otpAuthUriBuilder() + // -- Private Methods --------------------------------------------------------------------------------------------- // // -- Inner Type -------------------------------------------------------------------------------------------------- // } \ No newline at end of file diff --git a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordConfig.kt b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordConfig.kt index 880420b..55e6e79 100644 --- a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordConfig.kt +++ b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordConfig.kt @@ -1,7 +1,5 @@ package dev.turingcomplete.kotlinonetimepassword -import java.lang.IllegalArgumentException - /** * The configuration for the [HmacOneTimePasswordGenerator]. * @@ -20,7 +18,7 @@ import java.lang.IllegalArgumentException * * @throws IllegalArgumentException if `codeDigits` is negative. */ -open class HmacOneTimePasswordConfig(var codeDigits: Int, var hmacAlgorithm: HmacAlgorithm) { +open class HmacOneTimePasswordConfig(val codeDigits: Int, val hmacAlgorithm: HmacAlgorithm) { // -- Companion Object -------------------------------------------------------------------------------------------- // // -- Properties -------------------------------------------------------------------------------------------------- // // -- Initialization ---------------------------------------------------------------------------------------------- // diff --git a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGenerator.kt b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGenerator.kt index 2a52ca7..420200b 100644 --- a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGenerator.kt +++ b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGenerator.kt @@ -1,5 +1,6 @@ package dev.turingcomplete.kotlinonetimepassword +import org.apache.commons.codec.binary.Base32 import java.nio.ByteBuffer import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec @@ -89,7 +90,7 @@ open class HmacOneTimePasswordGenerator(private val secret: ByteArray, val codeInt = binary.int.rem(10.0.pow(config.codeDigits).toInt()) // The integer code variable may contain a value with fewer digits than the - // required code digits. Therefore the final code value is filled with zeros + // required code digits. Therefore, the final code value is filled with zeros // on the left, till the code digits requirement is fulfilled. // // Ongoing example: @@ -109,6 +110,16 @@ open class HmacOneTimePasswordGenerator(private val secret: ByteArray, return code == generate(counter) } + /** + * Creates an [OtpAuthUriBuilder], which pre-configured with the secret, as + * well as the algorithm and code digits from the [config]. + */ + fun otpAuthUriBuilder(initialCounter: Long): OtpAuthUriBuilder.Hotp { + return OtpAuthUriBuilder.forHotp(initialCounter, Base32().encode(secret)) + .algorithm(config.hmacAlgorithm) + .digits(config.codeDigits) + } + // -- Private Methods --------------------------------------------------------------------------------------------- // // -- Inner Type -------------------------------------------------------------------------------------------------- // } \ No newline at end of file diff --git a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilder.kt b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilder.kt new file mode 100644 index 0000000..e06d8c5 --- /dev/null +++ b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilder.kt @@ -0,0 +1,296 @@ +package dev.turingcomplete.kotlinonetimepassword + +import dev.turingcomplete.kotlinonetimepassword.OtpAuthUriBuilder.Companion.forHotp +import dev.turingcomplete.kotlinonetimepassword.OtpAuthUriBuilder.Companion.forTotp +import java.io.ByteArrayOutputStream +import java.net.URI +import java.net.URLEncoder +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.concurrent.TimeUnit + +/** + * A builder to create an OTP Auth URI as defined in + * [Key Uri Format](https://github.com/google/google-authenticator/wiki/Key-Uri-Format). + * This URI contains all necessary information for a TOTP/HOTP client to set up + * the code generation. + * + * This URI can be used, for example, to be encoded into a QR code. + * + * An example OTP Auth URI would be: + * ```text + * otpauth://totp/Company:John@company.com?secret=SGWY3DPESRKFPHH&issuer=Company&digits=8&algorithm=SHA1 + * ``` + * + * Use the factory methods [forTotp]/[TimeBasedOneTimePasswordGenerator.otpAuthUriBuilder] + * or [forHotp]/[HmacOneTimePasswordGenerator.otpAuthUriBuilder] to create a/an + * TOTP/HOTP specific instance of an [OtpAuthUriBuilder]. + * + * @param removePaddingFromBase32Secret if set to `true`, the Base32 padding + * character `=` will be removed from the `secret` URI parameter (e.g., + * `MFQWC===` will be transformed to `MFQWC`.), this is required by the + * specification. + * @property charset the [Charset] to be used at various places inside this + * builder, for example, for the URL encoding. + * + * @see OtpAuthUriBuilder.Totp + * @see OtpAuthUriBuilder.Hotp + */ +open class OtpAuthUriBuilder>(private val type: String, + base32Secret: ByteArray, + removePaddingFromBase32Secret: Boolean = true, + private val charset: Charset = StandardCharsets.UTF_8) { + // -- Companion Object -------------------------------------------------------------------------------------------- // + + companion object { + /** + * Creates a new [OtpAuthUriBuilder] for a __TOTP__ OTP Auth URI. + * + * @param base32Secret the secret as a Base32 encoded [ByteArray]. + * + * @see TimeBasedOneTimePasswordGenerator.otpAuthUriBuilder + */ + fun forTotp(base32Secret: ByteArray): Totp { + return Totp(base32Secret) + } + + /** + * Creates a new [OtpAuthUriBuilder] for a __HOTP__ OTP Auth URI. + * + * @param base32Secret the secret as a Base32 encoded [ByteArray]. + * + * @see HmacOneTimePasswordGenerator.otpAuthUriBuilder + */ + fun forHotp(initialCounter: Long, base32Secret: ByteArray): Hotp { + return Hotp(initialCounter, base32Secret) + } + } + + // -- Properties -------------------------------------------------------------------------------------------------- // + + private val base32Secret: ByteArray + private var label: String? = null + protected var parameters = mutableMapOf() + + // -- Initialization ---------------------------------------------------------------------------------------------- // + + init { + this.base32Secret = if (removePaddingFromBase32Secret) removePaddingFromBase32Secret(base32Secret) else base32Secret + } + + // -- Exposed Methods --------------------------------------------------------------------------------------------- // + + /** + * Sets the label path part of the URI, which consist of an account name + * and an optional issuer. Both values will be separated by a colon (`:`), + * which can be URL encoded by setting the parameter [encodeSeparator]. + * + * The issuer is a provider or service to which the account name (for + * which the OTP code gets used) belongs to. + * + * The issuer and account name will be URL encoded. + * + * The OTP Auth URI specification recommends to always set this path part + * with both values. And if it is set, the [issuer] parameter should also be + * set. + * + * This is an _optional_ path part. + */ + fun label(accountName: String, issuer: String?, encodeSeparator: Boolean = false): S { + if (accountName.contains(":") + || accountName.contains("%3A") + || issuer?.contains(":") == true + || issuer?.contains("%3A") == true) { + throw IllegalArgumentException("Neither the account name nor the issuer are allowed to contain a colon.") + } + + val encodedAccountName = URLEncoder.encode(accountName, charset.name()) + label = if (issuer != null) { + val colon = if (encodeSeparator) "%3A" else ":" + URLEncoder.encode(issuer, charset.name()) + colon + encodedAccountName + } + else { + encodedAccountName + } + + @Suppress("UNCHECKED_CAST") + return this as S + } + + /** + * Sets the `issuer` query parameter, which indicates the provider or service + * the account (for which the OTP code gets used) belongs to. + * + * The OTP Auth URI specification recommends to always set this parameter. And + * if it is set, the [label] path part should also be set. + * + * The value will be URL encoded. + * + * This is an _optional_ parameter. + */ + fun issuer(issuer: String): S { + parameters["issuer"] = URLEncoder.encode(issuer, StandardCharsets.UTF_8.name()) + + @Suppress("UNCHECKED_CAST") + return this as S + } + + /** + * Sets the `algorithm` query parameter, which is the uppercase name of the + * HMAC algorithms defined in [HmacAlgorithm]. + * + * This value is equivalent to the [TimeBasedOneTimePasswordConfig.hmacAlgorithm] + * and [HmacOneTimePasswordConfig.hmacAlgorithm] configuration. + * + * The Google Authenticator may ignore this value and always uses `SHA1`. + * + * This is an _optional_ parameter. + */ + fun algorithm(algorithm: HmacAlgorithm): S { + parameters["algorithm"] = algorithm.name + + @Suppress("UNCHECKED_CAST") + return this as S + } + + /** + * Sets the `digits` query parameter, which is the length of the generated + * code. + * + * This value is equivalent to the [TimeBasedOneTimePasswordConfig.codeDigits] and + * [HmacOneTimePasswordConfig.codeDigits] configuration. + * + * The Google Authenticator may ignore this value and always uses `6`. + * + * This is an _optional_ parameter. + */ + fun digits(digits: Int): S { + parameters["digits"] = digits.toString() + + @Suppress("UNCHECKED_CAST") + return this as S + } + + /** + * Builds the final OTP Auth URI as a [String]. + * + * Warning: Handling the URI as a string may leak the secret into the String + * pool of the JVM. Consider using [buildToByteArray] instead. + */ + fun buildToString(): String { + return buildUriWithoutSecret(mapOf(Pair("secret", base32Secret.toString(charset)))) + } + + /** + * Builds the final OTP Auth URI as a [URI]. + * + * Warning: Handling the URI as a string may leak the secret into the String + * pool of the JVM. Consider using [buildToByteArray] instead. + */ + fun buildToUri(): URI { + return URI(buildToString()) + } + + /** + * Builds the final OTP Auth URI as a [ByteArray]. + */ + fun buildToByteArray(): ByteArray { + return ByteArrayOutputStream().apply { + write(buildUriWithoutSecret().toByteArray(charset)) + write(if (parameters.isNotEmpty()) '&'.code else '?'.code) + write("secret=".toByteArray(charset)) + write(base32Secret) + }.toByteArray() + } + + // -- Private Methods --------------------------------------------------------------------------------------------- // + + private fun buildUriWithoutSecret(additionalParameters: Map = emptyMap()): String { + val query = parameters.plus(additionalParameters).map { "${it.key}=${it.value}" }.joinToString(separator = "&", prefix = "?") + return "otpauth://$type/${if (label != null) "$label/" else ""}$query" + } + + private fun removePaddingFromBase32Secret(base32Secret: ByteArray): ByteArray { + val base32SecretByteBuffer = ByteBuffer.wrap(base32Secret) + val base32SecretCharBuffer: CharBuffer = charset.decode(base32SecretByteBuffer) + + var cleanedBase32SecretLength = 0 + val cleanedBase32SecretCharBuffer = CharBuffer.allocate(base32SecretCharBuffer.length) + for(i in base32SecretCharBuffer.indices) { + if (base32SecretCharBuffer[i] != '=') { + cleanedBase32SecretLength++ + cleanedBase32SecretCharBuffer.put(i, base32SecretCharBuffer[i]) + } + } + + val cleanedBase32SecretByteBuffer = charset.encode(cleanedBase32SecretCharBuffer.subSequence(0, cleanedBase32SecretLength)) + val cleanedBase32Secret = Arrays.copyOfRange(cleanedBase32SecretByteBuffer.array(), + cleanedBase32SecretByteBuffer.position(), + cleanedBase32SecretByteBuffer.limit()) + + // Clean up + // `base32SecretByteBuffer` holds a reference to the original array + Arrays.fill(base32SecretCharBuffer.array(), '-') + Arrays.fill(cleanedBase32SecretCharBuffer.array(), '-') + Arrays.fill(cleanedBase32SecretByteBuffer.array(), 0.toByte()) + + return cleanedBase32Secret + } + + // -- Inner Type -------------------------------------------------------------------------------------------------- // + + /** + * A builder for a TOTP OTP Auth URI. + * + * An instance should be created via [forTotp] + * or [TimeBasedOneTimePasswordGenerator.otpAuthUriBuilder]. + */ + class Totp(base32Secret: ByteArray) : OtpAuthUriBuilder("totp", base32Secret) { + + /** + * Sets the `period` query parameter, which defines the validity of a TOTP + * code in seconds. + * + * This value is equivalent to the [TimeBasedOneTimePasswordConfig.timeStep] and + * [TimeBasedOneTimePasswordConfig.timeStepUnit] configuration. + * + * This is an _optional_ parameter. + */ + fun period(timeStep: Long, timeStepUnit: TimeUnit): Totp { + parameters["period"] = timeStepUnit.toSeconds(timeStep).toString() + return this + } + } + + // -- Inner Type -------------------------------------------------------------------------------------------------- // + + /** + * A builder for an HOTP OTP Auth URI. + * + * An instance should be created via [forHotp] + * or [HmacOneTimePasswordGenerator.otpAuthUriBuilder]. + * + * @param initialCounter the initial [counter] value. + */ + class Hotp(initialCounter: Long, base32Secret: ByteArray) : OtpAuthUriBuilder("hotp", base32Secret) { + + init { + counter(initialCounter) + } + + /** + * Sets the `counter` parameter, which defines the initial counter value. + * + * This is a _required_ parameter and will be initially set by the + * constructor parameter `initialCounter`. Calling this method will + * overwrite the initial value from the constructor. + */ + fun counter(initialCounter: Long): Hotp { + parameters["counter"] = initialCounter.toString() + return this + } + } +} diff --git a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGenerator.kt b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGenerator.kt index 51a0fc8..8c021f2 100644 --- a/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGenerator.kt +++ b/src/main/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGenerator.kt @@ -1,5 +1,6 @@ package dev.turingcomplete.kotlinonetimepassword +import org.apache.commons.codec.binary.Base32 import java.time.Instant import java.util.* import java.util.concurrent.TimeUnit @@ -31,14 +32,13 @@ open class TimeBasedOneTimePasswordGenerator(private val secret: ByteArray, priv * steps is calculated. The default value is the current system time from * [System.currentTimeMillis]. */ - fun counter(timestamp: Long = System.currentTimeMillis()): Long = if (config.timeStep == 0L) { - 0 // To avoid a divide by zero exception - } else { - floor( - timestamp.toDouble() - .div(TimeUnit.MILLISECONDS.convert(config.timeStep, config.timeStepUnit).toDouble()) - ) - .toLong() + fun counter(timestamp: Long = System.currentTimeMillis()): Long { + if (config.timeStep == 0L) { + // To avoid a divide by zero + return 0 + } + + return floor(timestamp.toDouble().div(TimeUnit.MILLISECONDS.convert(config.timeStep, config.timeStepUnit))).toLong() } /** @@ -112,6 +112,17 @@ open class TimeBasedOneTimePasswordGenerator(private val secret: ByteArray, priv */ fun isValid(code: String, instant: Instant) = isValid(code, instant.toEpochMilli()) + /** + * Creates an [OtpAuthUriBuilder], which pre-configured with the secret, as + * well as the algorithm, code digits and time step from the [config]. + */ + fun otpAuthUriBuilder(): OtpAuthUriBuilder.Totp { + return OtpAuthUriBuilder.forTotp(Base32().encode(secret)) + .algorithm(config.hmacAlgorithm) + .digits(config.codeDigits) + .period(config.timeStep, config.timeStepUnit) + } + // -- Private Methods --------------------------------------------------------------------------------------------- // // -- Inner Type -------------------------------------------------------------------------------------------------- // } \ No newline at end of file diff --git a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticatorTest.kt b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticatorTest.kt index 729963b..7addc5f 100644 --- a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticatorTest.kt +++ b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/GoogleAuthenticatorTest.kt @@ -1,7 +1,6 @@ package dev.turingcomplete.kotlinonetimepassword import org.apache.commons.codec.binary.Base32 -import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName @@ -39,6 +38,14 @@ class GoogleAuthenticatorTest { assertEquals(16, googleAuthenticatorRandomSecret.size) } + @Test + fun testOtpAuthUriBuilder() { + val secret = Base32().encode("Foo".toByteArray()) + assertTrue(String(secret).startsWith("IZXW6")) + val otpAuthUri = GoogleAuthenticator(secret).otpAuthUriBuilder().issuer("foo").buildToString() + assertEquals("otpauth://totp/?algorithm=SHA1&digits=6&period=30&issuer=foo&secret=IZXW6", otpAuthUri) + } + // -- Private Methods --------------------------------------------------------------------------------------------- // // -- Inner Type -------------------------------------------------------------------------------------------------- // } \ No newline at end of file diff --git a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGeneratorTest.kt b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGeneratorTest.kt index 7a286b2..a1ed85f 100644 --- a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGeneratorTest.kt +++ b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/HmacOneTimePasswordGeneratorTest.kt @@ -1,6 +1,8 @@ package dev.turingcomplete.kotlinonetimepassword -import org.junit.jupiter.api.Assertions +import org.apache.commons.codec.binary.Base32 +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -19,7 +21,7 @@ class HmacOneTimePasswordGeneratorTest { val config = HmacOneTimePasswordConfig(0, HmacAlgorithm.SHA1) val hmacOneTimePasswordGenerator = HmacOneTimePasswordGenerator("Leia".toByteArray(), config) - Assertions.assertEquals(0, hmacOneTimePasswordGenerator.generate(42).length) + assertEquals(0, hmacOneTimePasswordGenerator.generate(42).length) } @Test @@ -28,7 +30,7 @@ class HmacOneTimePasswordGeneratorTest { val config = HmacOneTimePasswordConfig(8, HmacAlgorithm.SHA1) val hmacOneTimePasswordGenerator = HmacOneTimePasswordGenerator("Leia".toByteArray(), config) - Assertions.assertEquals("67527464", hmacOneTimePasswordGenerator.generate(0)) + assertEquals("67527464", hmacOneTimePasswordGenerator.generate(0)) } @ParameterizedTest(name = "{0}, code digits: {1}, counter: {2}, code: {3}, secret: {4}") @@ -48,14 +50,24 @@ class HmacOneTimePasswordGeneratorTest { validateWithExpectedCode(counter, code, 6, "12345678901234567890", HmacAlgorithm.SHA1) } + @Test + fun testOtpAuthUriBuilder() { + val secret = "Foo".toByteArray() + assertTrue(Base32().encodeToString(secret).startsWith("IZXW6")) + val config = HmacOneTimePasswordConfig(9, HmacAlgorithm.SHA256) + val hmacOneTimePasswordGenerator = HmacOneTimePasswordGenerator(secret, config) + val otpAuthUri = hmacOneTimePasswordGenerator.otpAuthUriBuilder(999).issuer("foo").buildToString() + assertEquals("otpauth://hotp/?counter=999&algorithm=SHA256&digits=9&issuer=foo&secret=IZXW6", otpAuthUri) + } + // -- Private Methods --------------------------------------------------------------------------------------------- // private fun validateWithExpectedCode(counter: Long, expectedCode: String, codeDigits: Int, secret: String, hmacAlgorithm: HmacAlgorithm) { val config = HmacOneTimePasswordConfig(codeDigits, hmacAlgorithm) val hmacOneTimePasswordGenerator = HmacOneTimePasswordGenerator(secret.toByteArray(), config) - Assertions.assertEquals(expectedCode, hmacOneTimePasswordGenerator.generate(counter)) - Assertions.assertTrue(hmacOneTimePasswordGenerator.isValid(expectedCode, counter)) + assertEquals(expectedCode, hmacOneTimePasswordGenerator.generate(counter)) + assertTrue(hmacOneTimePasswordGenerator.isValid(expectedCode, counter)) } // -- Inner Type -------------------------------------------------------------------------------------------------- // diff --git a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtherLibrariesComparisonTest.kt b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtherLibrariesComparisonTest.kt index 6372cfe..707bc72 100644 --- a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtherLibrariesComparisonTest.kt +++ b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtherLibrariesComparisonTest.kt @@ -13,6 +13,10 @@ import java.time.Instant import java.util.concurrent.TimeUnit import javax.crypto.spec.SecretKeySpec +/** + * This test compares the TOTP code generation of this library to other Java-based + * TOTP libraries. + */ class OtherLibrariesComparisonTest { // -- Companion Object -------------------------------------------------------------------------------------------- // @@ -28,7 +32,7 @@ class OtherLibrariesComparisonTest { @ParameterizedTest @DisplayName("com.eatthepath:java-otp (https://github.com/jchambers/java-otp)") @CsvSource(value = ["15, 6", "15, 8", "30, 6", "30, 8", "45, 6", "45, 8"]) - fun testComparetoEtthapath(timeStepSeconds: Long, digits: Int) { + fun testCompareToEtthapath(timeStepSeconds: Long, digits: Int) { val currentTime = System.currentTimeMillis() val expectedCode = createExpectedCode(timeStepSeconds, digits, currentTime) diff --git a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilderTest.kt b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilderTest.kt new file mode 100644 index 0000000..f833afa --- /dev/null +++ b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/OtpAuthUriBuilderTest.kt @@ -0,0 +1,154 @@ +package dev.turingcomplete.kotlinonetimepassword + +import org.apache.commons.codec.binary.Base32 +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.util.concurrent.TimeUnit + +/** + * Tests the [OtpAuthUriBuilder]. + */ +class OtpAuthUriBuilderTest { + // -- Companion Object -------------------------------------------------------------------------------------------- // + + companion object { + private val BASE32_SECRET = Base32().encode("secret$9".toByteArray()) + private const val BASE32_SECRET_REMOVED_PADDING = "ONSWG4TFOQSDS" + + init { + assertTrue(String(BASE32_SECRET).startsWith(BASE32_SECRET_REMOVED_PADDING)) + } + } + + // -- Properties -------------------------------------------------------------------------------------------------- // + // -- Initialization ---------------------------------------------------------------------------------------------- // + // -- Exposed Methods --------------------------------------------------------------------------------------------- // + + @Test + fun testTotpPeriodParameter() { + assertEquals("otpauth://totp/?period=10&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .period(10, TimeUnit.SECONDS) + .buildToString()) + + assertEquals("otpauth://totp/?period=600&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .period(10, TimeUnit.MINUTES) + .buildToString()) + } + + @Test + fun testHotpCounterByinitialCounterParameter() { + assertEquals("otpauth://hotp/?counter=99999&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forHotp(99999, BASE32_SECRET) + .buildToString()) + } + + @Test + fun testHotpCounterParameter() { + assertEquals("otpauth://hotp/?counter=888888&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forHotp(99999, BASE32_SECRET) + .counter(888888) + .buildToString()) + } + + @Test + fun testDigitsParameter() { + assertEquals("otpauth://totp/?digits=333&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .digits(333) + .buildToString()) + } + + @Test + fun testAlgorithmParameter() { + assertEquals("otpauth://totp/?algorithm=SHA512&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .algorithm(HmacAlgorithm.SHA512) + .buildToString()) + } + + @Test + fun testIssuerParameter() { + assertEquals("otpauth://totp/?issuer=foo&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .issuer("foo") + .buildToString()) + } + + @Test + fun testIssuerParameterUrlEncoding() { + assertEquals("otpauth://totp/?issuer=f%21%21oo&secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .issuer("f!!oo") + .buildToString()) + } + + @Test + fun testLabel() { + assertEquals("otpauth://totp/iss:acc/?secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .label("acc", "iss", false) + .buildToString()) + + assertEquals("otpauth://totp/acc/?secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .label("acc", null, false) + .buildToString()) + + assertEquals("otpauth://totp/iss%3Aacc/?secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .label("acc", "iss", true) + .buildToString()) + } + + @Test + fun testLabelUrlEncoding() { + assertEquals("otpauth://totp/i%2F%2Fss%3Aa%2F%2Fcc/?secret=$BASE32_SECRET_REMOVED_PADDING", + OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .label("a//cc", "i//ss", true) + .buildToString()) + } + + @Test + fun testBuildTo() { + val builder = OtpAuthUriBuilder.forTotp(BASE32_SECRET) + .label("acc", "i/ss", false) + .issuer("i/ss") + .digits(8) + + val stringRepresentation = builder.buildToString() + assertEquals("otpauth://totp/i%2Fss:acc/?issuer=i%2Fss&digits=8&secret=$BASE32_SECRET_REMOVED_PADDING", stringRepresentation) + + val byteArrayRepresentation = builder.buildToByteArray() + assertEquals(stringRepresentation, String(byteArrayRepresentation)) + + val urlRepresentation = builder.buildToUri() + assertEquals(stringRepresentation, urlRepresentation.toString()) + } + + @ParameterizedTest + @CsvSource(value = [ + ", ", // null will be transformed to empty string + "a, ME", // ME====== + "aa, MFQQ", // MFQQ==== + "aaa, MFQWC", // MFQWC=== + "aaaa, MFQWCYI", // MFQWCYI= + "aaaaa, MFQWCYLB", // MFQWCYLB + "aaaaaa, MFQWCYLBME", // MFQWCYLBME====== + "aaaaaaa, MFQWCYLBMFQQ", // MFQWCYLBMFQQ==== + "aaaaaaaa, MFQWCYLBMFQWC", // MFQWCYLBMFQWC=== + ]) + fun testRemoveBase32SecretPadding(secret: String?, expectedSecretParameterValue: String?) { + val secretParameterRegex = Regex("^.*[&|?]secret=(.*)$") + val otpAuthUri = OtpAuthUriBuilder.forTotp(Base32().encode((secret ?: "").toByteArray())).buildToString() + val actualSecretParameterValue = secretParameterRegex.matchEntire(otpAuthUri)!!.groups[1]!!.value + assertEquals(expectedSecretParameterValue ?: "", actualSecretParameterValue) + } + + // -- Private Methods --------------------------------------------------------------------------------------------- // + // -- Inner Type -------------------------------------------------------------------------------------------------- // +} diff --git a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGeneratorTest.kt b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGeneratorTest.kt index 1917958..ad4d4ee 100644 --- a/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGeneratorTest.kt +++ b/src/test/kotlin/dev/turingcomplete/kotlinonetimepassword/TimeBasedOneTimePasswordGeneratorTest.kt @@ -1,6 +1,9 @@ package dev.turingcomplete.kotlinonetimepassword +import org.apache.commons.codec.binary.Base32 import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -24,7 +27,7 @@ class TimeBasedOneTimePasswordGeneratorTest { val secret = "Leia".toByteArray() val timeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(secret, config) - Assertions.assertEquals(0, timeBasedOneTimePasswordGenerator.generate(Date(12345)).length) + assertEquals(0, timeBasedOneTimePasswordGenerator.generate(Date(12345)).length) } @Test @@ -37,7 +40,7 @@ class TimeBasedOneTimePasswordGeneratorTest { val firstTimestamp = 1593727260000 // 2020/07/03 00:01:00 val code = timeBasedOneTimePasswordGenerator.generate(Date(firstTimestamp)) val secondTimestamp = 1593727289000 // 2020/07/03 00:01:29 - Assertions.assertTrue(timeBasedOneTimePasswordGenerator.isValid(code, Date(secondTimestamp))) + assertTrue(timeBasedOneTimePasswordGenerator.isValid(code, Date(secondTimestamp))) val thirdTimestamp = 1593727290000 // 2020/07/03 00:01:30 Assertions.assertFalse(timeBasedOneTimePasswordGenerator.isValid(code, Date(thirdTimestamp))) } @@ -52,15 +55,15 @@ class TimeBasedOneTimePasswordGeneratorTest { val timestamp = 1593727270000 // 2020/07/03 00:01:10 GMT+0200 val expectedCounter = 53124242L val counter = timeBasedOneTimePasswordGenerator.counter(timestamp) - Assertions.assertEquals(expectedCounter, counter) + assertEquals(expectedCounter, counter) val expectedStart = 1593727260000L // 2020/07/03 00:01:00 GMT+0200 val startMillis = timeBasedOneTimePasswordGenerator.timeslotStart(counter) - Assertions.assertEquals(expectedStart, startMillis) + assertEquals(expectedStart, startMillis) val expectedEnd = 1593727289000L // 2020/07/03 00:01:29 GMT+0200 val endMillis = (timeBasedOneTimePasswordGenerator.timeslotStart(counter+1)-1000) - Assertions.assertEquals(expectedEnd, endMillis) + assertEquals(expectedEnd, endMillis) } @Test @@ -71,7 +74,7 @@ class TimeBasedOneTimePasswordGeneratorTest { val zeroConfig = TimeBasedOneTimePasswordConfig(0, TimeUnit.MINUTES, 6, hmacAlgorithm) val zeroTimeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(secret, zeroConfig) - Assertions.assertEquals("527464", zeroTimeBasedOneTimePasswordGenerator.generate(Date(12334532445))) + assertEquals("527464", zeroTimeBasedOneTimePasswordGenerator.generate(Date(12334532445))) } @Test @@ -82,8 +85,8 @@ class TimeBasedOneTimePasswordGeneratorTest { val zeroConfig = TimeBasedOneTimePasswordConfig(30, TimeUnit.MINUTES, 6, hmacAlgorithm) val zeroTimeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(secret, zeroConfig) - Assertions.assertEquals("527464", zeroTimeBasedOneTimePasswordGenerator.generate(Date(0))) - Assertions.assertEquals("630888", zeroTimeBasedOneTimePasswordGenerator.generate(Date(-22334579403))) + assertEquals("527464", zeroTimeBasedOneTimePasswordGenerator.generate(Date(0))) + assertEquals("630888", zeroTimeBasedOneTimePasswordGenerator.generate(Date(-22334579403))) } @ParameterizedTest(name = "Timestamp: {0}, expected code: {1}") @@ -115,6 +118,16 @@ class TimeBasedOneTimePasswordGeneratorTest { 30, TimeUnit.SECONDS, expectedCode, secret) } + @Test + fun testOtpAuthUriBuilder() { + val secret = "Foo".toByteArray() + assertTrue(Base32().encodeToString(secret).startsWith("IZXW6")) + val config = TimeBasedOneTimePasswordConfig(45, TimeUnit.MINUTES, 9, HmacAlgorithm.SHA256) + val timeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(secret, config) + val otpAuthUri = timeBasedOneTimePasswordGenerator.otpAuthUriBuilder().issuer("foo").buildToString() + assertEquals("otpauth://totp/?algorithm=SHA256&digits=9&period=${TimeUnit.MINUTES.toSeconds(45)}&issuer=foo&secret=IZXW6", otpAuthUri) + } + // -- Private Methods --------------------------------------------------------------------------------------------- // private fun validateWithExpectedCode(hmacAlgorithm: @@ -129,8 +142,8 @@ class TimeBasedOneTimePasswordGeneratorTest { val config = TimeBasedOneTimePasswordConfig(timeStep, timeStepUnit, codeDigits, hmacAlgorithm) val timeBasedOneTimePasswordGenerator = TimeBasedOneTimePasswordGenerator(secret.toByteArray(), config) - Assertions.assertEquals(expectedCode, timeBasedOneTimePasswordGenerator.generate(timestamp)) - Assertions.assertTrue(timeBasedOneTimePasswordGenerator.isValid(expectedCode, timestamp)) + assertEquals(expectedCode, timeBasedOneTimePasswordGenerator.generate(timestamp)) + assertTrue(timeBasedOneTimePasswordGenerator.isValid(expectedCode, timestamp)) } // -- Inner Type -------------------------------------------------------------------------------------------------- //