Compare commits

..

30 Commits

Author SHA1 Message Date
849a0d022f Merge pull request 'infendro-patch-1' (#4) from infendro-patch-1 into develop
Reviewed-on: #4
2026-04-16 16:09:01 +00:00
99ef6f2085 Update README.md 2026-04-16 16:08:13 +00:00
a2051dbd85 Merge pull request '1.3.1 Release' (#3) from develop into main
All checks were successful
/ publish (push) Successful in 3m6s
Reviewed-on: Infendro/otp-kt#3
2026-03-12 10:34:21 +00:00
767665180a Bump version to 1.3.1
All checks were successful
/ test (pull_request) Successful in 41s
2026-03-12 11:26:23 +01:00
8b0f24c6b4 Upgrade dependencies 2026-03-12 11:25:47 +01:00
baf897e9e3 Merge pull request '1.3.0 release' (#2) from develop into main
All checks were successful
/ publish (push) Successful in 2m38s
Reviewed-on: Infendro/otp-kt#2
2026-02-16 18:21:00 +00:00
e43fc4de86 Upgrade dependencies
All checks were successful
/ test (pull_request) Successful in 1m14s
2026-02-16 19:17:19 +01:00
733d633753 refactor README
All checks were successful
/ test (pull_request) Successful in 33s
2026-01-30 13:31:04 +01:00
f5f073168d minor refactor
All checks were successful
/ test (pull_request) Successful in 33s
2026-01-30 13:23:32 +01:00
59f7d925a3 split ci
All checks were successful
/ test (pull_request) Successful in 1m39s
2026-01-29 18:15:06 +01:00
d2b901a6ff upgrade
Some checks failed
/ test (push) Has been cancelled
/ publish (push) Has been cancelled
too tired to write a detailed commit message or split into self-explanatory commits
2026-01-29 18:12:16 +01:00
f85e22311d improve ci
All checks were successful
/ publish (push) Has been skipped
/ test (push) Successful in 3m57s
2026-01-13 18:06:00 +01:00
37bbe35482 update README
small rewording, link wikipedia article
2025-12-14 14:43:30 +01:00
8870703608 replace IllegalArgumentException with require 2025-12-14 00:16:09 +01:00
25e6fcdeab update README
bump version
2025-12-13 23:29:47 +01:00
62cbe2de3b bump version to 1.2.2
All checks were successful
/ publish (push) Successful in 3m20s
2025-12-13 23:28:41 +01:00
52f0ab0661 update README 2025-12-13 23:27:49 +01:00
e5bb1f5a44 bump version to 1.2.1
All checks were successful
/ publish (push) Successful in 3m34s
2025-12-13 02:35:09 +01:00
62d5438b3b use UByteArray for SecretGenerator 2025-12-13 02:28:53 +01:00
aa686cd81e Merge remote-tracking branch 'origin/main'
All checks were successful
/ publish (push) Successful in 3m22s
2025-12-13 02:12:49 +01:00
2804576658 fix workflow 2025-12-13 01:53:20 +01:00
49acfddde7 bump version to 1.2.0
Some checks failed
/ publish (push) Failing after 59s
2025-12-13 01:45:23 +01:00
498a5b5826 update dependencies, use common plugin 2025-12-13 01:45:23 +01:00
e8999ae4e4 update README
fix typo
2025-10-22 09:27:42 +00:00
bb5cd2878b add README 2025-10-04 12:22:45 +02:00
8b9a7ec26e small refactor 2025-08-17 14:53:04 +02:00
acc080b65e upgrade gradle wrapper 2025-08-17 14:52:48 +02:00
f32ebaf416 bump version to 1.1.1
All checks were successful
/ publish (push) Successful in 3m30s
2025-07-24 21:07:58 +02:00
633263c541 add opt-in 2025-07-24 21:07:38 +02:00
ba1c983094 update dependencies 2025-07-24 21:07:32 +02:00
16 changed files with 205 additions and 205 deletions

View File

@@ -1,36 +0,0 @@
on:
push:
branches:
- main
env:
GIT__TOKEN: ${{ secrets.TOKEN }}
jobs:
publish:
runs-on: linux
steps:
- uses: actions/checkout@v4
- uses: actions/cache/restore@v4
with:
key: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
~/.konan/
- uses: actions/setup-java@v4
with:
java-version: '24'
distribution: 'temurin'
- run: ./gradlew publish
- uses: actions/cache/save@v4
with:
key: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
~/.konan/

View File

@@ -0,0 +1,47 @@
on:
push:
branches: [ main ]
jobs:
publish:
runs-on: linux
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
java-version: '24'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
key: gradle-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
- name: Cache Konan
uses: actions/cache@v4
with:
key: konan-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: konan
path: |
~/.konan/
- name: Cache Node
uses: actions/cache@v4
with:
key: node-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: node
path: |
~/.gradle/nodejs
~/.gradle/yarn
- name: Publish
run: ./gradlew publish
env:
INFENDRO__TOKEN: ${{ secrets.TOKEN }}

View File

@@ -0,0 +1,28 @@
on:
pull_request:
branches: [ main ]
jobs:
test:
runs-on: linux
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
java-version: '24'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
key: gradle-${{ hashFiles('**/libs.versions.toml') }}
restore-keys: gradle
path: |
~/.gradle/caches/
~/.gradle/wrapper/
- name: Test
run: ./gradlew jvmTest

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# otp.kt
`otp.kt` is a Kotlin Multiplatform library that provides common [One-Time Password](https://en.wikipedia.org/wiki/One-time_password) algorithms.
Supported algorithms:
- HOTP
- TOTP
## Installation
Add the following to your `build.gradle.kts`.
```kotlin
repositories {
maven("https://git.infendro.com/api/packages/Infendro/maven")
}
dependencies {
implementation("com.infendro:otp:1.3.1")
}
```
## Usage
```kotlin
fun main() {
// create TOTP instance
val totp = TOTP(
function = SHA1(),
length = 6,
period = 30.seconds,
)
// generate and verify OTP
val secret = "...".encodeToByteArray()
val time = System.now()
val otp = totp.generate(secret, time)
totp.verify(secret, time, otp)
}
```

View File

@@ -1,56 +1,43 @@
group = "com.infendro" @file:OptIn(ExperimentalWasmDsl::class)
version = "1.1.0"
repositories { import com.infendro.plugin.infendro
maven("https://git.infendro.com/api/packages/Infendro/maven") import com.infendro.plugin.token
mavenCentral() import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
}
group = "com.infendro"
version = "1.3.1"
plugins { plugins {
alias(libs.plugins.infendro)
alias(libs.plugins.multiplatform) alias(libs.plugins.multiplatform)
id("maven-publish") id("maven-publish")
} }
kotlin { kotlin {
jvm() jvm()
js {
nodejs()
}
linuxX64() linuxX64()
linuxArm64()
mingwX64()
macosX64()
macosArm64()
js { nodejs() }
wasmJs { nodejs() }
wasmWasi { nodejs() }
repositories {
infendro()
mavenCentral()
}
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(libs.bytes)
implementation(libs.hash) implementation(libs.hash)
implementation(libs.mac) implementation(libs.mac)
} }
} }
} }
publishing { publishing.repositories {
repositories { infendro(token)
maven {
url = uri("https://git.infendro.com/api/packages/Infendro/maven")
authentication.create("header", HttpHeaderAuthentication::class)
credentials(HttpHeaderCredentials::class) {
val token = secret("git.token") ?: throw GradleException()
name = "Authorization"
value = "token $token"
}
}
}
}
fun secret(
key: String,
): String? {
val property = key
val environment = key
.uppercase()
.replace(".", "__")
val prop = properties[property] as String?
val env = System.getenv(environment)
return prop ?: env
} }

View File

@@ -1,11 +1,15 @@
[versions] [versions]
kotlin = "2.1.20" infendro = "1.1.0"
hash = "1.1.0" kotlin = "2.3.0"
mac = "1.1.0" bytes = "1.4.2"
hash = "1.7.0"
mac = "1.5.0"
[plugins] [plugins]
infendro = { id = "com.infendro.plugin", version.ref = "infendro" }
multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
[libraries] [libraries]
bytes = { module = "com.infendro:bytes", version.ref = "bytes" }
hash = { module = "com.infendro:hash", version.ref = "hash" } hash = { module = "com.infendro:hash", version.ref = "hash" }
mac = { module = "com.infendro:mac", version.ref = "mac" } mac = { module = "com.infendro:mac", version.ref = "mac" }

Binary file not shown.

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

12
gradlew vendored
View File

@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -112,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -203,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
@@ -211,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

26
gradlew.bat vendored
View File

@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
@@ -57,22 +59,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell

View File

@@ -1 +1,6 @@
rootProject.name = "otp" rootProject.name = "otp"
pluginManagement.repositories {
maven("https://git.infendro.com/api/packages/Infendro/maven")
mavenCentral()
}

View File

@@ -1,58 +1,32 @@
package com.infendro.otp package com.infendro.otp
import com.infendro.bytes.bytearray.toInt
import com.infendro.bytes.long.toByteArray
import com.infendro.hash.HashFunction import com.infendro.hash.HashFunction
import com.infendro.hash.SHA1 import com.infendro.hash.sha1.SHA1
import com.infendro.mac.HMAC import com.infendro.mac.HMAC
import com.infendro.otp.util.toByteArray import com.infendro.otp.util.pow
import com.infendro.otp.util.toInt
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.math.pow
class HOTP( class HOTP(
val function: HashFunction = SHA1, val function: HashFunction = SHA1(),
val length: Int = 6, val length: Int = 6,
) { ) {
fun verify( private val hmac = HMAC(function)
secret: ByteArray,
counter: Long, fun generate(secret: ByteArray, counter: Long): String {
otp: String, require(counter >= 0)
): Boolean { return otp(secret, counter)
}
fun verify(secret: ByteArray, counter: Long, otp: String): Boolean {
return generate(secret, counter) == otp return generate(secret, counter) == otp
} }
fun generate( private fun otp(secret: ByteArray, counter: Long): String {
secret: ByteArray, val hash = hmac.hash(secret, counter.toByteArray())
counter: Long,
): String {
if (counter < 0)
throw Exception()
val hash = generateHash(
secret,
counter.toByteArray()
)
return otp(hash)
}
private fun generateHash(
secret: ByteArray,
value: ByteArray,
): ByteArray {
return HMAC(function).hash(secret, value)
}
private fun otp(
hash: ByteArray,
): String {
val offset = (hash.last() and 0xF).toInt() val offset = (hash.last() and 0xF).toInt()
val otp = (hash.toInt(offset) and 0x7FFFFFFF) % 10.pow(length)
var truncatedHash = hash return "$otp".padStart(length, '0')
.drop(offset)
.take(4)
.toInt() and 0x7FFFFFFF
truncatedHash %= 10.0.pow(length).toInt()
return truncatedHash.toString()
.padStart(length, '0')
} }
} }

View File

@@ -1,25 +0,0 @@
package com.infendro.otp
import com.infendro.hash.*
import kotlin.random.Random
object SecretGenerator {
private val random = Random.Default
fun generate(
algorithm: HashFunction = SHA1,
): ByteArray {
val bytes = when (algorithm) {
MD5 -> 16
SHA1 -> 20
SHA224 -> 28
SHA256 -> 32
SHA384 -> 48
SHA512 -> 64
}
return ByteArray(bytes).also {
random.nextBytes(it)
}
}
}

View File

@@ -1,41 +1,27 @@
package com.infendro.otp package com.infendro.otp
import com.infendro.hash.HashFunction import com.infendro.hash.HashFunction
import com.infendro.hash.SHA1 import com.infendro.hash.sha1.SHA1
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
@ExperimentalTime
class TOTP( class TOTP(
function: HashFunction = SHA1, val function: HashFunction = SHA1(),
length: Int = 6, val length: Int = 6,
val period: Duration = 30.seconds, val period: Duration = 30.seconds,
) { ) {
val hotp = HOTP(function, length) private val hotp = HOTP(function, length)
fun verify( fun generate(secret: ByteArray, instant: Instant): String {
secret: ByteArray, return hotp.generate(secret, counter(instant))
instant: Instant, }
otp: String,
): Boolean { fun verify(secret: ByteArray, instant: Instant, otp: String): Boolean {
return generate(secret, instant) == otp return generate(secret, instant) == otp
} }
fun generate( private fun counter(instant: Instant): Long {
secret: ByteArray,
instant: Instant,
): String {
return hotp.generate(
secret,
counter(instant)
)
}
private fun counter(
instant: Instant,
): Long {
val ms = instant.toEpochMilliseconds() val ms = instant.toEpochMilliseconds()
val d = period.inWholeMilliseconds val d = period.inWholeMilliseconds
return ms / d return ms / d

View File

@@ -1,20 +0,0 @@
package com.infendro.otp.util
internal fun Long.toByteArray(): ByteArray {
return ByteArray(8) { i ->
val offset = (7 - i) * 8
(this shr offset).toByte()
}
}
internal fun ByteArray.toInt(): Int {
if (size != 4) throw Exception()
return fold(0) { acc, byte ->
(acc shl 8) or (byte.toInt() and 0xFF)
}
}
internal fun Collection<Byte>.toInt(): Int {
return toByteArray().toInt()
}

View File

@@ -0,0 +1,6 @@
package com.infendro.otp.util
import kotlin.math.pow
internal fun Int.pow(x: Int): Int =
this.toDouble().pow(x).toInt()