├── sharedLib
├── .gitignore
├── src
│ ├── nativeMain
│ │ ├── cinterop
│ │ │ └── zlib.def
│ │ └── kotlin
│ │ │ ├── tech
│ │ │ └── arnav
│ │ │ │ └── twofac
│ │ │ │ └── lib
│ │ │ │ └── Platform.native.kt
│ │ │ └── libtwofac.kt
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── lib
│ │ │ ├── Platform.kt
│ │ │ ├── annotations.kt
│ │ │ ├── storage
│ │ │ ├── Storage.kt
│ │ │ ├── StoredAccount.kt
│ │ │ ├── MemoryStorage.kt
│ │ │ └── StorageUtils.kt
│ │ │ ├── otp
│ │ │ ├── OTP.kt
│ │ │ ├── TOTP.kt
│ │ │ └── HOTP.kt
│ │ │ ├── crypto
│ │ │ ├── CryptoTools.kt
│ │ │ ├── DefaultCryptoTools.kt
│ │ │ └── Encoding.kt
│ │ │ └── TwoFacLib.kt
│ ├── jvmMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── lib
│ │ │ └── Platform.jvm.kt
│ ├── wasmJsMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── lib
│ │ │ └── Platform.wasmJs.kt
│ ├── jvmTest
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── lib
│ │ │ └── crypto
│ │ │ └── CryptoProviderTests.kt
│ ├── wasmJsTest
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── lib
│ │ │ └── crypto
│ │ │ └── CryptoProviderTests.kt
│ ├── nativeTest
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── lib
│ │ │ └── crypto
│ │ │ └── CryptoProviderTests.kt
│ └── commonTest
│ │ └── kotlin
│ │ └── tech
│ │ └── arnav
│ │ └── twofac
│ │ └── lib
│ │ ├── crypto
│ │ ├── CryptoToolsTest.kt
│ │ └── EncodingTest.kt
│ │ ├── otp
│ │ └── HOTPTest.kt
│ │ ├── TwoFacLibTest.kt
│ │ ├── storage
│ │ └── StorageUtilsTest.kt
│ │ └── uri
│ │ └── OtpAuthURITest.kt
├── api
│ ├── sharedLib.klib.api
│ └── sharedLib.api
└── build.gradle.kts
├── watchApp
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── values-round
│ │ │ └── strings.xml
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ └── styles.xml
│ │ ├── mipmap-anydpi
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ └── drawable
│ │ │ ├── splash_icon.xml
│ │ │ ├── ic_launcher_foreground.xml
│ │ │ └── ic_launcher_background.xml
│ │ ├── java
│ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── watch
│ │ │ └── presentation
│ │ │ ├── theme
│ │ │ └── Theme.kt
│ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
├── lint.xml
├── proguard-rules.pro
└── build.gradle.kts
├── composeApp
├── src
│ ├── androidMain
│ │ ├── res
│ │ │ ├── values
│ │ │ │ └── strings.xml
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.png
│ │ │ │ └── ic_launcher_round.png
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ │ └── drawable
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── kotlin
│ │ │ └── tech
│ │ │ │ └── arnav
│ │ │ │ └── twofac
│ │ │ │ ├── TwoFacApplication.kt
│ │ │ │ ├── Platform.android.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ └── storage
│ │ │ │ └── AppDirUtils.android.kt
│ │ └── AndroidManifest.xml
│ ├── wasmJsMain
│ │ ├── resources
│ │ │ ├── styles.css
│ │ │ └── index.html
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ ├── Platform.wasmJs.kt
│ │ │ ├── main.kt
│ │ │ └── storage
│ │ │ └── AppDirUtils.wasmJs.kt
│ ├── commonMain
│ │ ├── kotlin
│ │ │ └── tech
│ │ │ │ └── arnav
│ │ │ │ └── twofac
│ │ │ │ ├── Platform.kt
│ │ │ │ ├── Greeting.kt
│ │ │ │ ├── navigation
│ │ │ │ └── NavigationRoutes.kt
│ │ │ │ ├── storage
│ │ │ │ ├── AppDirUtils.kt
│ │ │ │ └── FileStorage.kt
│ │ │ │ ├── Application.kt
│ │ │ │ ├── di
│ │ │ │ └── modules.kt
│ │ │ │ ├── App.kt
│ │ │ │ ├── screens
│ │ │ │ ├── SettingsScreen.kt
│ │ │ │ ├── AddAccountScreen.kt
│ │ │ │ ├── AccountsScreen.kt
│ │ │ │ └── AccountDetailScreen.kt
│ │ │ │ ├── viewmodels
│ │ │ │ └── AccountsViewModel.kt
│ │ │ │ └── components
│ │ │ │ ├── PasskeyDialog.kt
│ │ │ │ └── OTPCard.kt
│ │ └── composeResources
│ │ │ └── drawable
│ │ │ └── compose-multiplatform.xml
│ ├── iosMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ ├── MainViewController.kt
│ │ │ ├── Platform.ios.kt
│ │ │ └── storage
│ │ │ └── AppDirUtils.ios.kt
│ ├── commonTest
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── ComposeAppCommonTest.kt
│ └── desktopMain
│ │ └── kotlin
│ │ └── tech
│ │ └── arnav
│ │ └── twofac
│ │ ├── main.kt
│ │ ├── Platform.jvm.kt
│ │ └── storage
│ │ └── AppDirUtils.jvm.kt
├── .gitignore
└── build.gradle.kts
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── iosApp
├── iosApp
│ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ ├── app-icon-1024.png
│ │ │ └── Contents.json
│ │ └── AccentColor.colorset
│ │ │ └── Contents.json
│ ├── Preview Content
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── iOSApp.swift
│ ├── Info.plist
│ └── ContentView.swift
├── Configuration
│ └── Config.xcconfig
├── iosApp.xcodeproj
│ └── project.xcworkspace
│ │ └── contents.xcworkspacedata
└── .gitignore
├── cliApp
├── gradle
│ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
├── src
│ ├── commonMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── cli
│ │ │ ├── di
│ │ │ ├── qualifiers.kt
│ │ │ └── modules.kt
│ │ │ ├── Platform.kt
│ │ │ ├── storage
│ │ │ ├── AppDirUtils.kt
│ │ │ └── FileStorage.kt
│ │ │ ├── viewmodels
│ │ │ └── AccountsViewModel.kt
│ │ │ ├── Main.kt
│ │ │ └── commands
│ │ │ ├── AddCommand.kt
│ │ │ ├── InfoCommand.kt
│ │ │ └── DisplayCommand.kt
│ ├── commonTest
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── cli
│ │ │ ├── di
│ │ │ └── KoinVerificationTest.kt
│ │ │ └── commands
│ │ │ ├── InfoCommandTest.kt
│ │ │ └── DisplayCommandTest.kt
│ ├── macosMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── cli
│ │ │ └── Platform.macos.kt
│ ├── linuxMain
│ │ └── kotlin
│ │ │ └── tech
│ │ │ └── arnav
│ │ │ └── twofac
│ │ │ └── cli
│ │ │ └── Platform.linux.kt
│ └── mingwMain
│ │ └── kotlin
│ │ └── tech
│ │ └── arnav
│ │ └── twofac
│ │ └── cli
│ │ └── Platform.mingw.kt
├── build.gradle.kts
└── gradlew.bat
├── .claude
└── settings.local.json
├── .github
├── copilot-instructions.md
├── git-commit-instructions.md
└── workflows
│ ├── cli-app-run.yml
│ └── lib-unit-tests.yml
├── kotlin-js-store
└── wasm
│ └── yarn.lock
├── .gitignore
├── versions.properties
├── gradle.properties
├── settings.gradle.kts
├── README.md
└── gradlew.bat
/sharedLib/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/watchApp/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/sharedLib/src/nativeMain/cinterop/zlib.def:
--------------------------------------------------------------------------------
1 | headers = zlib.h
2 | linkerOpts = -lz
3 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | twofac
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info": {
3 | "author": "xcode",
4 | "version": 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/cliApp/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/cliApp/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/Platform.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | expect fun libPlatform(): String
--------------------------------------------------------------------------------
/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info": {
3 | "author": "xcode",
4 | "version": 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/watchApp/src/main/res/values-round/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | From the Round world,\nHello, %1$s!
3 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/sharedLib/src/jvmMain/kotlin/tech/arnav/twofac/lib/Platform.jvm.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | actual fun libPlatform(): String {
4 | return "JVM"
5 | }
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/resources/styles.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | width: 100%;
3 | height: 100%;
4 | margin: 0;
5 | padding: 0;
6 | overflow: hidden;
7 | }
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/sharedLib/src/nativeMain/kotlin/tech/arnav/twofac/lib/Platform.native.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | actual fun libPlatform(): String {
4 | return "Native"
5 | }
--------------------------------------------------------------------------------
/sharedLib/src/wasmJsMain/kotlin/tech/arnav/twofac/lib/Platform.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | actual fun libPlatform(): String {
4 | return "wasm/js"
5 | }
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/watchApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/championswimmer/TwoFac/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
--------------------------------------------------------------------------------
/iosApp/Configuration/Config.xcconfig:
--------------------------------------------------------------------------------
1 | TEAM_ID=
2 |
3 | PRODUCT_NAME=twofac
4 | PRODUCT_BUNDLE_IDENTIFIER=tech.arnav.twofac.twofac$(TEAM_ID)
5 |
6 | CURRENT_PROJECT_VERSION=1
7 | MARKETING_VERSION=1.0
--------------------------------------------------------------------------------
/iosApp/iosApp/iOSApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct iOSApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/.claude/settings.local.json:
--------------------------------------------------------------------------------
1 | {
2 | "permissions": {
3 | "allow": [
4 | "WebFetch(domain:www.jetbrains.com)",
5 | "Bash(./gradlew:*)",
6 | "Bash(grep:*)"
7 | ],
8 | "deny": []
9 | }
10 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/Platform.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | interface Platform {
4 | val name: String
5 | fun getAppDataDir(): String
6 | }
7 |
8 | expect fun getPlatform(): Platform
--------------------------------------------------------------------------------
/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/di/qualifiers.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MatchingDeclarationName")
2 |
3 | package tech.arnav.twofac.cli.di
4 |
5 |
6 | @Target(AnnotationTarget.PROPERTY)
7 | annotation class StorageFilePath
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors": [
3 | {
4 | "idiom": "universal"
5 | }
6 | ],
7 | "info": {
8 | "author": "xcode",
9 | "version": 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/tech/arnav/twofac/MainViewController.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import androidx.compose.ui.window.ComposeUIViewController
4 |
5 | fun MainViewController() = ComposeUIViewController {
6 | initKoin()
7 | App()
8 | }
--------------------------------------------------------------------------------
/composeApp/src/commonTest/kotlin/tech/arnav/twofac/ComposeAppCommonTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import kotlin.test.Test
4 | import kotlin.test.assertEquals
5 |
6 | class ComposeAppCommonTest {
7 |
8 | @Test
9 | fun example() {
10 | assertEquals(3, 1 + 2)
11 | }
12 | }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/watchApp/lint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/cliApp/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CADisableMinimumFrameDurationOnPhone
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/Greeting.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import tech.arnav.twofac.lib.libPlatform as sharedLibPlatform
4 | class Greeting {
5 | private val platform = getPlatform()
6 |
7 | fun greet(): String {
8 | return "Hello, ${platform.name} ${sharedLibPlatform()}!"
9 | }
10 | }
--------------------------------------------------------------------------------
/watchApp/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | TwoFac
3 |
7 | From the Square world,\nHello, %1$s!
8 |
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/Platform.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | class WasmPlatform : Platform {
4 | override val name: String = "Web with Kotlin/Wasm"
5 |
6 | override fun getAppDataDir(): String {
7 | return "/tmp/twofac"
8 | }
9 | }
10 |
11 | actual fun getPlatform(): Platform = WasmPlatform()
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/tech/arnav/twofac/TwoFacApplication.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import android.app.Application
4 | import ca.gosyer.appdirs.impl.attachAppDirs
5 |
6 | class TwoFacApplication : Application() {
7 | override fun onCreate() {
8 | super.onCreate()
9 | attachAppDirs()
10 | initKoin()
11 | }
12 | }
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | This is a Kotlin Multiplatform project.
2 |
3 | This is what each module is for:
4 |
5 | - `sharedLib`: shared common business logic code
6 | - `composeApp`: Android, Desktop and Web (wasm) app using Compose
7 | - `iosApp`: iOS app that uses the `ComposeApp` as a framework
8 | - `cliApp`: command line interface app that uses the `sharedLib` for business logic
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/annotations.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
4 | @Retention(AnnotationRetention.BINARY)
5 | annotation class PublicApi
6 |
7 | @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
8 | @Retention(AnnotationRetention.BINARY)
9 | annotation class InternalApi
10 |
--------------------------------------------------------------------------------
/kotlin-js-store/wasm/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | "@js-joda/core@3.2.0":
6 | version "3.2.0"
7 | resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
8 | integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
9 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-anydpi/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .kotlin
3 | .gradle
4 | **/build/
5 | xcuserdata
6 | !src/**/build/
7 | local.properties
8 | .idea
9 | .DS_Store
10 | captures
11 | .externalNativeBuild
12 | .cxx
13 | *.xcodeproj/*
14 | !*.xcodeproj/project.pbxproj
15 | !*.xcodeproj/xcshareddata/
16 | !*.xcodeproj/project.xcworkspace/
17 | !*.xcworkspace/contents.xcworkspacedata
18 | **/xcshareddata/WorkspaceSettings.xcsettings
19 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/main.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import androidx.compose.ui.ExperimentalComposeUiApi
4 | import androidx.compose.ui.window.ComposeViewport
5 | import kotlinx.browser.document
6 |
7 | @OptIn(ExperimentalComposeUiApi::class)
8 | fun main() {
9 | initKoin()
10 |
11 | ComposeViewport(document.body!!) {
12 | App()
13 | }
14 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/navigation/NavigationRoutes.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.navigation
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | object Home
7 |
8 | @Serializable
9 | object Accounts
10 |
11 | @Serializable
12 | data class AccountDetail(val accountId: String)
13 |
14 | @Serializable
15 | object Settings
16 |
17 | @Serializable
18 | object AddAccount
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/resources/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | twofac
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/tech/arnav/twofac/main.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import androidx.compose.ui.window.Window
4 | import androidx.compose.ui.window.application
5 |
6 | fun main() {
7 | initKoin()
8 |
9 | application {
10 | Window(
11 | onCloseRequest = ::exitApplication,
12 | title = "twofac",
13 | ) {
14 | App()
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/versions.properties:
--------------------------------------------------------------------------------
1 | #### Dependencies and Plugin versions with their available updates.
2 | #### Generated by `./gradlew refreshVersions` version 0.60.6
3 | ####
4 | #### Don't manually edit or split the comments that start with four hashtags (####),
5 | #### they will be overwritten by refreshVersions.
6 | ####
7 | #### suppress inspection "SpellCheckingInspection" for whole file
8 | #### suppress inspection "UnusedProperty" for whole file
9 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/storage/AppDirUtils.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.storage
2 |
3 | import io.github.xxfast.kstore.KStore
4 | import tech.arnav.twofac.lib.storage.StoredAccount
5 |
6 | const val ACCOUNTS_STORAGE_KEY = "twofac_accounts"
7 | const val ACCOUNTS_STORAGE_FILE = "accounts.json"
8 |
9 | expect fun createAccountsStore(): KStore>
10 |
11 | expect fun getStoragePath(): String
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/Platform.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli
2 |
3 | import ca.gosyer.appdirs.AppDirs
4 |
5 | interface Platform {
6 | val name: String
7 |
8 | val appDirs get() = _appDirs
9 |
10 | companion object {
11 | private val _appDirs = AppDirs {
12 | appName = "TwoFac"
13 | appAuthor = "tech.arnav"
14 | macOS.useSpaceBetweenAuthorAndApp = false
15 | }
16 | }
17 | }
18 |
19 | expect fun getPlatform(): Platform
--------------------------------------------------------------------------------
/watchApp/src/main/java/tech/arnav/twofac/watch/presentation/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.watch.presentation.theme
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.wear.compose.material.MaterialTheme
5 |
6 | @Composable
7 | fun TwofacTheme(
8 | content: @Composable () -> Unit
9 | ) {
10 | /**
11 | * Empty theme to customize for your app.
12 | * See: https://developer.android.com/jetpack/compose/designsystems/custom
13 | */
14 | MaterialTheme(
15 | content = content
16 | )
17 | }
--------------------------------------------------------------------------------
/sharedLib/src/jvmTest/kotlin/tech/arnav/twofac/lib/crypto/CryptoProviderTests.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 | import kotlin.test.assertNotNull
7 |
8 |
9 | class CryptoProviderTests {
10 |
11 | @Test
12 | fun testJvmCryptoProviderAvailable() {
13 | val cryptoProvider = CryptographyProvider.Default
14 | assertNotNull(cryptoProvider)
15 | assertEquals("JDK", cryptoProvider.name)
16 | }
17 | }
--------------------------------------------------------------------------------
/sharedLib/src/wasmJsTest/kotlin/tech/arnav/twofac/lib/crypto/CryptoProviderTests.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 | import kotlin.test.assertNotNull
7 |
8 | class CryptoProviderTests {
9 |
10 | @Test
11 | fun testCryptoProviderAvailable() {
12 | val cryptoProvider = CryptographyProvider.Default
13 | assertNotNull(cryptoProvider)
14 | assertEquals("WebCrypto", cryptoProvider.name)
15 | }
16 | }
--------------------------------------------------------------------------------
/.github/git-commit-instructions.md:
--------------------------------------------------------------------------------
1 | Generate commit messages that are clear, concise, and follow the conventional commit format.
2 | Use [feat], [fix], [docs], [style], [refactor], [perf], [test], [ci] and similar prefixes.
3 | Do not use emojis in commit messages.
4 |
5 | # Commit Message Guidelines
6 |
7 | When writing commit messages, please follow these guidelines to ensure clarity and consistency:
8 |
9 | - Use a short single line summary (50 characters or less) to describe the change.
10 | - Add detailed explanations in the body. Use bullet points for clarity.
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/Application.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import org.koin.core.context.startKoin
4 | import org.koin.dsl.KoinAppDeclaration
5 | import tech.arnav.twofac.di.appModule
6 | import tech.arnav.twofac.di.storageModule
7 | import tech.arnav.twofac.di.viewModelModule
8 |
9 | fun initKoin(appDeclaration: KoinAppDeclaration = {}) =
10 | startKoin {
11 | appDeclaration()
12 | modules(
13 | storageModule,
14 | appModule,
15 | viewModelModule
16 | )
17 | }
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/tech/arnav/twofac/storage/AppDirUtils.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.storage
2 |
3 | import io.github.xxfast.kstore.KStore
4 | import io.github.xxfast.kstore.storage.storeOf
5 | import tech.arnav.twofac.lib.storage.StoredAccount
6 |
7 | actual fun createAccountsStore(): KStore> {
8 | return storeOf(
9 | key = ACCOUNTS_STORAGE_KEY,
10 | default = emptyList()
11 | )
12 | }
13 |
14 | actual fun getStoragePath(): String {
15 | return "Browser LocalStorage (key: $ACCOUNTS_STORAGE_KEY)"
16 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/tech/arnav/twofac/Platform.android.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import android.os.Build
4 | import ca.gosyer.appdirs.AppDirs
5 |
6 | class AndroidPlatform : Platform {
7 | override val name: String = "Android ${Build.VERSION.SDK_INT}"
8 |
9 | private val appDirs = AppDirs {
10 | appName = "TwoFac"
11 | appAuthor = "tech.arnav"
12 | }
13 |
14 | override fun getAppDataDir(): String {
15 | return appDirs.getUserDataDir()
16 | }
17 | }
18 |
19 | actual fun getPlatform(): Platform = AndroidPlatform()
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | #Kotlin
2 | kotlin.code.style=official
3 | kotlin.daemon.jvmargs=-Xmx6G
4 | #Gradle
5 | org.gradle.jvmargs=-Xmx6G -Dfile.encoding=UTF-8
6 | org.gradle.configuration-cache=true
7 | org.gradle.caching=true
8 | #Android
9 | android.nonTransitiveRClass=true
10 | android.useAndroidX=true
11 | #Native
12 | kotlin.native.cacheKind.macosArm64=none
13 | kotlin.native.ignoreDisabledTargets=true
14 | kotlin.mpp.enableCInteropCommonization=true
15 | #Dokka
16 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
17 | org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/tech/arnav/twofac/Platform.jvm.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import ca.gosyer.appdirs.AppDirs
4 |
5 | class JVMPlatform : Platform {
6 | override val name: String = "Java ${System.getProperty("java.version")}"
7 |
8 | private val appDirs = AppDirs {
9 | appName = "TwoFac"
10 | appAuthor = "tech.arnav"
11 | macOS.useSpaceBetweenAuthorAndApp = false
12 | }
13 |
14 | override fun getAppDataDir(): String {
15 | return appDirs.getUserDataDir()
16 | }
17 | }
18 |
19 | actual fun getPlatform(): Platform = JVMPlatform()
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/storage/Storage.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.storage
2 |
3 | import tech.arnav.twofac.lib.PublicApi
4 | import kotlin.uuid.ExperimentalUuidApi
5 | import kotlin.uuid.Uuid
6 |
7 | @PublicApi
8 | interface Storage {
9 |
10 | suspend fun getAccountList(): List
11 |
12 | suspend fun getAccount(accountLabel: String): StoredAccount?
13 |
14 | @OptIn(ExperimentalUuidApi::class)
15 | suspend fun getAccount(accountID: Uuid): StoredAccount?
16 |
17 | suspend fun saveAccount(account: StoredAccount): Boolean
18 |
19 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import ComposeApp
4 |
5 | struct ComposeView: UIViewControllerRepresentable {
6 | func makeUIViewController(context: Context) -> UIViewController {
7 | MainViewControllerKt.MainViewController()
8 | }
9 |
10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
11 | }
12 | }
13 |
14 | struct ContentView: View {
15 | var body: some View {
16 | ComposeView()
17 | .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
18 | }
19 | }
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/sharedLib/src/nativeTest/kotlin/tech/arnav/twofac/lib/crypto/CryptoProviderTests.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import kotlin.test.Test
5 | import kotlin.test.assertNotNull
6 | import kotlin.test.assertTrue
7 |
8 | class CryptoProviderTests {
9 |
10 | @Test
11 | fun testCryptoProviderAvailable() {
12 | val cryptoProvider = CryptographyProvider.Default
13 | assertNotNull(cryptoProvider)
14 | // Check if the provider is OpenSSL (usually "OpenSSL3 (3.x.x)" or similar)
15 | assertTrue(cryptoProvider.name.startsWith("OpenSSL"))
16 | }
17 | }
--------------------------------------------------------------------------------
/cliApp/src/commonTest/kotlin/tech/arnav/twofac/cli/di/KoinVerificationTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.di
2 |
3 | import org.koin.core.logger.Level
4 | import org.koin.dsl.koinApplication
5 | import org.koin.test.KoinTest
6 | import org.koin.test.check.checkModules
7 | import kotlin.test.Test
8 |
9 | class KoinVerificationTest : KoinTest {
10 |
11 | @Test
12 | fun shouldInjectKoinModules() {
13 | koinApplication {
14 | printLogger(Level.INFO)
15 | modules(appModule, storageModule)
16 | @Suppress("DEPRECATION") // verify() not supported outside JVM yet
17 | checkModules()
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/storage/AppDirUtils.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.storage
2 |
3 | import kotlinx.io.files.Path
4 | import kotlinx.io.files.SystemFileSystem
5 | import tech.arnav.twofac.cli.getPlatform
6 |
7 | object AppDirUtils {
8 | const val ACCOUNTS_STORAGE_FILE_NAME = "accounts.json"
9 | private val appDirs get() = getPlatform().appDirs
10 |
11 | fun getStorageFilePath(forceCreate: Boolean = false): Path {
12 | val dir = appDirs.getUserDataDir()
13 | if (forceCreate) {
14 | SystemFileSystem.createDirectories(Path(dir))
15 | }
16 | return Path(dir, ACCOUNTS_STORAGE_FILE_NAME)
17 | }
18 | }
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/viewmodels/AccountsViewModel.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.viewmodels
2 |
3 | import tech.arnav.twofac.lib.TwoFacLib
4 | import tech.arnav.twofac.lib.storage.StoredAccount
5 |
6 | typealias DisplayAccountsStatic = List>
7 |
8 | class AccountsViewModel(val twoFacLib: TwoFacLib) {
9 |
10 | suspend fun unlock(passkey: String) {
11 | return twoFacLib.unlock(passkey)
12 | }
13 |
14 | suspend fun showAllAccountOTPs(): DisplayAccountsStatic = twoFacLib.getAllAccountOTPs()
15 |
16 | suspend fun addAccount(accountURI: String) = twoFacLib.addAccount(accountURI)
17 |
18 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/tech/arnav/twofac/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.tooling.preview.Preview
9 |
10 | class MainActivity : ComponentActivity() {
11 | override fun onCreate(savedInstanceState: Bundle?) {
12 | enableEdgeToEdge()
13 | super.onCreate(savedInstanceState)
14 |
15 | setContent {
16 | App()
17 | }
18 | }
19 | }
20 |
21 | @Preview
22 | @Composable
23 | fun AppAndroidPreview() {
24 | App()
25 | }
--------------------------------------------------------------------------------
/cliApp/src/commonTest/kotlin/tech/arnav/twofac/cli/commands/InfoCommandTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.commands
2 |
3 | import com.github.ajalt.clikt.testing.test
4 | import kotlin.test.Test
5 | import kotlin.test.assertContains
6 | import kotlin.test.assertTrue
7 |
8 | class InfoCommandTest {
9 |
10 | val infoCommand = InfoCommand()
11 |
12 | @Test
13 | fun testInfoCommand() {
14 | val result = infoCommand.test()
15 |
16 | assertTrue(result.statusCode == 0)
17 | assertContains(result.output, "TwoFac CLI")
18 | assertContains(result.output, "Platform")
19 | assertContains(result.output, "Library")
20 | assertContains(result.output, "Data Directory")
21 |
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/cliApp/src/macosMain/kotlin/tech/arnav/twofac/cli/Platform.macos.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MatchingDeclarationName")
2 |
3 | package tech.arnav.twofac.cli
4 |
5 | import kotlinx.cinterop.ExperimentalForeignApi
6 | import kotlinx.cinterop.useContents
7 | import platform.Foundation.NSProcessInfo
8 |
9 | class MacOSPlatform : Platform {
10 | @OptIn(ExperimentalForeignApi::class)
11 | private fun getMacOSVersion(): String {
12 | val osVersion = NSProcessInfo.processInfo.operatingSystemVersion
13 | osVersion.useContents {
14 | return "${majorVersion}.${minorVersion}.${patchVersion}"
15 | }
16 | }
17 |
18 | override val name: String = "macOS ${getMacOSVersion()}"
19 | }
20 |
21 | actual fun getPlatform(): Platform = MacOSPlatform()
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/di/modules.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.di
2 |
3 | import org.koin.dsl.module
4 | import tech.arnav.twofac.lib.TwoFacLib
5 | import tech.arnav.twofac.lib.storage.Storage
6 | import tech.arnav.twofac.storage.FileStorage
7 | import tech.arnav.twofac.storage.createAccountsStore
8 | import tech.arnav.twofac.viewmodels.AccountsViewModel
9 |
10 | val storageModule = module {
11 | single {
12 | FileStorage(createAccountsStore())
13 | }
14 | }
15 |
16 | val appModule = module {
17 | single {
18 | TwoFacLib.initialise(storage = get())
19 | }
20 | }
21 |
22 | val viewModelModule = module {
23 | single {
24 | AccountsViewModel(twoFacLib = get())
25 | }
26 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images": [
3 | {
4 | "filename": "app-icon-1024.png",
5 | "idiom": "universal",
6 | "platform": "ios",
7 | "size": "1024x1024"
8 | },
9 | {
10 | "appearances": [
11 | {
12 | "appearance": "luminosity",
13 | "value": "dark"
14 | }
15 | ],
16 | "idiom": "universal",
17 | "platform": "ios",
18 | "size": "1024x1024"
19 | },
20 | {
21 | "appearances": [
22 | {
23 | "appearance": "luminosity",
24 | "value": "tinted"
25 | }
26 | ],
27 | "idiom": "universal",
28 | "platform": "ios",
29 | "size": "1024x1024"
30 | }
31 | ],
32 | "info": {
33 | "author": "xcode",
34 | "version": 1
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/watchApp/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/tech/arnav/twofac/Platform.ios.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import platform.Foundation.NSDocumentDirectory
4 | import platform.Foundation.NSSearchPathForDirectoriesInDomains
5 | import platform.Foundation.NSUserDomainMask
6 | import platform.UIKit.UIDevice
7 |
8 | class IOSPlatform : Platform {
9 | override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
10 |
11 | override fun getAppDataDir(): String {
12 | val documentDirectories = NSSearchPathForDirectoriesInDomains(
13 | directory = NSDocumentDirectory,
14 | domainMask = NSUserDomainMask,
15 | expandTilde = true
16 | )
17 | return documentDirectories.firstOrNull() as? String ?: ""
18 | }
19 | }
20 |
21 | actual fun getPlatform(): Platform = IOSPlatform()
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/di/modules.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.di
2 |
3 | import org.koin.core.qualifier.named
4 | import org.koin.dsl.module
5 | import tech.arnav.twofac.cli.storage.AppDirUtils
6 | import tech.arnav.twofac.cli.storage.FileStorage
7 | import tech.arnav.twofac.cli.viewmodels.AccountsViewModel
8 | import tech.arnav.twofac.lib.TwoFacLib
9 | import tech.arnav.twofac.lib.storage.Storage
10 |
11 | val storageModule = module {
12 |
13 | single(named()) {
14 | AppDirUtils.getStorageFilePath(forceCreate = true)
15 | }
16 |
17 | single {
18 | FileStorage(get(named()))
19 | }
20 | }
21 |
22 | val appModule = module {
23 |
24 | single {
25 | TwoFacLib.initialise(
26 | storage = get(),
27 | )
28 | }
29 |
30 | single {
31 | AccountsViewModel(twoFacLib = get())
32 | }
33 | }
--------------------------------------------------------------------------------
/cliApp/src/linuxMain/kotlin/tech/arnav/twofac/cli/Platform.linux.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MatchingDeclarationName")
2 |
3 | package tech.arnav.twofac.cli
4 |
5 | import kotlinx.cinterop.ExperimentalForeignApi
6 | import kotlinx.cinterop.alloc
7 | import kotlinx.cinterop.memScoped
8 | import kotlinx.cinterop.ptr
9 | import kotlinx.cinterop.toKString
10 | import platform.posix.uname
11 | import platform.posix.utsname
12 |
13 | class LinuxPlatform : Platform {
14 | @OptIn(ExperimentalForeignApi::class)
15 | private fun getLinuxKernelVersion(): String {
16 | memScoped {
17 | val utsname = alloc()
18 | if (uname(utsname.ptr) == 0) {
19 | return "${utsname.sysname.toKString()} ${utsname.release.toKString()}"
20 | }
21 | return "Linux"
22 | }
23 | }
24 |
25 | override val name: String = getLinuxKernelVersion()
26 | }
27 |
28 | actual fun getPlatform(): Platform = LinuxPlatform()
29 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/tech/arnav/twofac/storage/AppDirUtils.android.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.storage
2 |
3 | import ca.gosyer.appdirs.AppDirs
4 | import io.github.xxfast.kstore.KStore
5 | import io.github.xxfast.kstore.file.storeOf
6 | import kotlinx.io.files.Path
7 | import kotlinx.io.files.SystemFileSystem
8 | import tech.arnav.twofac.lib.storage.StoredAccount
9 |
10 | private val appDirs = AppDirs {
11 | appName = "TwoFac"
12 | appAuthor = "tech.arnav"
13 | }
14 |
15 | actual fun createAccountsStore(): KStore> {
16 | val dir = appDirs.getUserDataDir()
17 | SystemFileSystem.createDirectories(Path(dir))
18 | val filePath = Path(dir, ACCOUNTS_STORAGE_FILE)
19 |
20 | return storeOf(
21 | file = filePath,
22 | default = emptyList()
23 | )
24 | }
25 |
26 | actual fun getStoragePath(): String {
27 | val dir = appDirs.getUserDataDir()
28 | return Path(dir, ACCOUNTS_STORAGE_FILE).toString()
29 | }
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/storage/StoredAccount.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalUuidApi::class)
2 |
3 | package tech.arnav.twofac.lib.storage
4 |
5 | import kotlinx.serialization.Serializable
6 | import tech.arnav.twofac.lib.PublicApi
7 | import kotlin.uuid.ExperimentalUuidApi
8 | import kotlin.uuid.Uuid
9 |
10 | @PublicApi
11 | @Serializable
12 | data class StoredAccount constructor(
13 | val accountID: Uuid,
14 | val accountLabel: String,
15 | val salt: String,
16 | val encryptedURI: String,
17 | ) {
18 | data class DisplayAccount(
19 | val accountID: String,
20 | val accountLabel: String,
21 | val nextCodeAt: Long = 0L,
22 | )
23 |
24 | fun forDisplay(nextCodeAt: Long? = 0L): DisplayAccount {
25 | return DisplayAccount(
26 | accountID = accountID.toString(),
27 | accountLabel = accountLabel,
28 | nextCodeAt = nextCodeAt ?: 0L,
29 | )
30 | }
31 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/tech/arnav/twofac/storage/AppDirUtils.jvm.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.storage
2 |
3 | import ca.gosyer.appdirs.AppDirs
4 | import io.github.xxfast.kstore.KStore
5 | import io.github.xxfast.kstore.file.storeOf
6 | import kotlinx.io.files.Path
7 | import kotlinx.io.files.SystemFileSystem
8 | import tech.arnav.twofac.lib.storage.StoredAccount
9 |
10 | private val appDirs = AppDirs {
11 | appName = "TwoFac"
12 | appAuthor = "tech.arnav"
13 | macOS.useSpaceBetweenAuthorAndApp = false
14 | }
15 |
16 | actual fun createAccountsStore(): KStore> {
17 | val dir = appDirs.getUserDataDir()
18 | SystemFileSystem.createDirectories(Path(dir))
19 | val filePath = Path(dir, ACCOUNTS_STORAGE_FILE)
20 |
21 | return storeOf(
22 | file = filePath,
23 | default = emptyList()
24 | )
25 | }
26 |
27 | actual fun getStoragePath(): String {
28 | val dir = appDirs.getUserDataDir()
29 | return Path(dir, ACCOUNTS_STORAGE_FILE).toString()
30 | }
--------------------------------------------------------------------------------
/composeApp/.gitignore:
--------------------------------------------------------------------------------
1 | ### Android template
2 | # Gradle files
3 | .gradle/
4 | build/
5 |
6 | # Local configuration file (sdk path, etc)
7 | local.properties
8 |
9 | # Log/OS Files
10 | *.log
11 |
12 | # Android Studio generated files and folders
13 | captures/
14 | .externalNativeBuild/
15 | .cxx/
16 | *.apk
17 | output.json
18 |
19 | # IntelliJ
20 | *.iml
21 | .idea/
22 | misc.xml
23 | deploymentTargetDropDown.xml
24 | render.experimental.xml
25 |
26 | # Keystore files
27 | *.jks
28 | *.keystore
29 |
30 | # Google Services (e.g. APIs or Firebase)
31 | google-services.json
32 |
33 | # Android Profiling
34 | *.hprof
35 |
36 | ### Kotlin template
37 | # Compiled class file
38 | *.class
39 |
40 | # Log file
41 | *.log
42 |
43 | # BlueJ files
44 | *.ctxt
45 |
46 | # Mobile Tools for Java (J2ME)
47 | .mtj.tmp/
48 |
49 | # Package Files #
50 | *.jar
51 | *.war
52 | *.nar
53 | *.ear
54 | *.zip
55 | *.tar.gz
56 | *.rar
57 |
58 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
59 | hs_err_pid*
60 | replay_pid*
61 |
62 |
--------------------------------------------------------------------------------
/cliApp/src/mingwMain/kotlin/tech/arnav/twofac/cli/Platform.mingw.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("MatchingDeclarationName")
2 |
3 | package tech.arnav.twofac.cli
4 |
5 | import kotlinx.cinterop.ExperimentalForeignApi
6 | import kotlinx.cinterop.alloc
7 | import kotlinx.cinterop.memScoped
8 | import kotlinx.cinterop.ptr
9 | import kotlinx.cinterop.sizeOf
10 | import platform.windows.GetVersionExW
11 | import platform.windows.OSVERSIONINFOW
12 |
13 | class WindowsPlatform : Platform {
14 | @OptIn(ExperimentalForeignApi::class)
15 | private fun getWindowsVersion(): String {
16 | memScoped {
17 | val versionInfo = alloc()
18 | versionInfo.dwOSVersionInfoSize = sizeOf().toUInt()
19 | GetVersionExW(versionInfo.ptr)
20 |
21 | return "${versionInfo.dwMajorVersion}.${versionInfo.dwMinorVersion}.${versionInfo.dwBuildNumber}"
22 | }
23 | }
24 |
25 | override val name: String = "Windows ${getWindowsVersion()}"
26 | }
27 |
28 | actual fun getPlatform(): Platform = WindowsPlatform()
--------------------------------------------------------------------------------
/composeApp/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
12 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/Main.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli
2 |
3 | import com.github.ajalt.clikt.core.CliktCommand
4 | import com.github.ajalt.clikt.core.main
5 | import com.github.ajalt.clikt.core.subcommands
6 | import org.koin.core.context.startKoin
7 | import tech.arnav.twofac.cli.commands.AddCommand
8 | import tech.arnav.twofac.cli.commands.DisplayCommand
9 | import tech.arnav.twofac.cli.commands.InfoCommand
10 | import tech.arnav.twofac.cli.di.appModule
11 | import tech.arnav.twofac.cli.di.storageModule
12 |
13 | class MainCommand(val args: Array) : CliktCommand() {
14 |
15 |
16 | override val invokeWithoutSubcommand = true
17 | override fun run() {
18 | // If a subcommand is invoked, skip the main command logic
19 | if (currentContext.invokedSubcommands.isNotEmpty()) return
20 | // Else, run the display logic directly here
21 | DisplayCommand().main(args)
22 | }
23 | }
24 |
25 | fun main(args: Array) {
26 | startKoin {
27 | modules(storageModule, appModule)
28 | }
29 |
30 | MainCommand(args).subcommands(
31 | DisplayCommand(),
32 | InfoCommand(),
33 | AddCommand(),
34 | ).main(args)
35 | }
36 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/drawable/splash_icon.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
8 |
9 |
10 |
11 |
12 | -
16 |
22 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "twofac"
2 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
3 |
4 | pluginManagement {
5 | repositories {
6 | google {
7 | @Suppress("UnstableApiUsage")
8 | mavenContent {
9 | includeGroupAndSubgroups("androidx")
10 | includeGroupAndSubgroups("com.android")
11 | includeGroupAndSubgroups("com.google")
12 | }
13 | }
14 | mavenCentral()
15 | gradlePluginPortal()
16 | }
17 | }
18 |
19 | @Suppress("UnstableApiUsage")
20 | dependencyResolutionManagement {
21 | repositories {
22 | mavenLocal() // TODO: remove when KStore 2.0.0 is released
23 | google {
24 | mavenContent {
25 | includeGroupAndSubgroups("androidx")
26 | includeGroupAndSubgroups("com.android")
27 | includeGroupAndSubgroups("com.google")
28 | }
29 | }
30 | mavenCentral()
31 | }
32 | }
33 |
34 | plugins {
35 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
36 | id("de.fayard.refreshVersions") version "0.60.6"
37 | }
38 |
39 | include(":composeApp")
40 | include(":sharedLib")
41 | include(":cliApp")
42 |
43 | include(":watchApp")
44 |
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/tech/arnav/twofac/storage/AppDirUtils.ios.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.storage
2 |
3 | import io.github.xxfast.kstore.KStore
4 | import io.github.xxfast.kstore.file.storeOf
5 | import kotlinx.io.files.Path
6 | import kotlinx.io.files.SystemFileSystem
7 | import platform.Foundation.NSDocumentDirectory
8 | import platform.Foundation.NSSearchPathForDirectoriesInDomains
9 | import platform.Foundation.NSUserDomainMask
10 | import tech.arnav.twofac.lib.storage.StoredAccount
11 |
12 | private fun getDocumentDirectory(): String {
13 | val documentDirectories = NSSearchPathForDirectoriesInDomains(
14 | directory = NSDocumentDirectory,
15 | domainMask = NSUserDomainMask,
16 | expandTilde = true
17 | )
18 | return documentDirectories.firstOrNull() as? String ?: ""
19 | }
20 |
21 | actual fun createAccountsStore(): KStore> {
22 | val dir = getDocumentDirectory()
23 | SystemFileSystem.createDirectories(Path(dir))
24 | val filePath = Path(dir, ACCOUNTS_STORAGE_FILE)
25 |
26 | return storeOf(
27 | file = filePath,
28 | default = emptyList()
29 | )
30 | }
31 |
32 | actual fun getStoragePath(): String {
33 | val dir = getDocumentDirectory()
34 | return Path(dir, ACCOUNTS_STORAGE_FILE).toString()
35 | }
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/commands/AddCommand.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.commands
2 |
3 | import com.github.ajalt.clikt.core.CliktCommand
4 | import com.github.ajalt.clikt.core.Context
5 | import com.github.ajalt.clikt.parameters.arguments.argument
6 | import com.github.ajalt.clikt.parameters.options.option
7 | import com.github.ajalt.clikt.parameters.options.prompt
8 | import kotlinx.coroutines.runBlocking
9 | import org.koin.core.component.KoinComponent
10 | import org.koin.core.component.inject
11 | import tech.arnav.twofac.cli.viewmodels.AccountsViewModel
12 |
13 | class AddCommand : CliktCommand(name = "add"), KoinComponent {
14 | override fun help(context: Context): String {
15 | return "Adds new accounts to the database. Provide an otpauth://... URI"
16 | }
17 |
18 | private val accountsViewModel: AccountsViewModel by inject()
19 |
20 | private val uri by argument(help = "otpauth:// URI")
21 | private val passkey by option("-p", "--passkey", help = "Passkey to add account").prompt(
22 | "Enter passkey",
23 | hideInput = true
24 | )
25 |
26 | override fun run() = runBlocking {
27 | accountsViewModel.unlock(passkey)
28 | accountsViewModel.addAccount(uri)
29 | echo("Account added successfully")
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/cliApp/src/commonTest/kotlin/tech/arnav/twofac/cli/commands/DisplayCommandTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.commands
2 |
3 | import com.github.ajalt.clikt.testing.test
4 | import org.koin.core.context.startKoin
5 | import tech.arnav.twofac.cli.di.appModule
6 | import tech.arnav.twofac.cli.di.storageModule
7 | import kotlin.test.BeforeClass
8 | import kotlin.test.Test
9 | import kotlin.test.assertContains
10 |
11 | class DisplayCommandTest {
12 |
13 | companion object {
14 | @BeforeClass
15 | fun setupKoin() {
16 | startKoin {
17 | modules(appModule, storageModule)
18 | }
19 | }
20 | }
21 |
22 | @Test
23 | fun testDisplayCommandWithoutPasskey() {
24 | val result = DisplayCommand().test()
25 |
26 | assertContains(result.output, "Passkey cannot be blank")
27 |
28 | }
29 |
30 | @Test
31 | fun testDisplayCommandWithPasskey() {
32 | val result = DisplayCommand().test("--passkey=testpasskey")
33 |
34 | assertContains(result.output, "display command executed with passkey: tes...")
35 | }
36 |
37 | @Test
38 | fun testDisplayCommandWithPasskeyInPrompt() {
39 | val result = DisplayCommand().test(stdin = "testpasskey\n")
40 |
41 | assertContains(result.output, "Enter passkey")
42 | assertContains(result.output, "display command executed with passkey: tes...")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/cliApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.kotlinMultiplatform)
3 | }
4 |
5 | group = "tech.arnav.twofac"
6 | version = "1.0-SNAPSHOT"
7 |
8 | kotlin {
9 | listOf(
10 | macosArm64(),
11 | macosX64(),
12 | linuxX64(),
13 | mingwX64(),
14 | ).forEach {
15 | it.apply {
16 | binaries.executable {
17 | baseName = "2fac"
18 | entryPoint = "tech.arnav.twofac.cli.main"
19 | }
20 | }
21 | }
22 |
23 | applyDefaultHierarchyTemplate()
24 |
25 | sourceSets {
26 | commonMain {
27 | dependencies {
28 | implementation(project(":sharedLib"))
29 | implementation(project.dependencies.platform(libs.koin.bom))
30 | implementation(libs.koin.core)
31 | implementation(libs.kotlinx.coroutines.core)
32 | implementation(libs.clikt)
33 | implementation(libs.mordant.coroutines)
34 | implementation(libs.kotlin.multiplatform.appdirs)
35 | implementation(libs.kstore)
36 | implementation(libs.kstore.file)
37 | }
38 | }
39 | commonTest {
40 | dependencies {
41 | implementation(libs.kotlin.test)
42 | implementation(libs.kotlinx.coroutines.test)
43 | implementation(libs.koin.test)
44 |
45 | }
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/commands/InfoCommand.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.commands
2 |
3 | import com.github.ajalt.clikt.core.CliktCommand
4 | import com.github.ajalt.clikt.core.Context
5 | import com.github.ajalt.clikt.core.terminal
6 | import com.github.ajalt.mordant.rendering.BorderType
7 | import com.github.ajalt.mordant.rendering.TextColors
8 | import com.github.ajalt.mordant.rendering.TextStyles
9 | import com.github.ajalt.mordant.table.Borders
10 | import com.github.ajalt.mordant.table.table
11 | import tech.arnav.twofac.cli.getPlatform
12 | import tech.arnav.twofac.lib.libPlatform
13 |
14 | class InfoCommand : CliktCommand("info") {
15 | override fun help(context: Context): String {
16 | return "Shows app information, platform and data directory."
17 | }
18 |
19 | override fun run() {
20 |
21 | terminal.println(table {
22 | borderType = BorderType.SQUARE_DOUBLE_SECTION_SEPARATOR
23 | header {
24 | style = TextStyles.bold + TextColors.yellow
25 | cellBorders = Borders.NONE
26 | row("TwoFac CLI Info")
27 | }
28 | body {
29 | column(0) {
30 | style = TextStyles.bold + TextColors.green
31 | }
32 | row("Platform", getPlatform().name)
33 | row("Library", libPlatform()) // TODO: add version via buildkonfig
34 | row("Data Directory", getPlatform().appDirs.getUserDataDir())
35 | }
36 | })
37 | }
38 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/storage/FileStorage.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.storage
2 |
3 | import io.github.xxfast.kstore.KStore
4 | import tech.arnav.twofac.lib.storage.Storage
5 | import tech.arnav.twofac.lib.storage.StoredAccount
6 | import kotlin.uuid.ExperimentalUuidApi
7 | import kotlin.uuid.Uuid
8 |
9 | class FileStorage(
10 | private val kstore: KStore>
11 | ) : Storage {
12 |
13 | override suspend fun getAccountList(): List {
14 | return kstore.get() ?: emptyList()
15 | }
16 |
17 | override suspend fun getAccount(accountLabel: String): StoredAccount? {
18 | return getAccountList().find { it.accountLabel == accountLabel }
19 | }
20 |
21 | @OptIn(ExperimentalUuidApi::class)
22 | override suspend fun getAccount(accountID: Uuid): StoredAccount? {
23 | return getAccountList().find { it.accountID == accountID }
24 | }
25 |
26 | @OptIn(ExperimentalUuidApi::class)
27 | override suspend fun saveAccount(account: StoredAccount): Boolean {
28 | return try {
29 | val currentAccounts = getAccountList().toMutableList()
30 | val existingIndex = currentAccounts.indexOfFirst { it.accountID == account.accountID }
31 |
32 | if (existingIndex >= 0) {
33 | currentAccounts[existingIndex] = account
34 | } else {
35 | currentAccounts.add(account)
36 | }
37 |
38 | kstore.set(currentAccounts)
39 | true
40 | } catch (e: Exception) {
41 | false
42 | }
43 | }
44 | }
--------------------------------------------------------------------------------
/.github/workflows/cli-app-run.yml:
--------------------------------------------------------------------------------
1 | name: CLI App Run
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 | workflow_dispatch: # For manual triggering
9 |
10 | jobs:
11 | run-cli-windows:
12 | runs-on: windows-latest
13 | steps:
14 | - uses: actions/checkout@v6
15 |
16 | - name: Set up JDK 21
17 | uses: actions/setup-java@v5
18 | with:
19 | java-version: '21'
20 | distribution: 'temurin'
21 |
22 | - name: Setup Gradle
23 | uses: gradle/gradle-build-action@v3
24 |
25 | - name: Run Windows CLI App
26 | run: ./gradlew :cliApp:runDebugExecutableMingwX64
27 |
28 | run-cli-macos:
29 | runs-on: macos-latest
30 | steps:
31 | - uses: actions/checkout@v6
32 |
33 | - name: Set up JDK 21
34 | uses: actions/setup-java@v5
35 | with:
36 | java-version: '21'
37 | distribution: 'temurin'
38 |
39 | - name: Setup Gradle
40 | uses: gradle/gradle-build-action@v3
41 |
42 | - name: Run macOS CLI App
43 | run: ./gradlew :cliApp:runDebugExecutableMacosArm64
44 |
45 | run-cli-ubuntu:
46 | runs-on: ubuntu-latest
47 | steps:
48 | - uses: actions/checkout@v6
49 |
50 | - name: Set up JDK 21
51 | uses: actions/setup-java@v5
52 | with:
53 | java-version: '21'
54 | distribution: 'temurin'
55 |
56 | - name: Setup Gradle
57 | uses: gradle/gradle-build-action@v3
58 |
59 | - name: Run Linux CLI App
60 | run: ./gradlew :cliApp:runDebugExecutableLinuxX64
61 |
--------------------------------------------------------------------------------
/sharedLib/src/commonTest/kotlin/tech/arnav/twofac/lib/crypto/CryptoToolsTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import kotlinx.coroutines.test.runTest
5 | import kotlinx.io.bytestring.encodeToByteString
6 | import kotlin.test.Test
7 | import kotlin.test.assertEquals
8 |
9 | class CryptoToolsTest {
10 |
11 | @OptIn(ExperimentalStdlibApi::class)
12 | @Test
13 | fun testEncryptDecrypt() = runTest {
14 | val tools = DefaultCryptoTools(CryptographyProvider.Default)
15 |
16 | val (signingKey, salt) = tools.createSigningKey("my-secret-password")
17 | val data = "my-secret-data".encodeToByteString()
18 |
19 | println("Signing Key: ${signingKey}")
20 |
21 | // Encrypt the data
22 | val encryptedData = tools.encrypt(signingKey, data)
23 |
24 | // Decrypt the data
25 | val decryptedData = tools.decrypt(encryptedData, signingKey)
26 |
27 | // Assert that the decrypted data matches the original data
28 | assertEquals(data.size, decryptedData.size)
29 | assertEquals(data, decryptedData)
30 | }
31 |
32 | fun testRoundTripCreateSigningKey() = runTest {
33 | val tools = DefaultCryptoTools(CryptographyProvider.Default)
34 |
35 | // Create a signing key with a password
36 | val (signingKey, salt) = tools.createSigningKey("my-secret-password")
37 | val (signingKey2, salt2) = tools.createSigningKey("my-secret-password", salt)
38 |
39 | // Assert that the keys are equal
40 | assertEquals(signingKey, signingKey2)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/watchApp/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
14 |
17 |
20 |
21 |
25 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/storage/MemoryStorage.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.storage
2 |
3 | import kotlin.uuid.ExperimentalUuidApi
4 | import kotlin.uuid.Uuid
5 |
6 | class MemoryStorage : Storage {
7 | // This is a simple in-memory storage implementation.
8 | // In a real application, you would implement the methods to store and retrieve accounts.
9 |
10 | private val accounts = mutableListOf()
11 |
12 | override suspend fun getAccountList(): List {
13 | // Return a copy of the list to prevent external modification
14 | return accounts.toList()
15 | }
16 |
17 | override suspend fun getAccount(accountLabel: String): StoredAccount? {
18 | // Find the account by label
19 | return accounts.find { it.accountLabel == accountLabel }
20 | }
21 |
22 | @OptIn(ExperimentalUuidApi::class)
23 | override suspend fun getAccount(accountID: Uuid): StoredAccount? {
24 | // Find the account by ID
25 | return accounts.find { it.accountID == accountID }
26 | }
27 |
28 | @OptIn(ExperimentalUuidApi::class)
29 | override suspend fun saveAccount(account: StoredAccount): Boolean {
30 | // Check if the account already exists
31 | val existingAccountIndex = accounts.indexOfFirst { it.accountID == account.accountID }
32 |
33 | return if (existingAccountIndex != -1) {
34 | // Update existing account
35 | accounts[existingAccountIndex] = account
36 | true
37 | } else {
38 | // Add new account
39 | accounts.add(account)
40 | true
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/storage/StorageUtils.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalUuidApi::class)
2 |
3 | package tech.arnav.twofac.lib.storage
4 |
5 | import dev.whyoleg.cryptography.CryptographyProvider
6 | import kotlinx.io.bytestring.decodeToString
7 | import kotlinx.io.bytestring.encodeToByteString
8 | import tech.arnav.twofac.lib.crypto.CryptoTools
9 | import tech.arnav.twofac.lib.crypto.DefaultCryptoTools
10 | import tech.arnav.twofac.lib.crypto.Encoding.toByteString
11 | import tech.arnav.twofac.lib.crypto.Encoding.toHexString
12 | import tech.arnav.twofac.lib.otp.OTP
13 | import tech.arnav.twofac.lib.uri.OtpAuthURI
14 | import kotlin.uuid.ExperimentalUuidApi
15 | import kotlin.uuid.Uuid
16 |
17 | object StorageUtils {
18 |
19 | private val cryptoTools = DefaultCryptoTools(CryptographyProvider.Default)
20 |
21 | suspend fun OTP.toStoredAccount(signingKey: CryptoTools.SigningKey): StoredAccount {
22 | val accountID = Uuid.fromByteArray(signingKey.salt.toByteArray())
23 | val otpAuthUriByteString = OtpAuthURI.create(this).encodeToByteString()
24 |
25 | val encryptedURI = cryptoTools.encrypt(signingKey.key, otpAuthUriByteString)
26 | return StoredAccount(
27 | accountID = accountID,
28 | accountLabel = "${issuer?.let { "$it:" } ?: ""}${accountName}",
29 | salt = signingKey.salt.toHexString(),
30 | encryptedURI = encryptedURI.toHexString()
31 | )
32 | }
33 |
34 | suspend fun StoredAccount.toOTP(signingKey: CryptoTools.SigningKey): OTP {
35 | val decryptedURI = cryptoTools.decrypt(encryptedURI.toByteString(), signingKey.key)
36 | return OtpAuthURI.parse(decryptedURI.decodeToString())
37 | }
38 | }
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/storage/FileStorage.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.storage
2 |
3 | import io.github.xxfast.kstore.extensions.getOrEmpty
4 | import io.github.xxfast.kstore.extensions.plus
5 | import io.github.xxfast.kstore.file.extensions.listStoreOf
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.IO
9 | import kotlinx.coroutines.async
10 | import kotlinx.io.files.Path
11 | import tech.arnav.twofac.lib.storage.Storage
12 | import tech.arnav.twofac.lib.storage.StoredAccount
13 | import kotlin.uuid.ExperimentalUuidApi
14 | import kotlin.uuid.Uuid
15 |
16 | class FileStorage(
17 | private val storageFilePath: Path
18 | ) : Storage {
19 | private val kstore = listStoreOf(storageFilePath)
20 |
21 | // TODO: need to use IO dispatcher for file operations
22 | private val coroutineScope = CoroutineScope(Dispatchers.IO)
23 |
24 | override suspend fun getAccountList(): List = coroutineScope.async {
25 | kstore.getOrEmpty()
26 | }.await()
27 |
28 | override suspend fun getAccount(accountLabel: String): StoredAccount? = coroutineScope.async {
29 | kstore.getOrEmpty().firstOrNull { it.accountLabel == accountLabel }
30 | }.await()
31 |
32 | @OptIn(ExperimentalUuidApi::class)
33 | override suspend fun getAccount(accountID: Uuid): StoredAccount? = coroutineScope.async {
34 | kstore.getOrEmpty().firstOrNull { it.accountID == accountID }
35 | }.await()
36 |
37 | override suspend fun saveAccount(account: StoredAccount): Boolean = coroutineScope.async {
38 | // TODO: handle duplicates and/or update existing accounts
39 | kstore.plus(account)
40 | true
41 | }.await()
42 | }
--------------------------------------------------------------------------------
/sharedLib/api/sharedLib.klib.api:
--------------------------------------------------------------------------------
1 | // Klib ABI Dump
2 | // Targets: [iosArm64, iosSimulatorArm64, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64, wasmJs]
3 | // Alias: native => [iosArm64, iosSimulatorArm64, linuxArm64, linuxX64, macosArm64, macosX64, mingwX64]
4 | // Rendering settings:
5 | // - Signature version: 2
6 | // - Show manifest properties: true
7 | // - Show declarations: true
8 |
9 | // Library unique name:
10 | abstract interface tech.arnav.twofac.lib.otp/OTP // tech.arnav.twofac.lib.otp/OTP|null[0]
11 |
12 | abstract interface tech.arnav.twofac.lib.storage/Storage // tech.arnav.twofac.lib.storage/Storage|null[0]
13 |
14 | final class tech.arnav.twofac.lib.storage/StoredAccount // tech.arnav.twofac.lib.storage/StoredAccount|null[0]
15 |
16 | final class tech.arnav.twofac.lib/TwoFacLib // tech.arnav.twofac.lib/TwoFacLib|null[0]
17 |
18 | // Targets: [native]
19 | final enum class /HashAlgo : kotlin/Enum { // /HashAlgo|null[0]
20 | enum entry SHA1 // /HashAlgo.SHA1|null[0]
21 | enum entry SHA256 // /HashAlgo.SHA256|null[0]
22 | enum entry SHA512 // /HashAlgo.SHA512|null[0]
23 | }
24 |
25 | // Targets: [native]
26 | final fun /genHOTP(kotlin/String, kotlin/Long, kotlin/Int = ..., /HashAlgo = ...): kotlin/String // /genHOTP|genHOTP(kotlin.String;kotlin.Long;kotlin.Int;HashAlgo){}[0]
27 |
28 | // Targets: [native]
29 | final fun /genHOTPFromUri(kotlin/String): kotlin/String // /genHOTPFromUri|genHOTPFromUri(kotlin.String){}[0]
30 |
31 | // Targets: [native]
32 | final fun /genTOTP(kotlin/String, kotlin/Long = ..., kotlin/Int = ..., /HashAlgo = ...): kotlin/String // /genTOTP|genTOTP(kotlin.String;kotlin.Long;kotlin.Int;HashAlgo){}[0]
33 |
34 | // Targets: [native]
35 | final fun /genTOTPFromUri(kotlin/String): kotlin/String // /genTOTPFromUri|genTOTPFromUri(kotlin.String){}[0]
36 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
9 |
10 |
16 |
19 |
22 |
23 |
24 |
25 |
31 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml:
--------------------------------------------------------------------------------
1 |
8 |
11 |
14 |
16 |
17 |
22 |
23 |
24 |
25 |
26 |
27 |
29 |
30 |
36 |
37 |
38 |
39 |
40 |
41 |
44 |
--------------------------------------------------------------------------------
/watchApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.androidApplication)
3 | alias(libs.plugins.kotlinAndroid)
4 | alias(libs.plugins.composeCompiler)
5 | }
6 |
7 | android {
8 | namespace = "tech.arnav.twofac.watch"
9 | compileSdk = libs.versions.android.compileSdk.get().toInt()
10 |
11 | defaultConfig {
12 | applicationId = "tech.arnav.twofac.watch"
13 | minSdk = 30
14 | targetSdk = libs.versions.android.targetSdk.get().toInt()
15 | versionCode = 1
16 | versionName = "1.0"
17 |
18 | }
19 |
20 | buildTypes {
21 | release {
22 | isMinifyEnabled = false
23 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
24 | }
25 | }
26 | compileOptions {
27 | sourceCompatibility = JavaVersion.VERSION_21
28 | targetCompatibility = JavaVersion.VERSION_21
29 | }
30 | useLibrary("wear-sdk")
31 | buildFeatures {
32 | compose = true
33 | }
34 | }
35 |
36 | dependencies {
37 |
38 | implementation(libs.play.services.wearable)
39 | implementation(platform(libs.androidx.compose.bom))
40 | implementation(libs.androidx.ui)
41 | implementation(libs.androidx.ui.graphics)
42 | implementation(libs.androidx.ui.tooling.preview)
43 | implementation(libs.androidx.compose.material)
44 | implementation(libs.androidx.compose.foundation)
45 | implementation(libs.androidx.wear.tooling.preview)
46 | implementation(libs.androidx.activity.compose)
47 | implementation(libs.androidx.core.splashscreen)
48 |
49 | implementation(libs.kstore.file)
50 | implementation(libs.kotlin.multiplatform.appdirs)
51 | implementation(project(":sharedLib"))
52 |
53 |
54 | androidTestImplementation(platform(libs.androidx.compose.bom))
55 | androidTestImplementation(libs.androidx.ui.test.junit4)
56 |
57 | debugImplementation(libs.androidx.ui.tooling)
58 | debugImplementation(libs.androidx.ui.test.manifest)
59 | }
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/otp/OTP.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.otp
2 |
3 | import tech.arnav.twofac.lib.PublicApi
4 | import tech.arnav.twofac.lib.crypto.CryptoTools
5 |
6 | /**
7 | * Common interface for OTP (One-Time Password) generation and validation.
8 | * Possible implementations include
9 | * - HOTP (HMAC-based One-Time Password) based on RFC 4226
10 | * - TOTP (Time-based One-Time Password) based on RFC 6238 (extension of HOTP)
11 | *
12 | */
13 | @PublicApi
14 | interface OTP {
15 |
16 | /**
17 | * The number of digits in the generated OTP. Default is 6.
18 | */
19 | val digits: Int
20 |
21 | /**
22 | * The algorithm used with HMAC for generating the OTP.
23 | * Default is SHA1, but can be changed to SHA256 or SHA512 if needed.
24 | */
25 | val algorithm: CryptoTools.Algo
26 |
27 | /**
28 | * The secret key used for generating the OTP. Base32-encoded string.
29 | */
30 | val secret: String
31 |
32 | /**
33 | * The account name associated with the OTP.
34 | * This is typically the username or email for which the OTP is generated.
35 | */
36 | val accountName: String
37 |
38 | /**
39 | * The issuer of the OTP, which is usually the service or application that provides the OTP.
40 | * This can be null if not specified.
41 | */
42 | val issuer: String?
43 |
44 |
45 | /**
46 | * Generate a new OTP based on the current counter.
47 | * The counter is typically incremented for each OTP generation.
48 | *
49 | * @param counter The current counter value, which is used to generate the OTP.
50 | * @return The generated OTP is a string with [digits] length.
51 | */
52 | suspend fun generateOTP(counter: Long): String
53 |
54 | /**
55 | * Validate an OTP against the expected value for a given counter.
56 | *
57 | * @param otp The OTP to validate.
58 | * @param counter The counter value used to generate the expected OTP.
59 | * @return True if the OTP is valid for the given counter, false otherwise.
60 | */
61 | suspend fun validateOTP(otp: String, counter: Long): Boolean
62 |
63 | }
64 |
--------------------------------------------------------------------------------
/sharedLib/src/commonTest/kotlin/tech/arnav/twofac/lib/otp/HOTPTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.otp
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 |
7 | class HOTPTest {
8 |
9 | @Test
10 | fun testGenerateOTP() = runTest {
11 | // Test vector from RFC 4226
12 | // Secret: "12345678901234567890" (ASCII)
13 | // Base32 encoded: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
14 | val hotp = HOTP(
15 | digits = 6,
16 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
17 | accountName = "test@example.com",
18 | issuer = "Test"
19 | )
20 |
21 | // Test vectors from RFC 4226 (Appendix D)
22 | val expectedOTPs = listOf(
23 | "755224", // Counter = 0
24 | "287082", // Counter = 1
25 | "359152", // Counter = 2
26 | "969429", // Counter = 3
27 | "338314", // Counter = 4
28 | "254676", // Counter = 5
29 | "287922", // Counter = 6
30 | "162583", // Counter = 7
31 | "399871", // Counter = 8
32 | "520489" // Counter = 9
33 | )
34 |
35 | // Test the first 10 OTPs
36 | for (i in 0..9) {
37 | val otp = hotp.generateOTP(i.toLong())
38 | println("Counter = $i, OTP = $otp, Expected = ${expectedOTPs[i]}")
39 | assertEquals(expectedOTPs[i], otp, "OTP for counter $i should match expected value")
40 | }
41 | }
42 |
43 | @Test
44 | fun testValidateOTP() = runTest {
45 | val hotp = HOTP(
46 | digits = 6,
47 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
48 | accountName = "test@example.com",
49 | issuer = "Test"
50 | )
51 |
52 | // Test validation with a known OTP
53 | val isValid = hotp.validateOTP("755224", 0)
54 | assertEquals(true, isValid, "OTP should be valid for counter 0")
55 |
56 | // Test validation with an invalid OTP
57 | val isInvalid = hotp.validateOTP("123456", 0)
58 | assertEquals(false, isInvalid, "OTP should be invalid")
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/.github/workflows/lib-unit-tests.yml:
--------------------------------------------------------------------------------
1 | name: Library Unit Tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 | workflow_dispatch: # For manual triggering
9 |
10 | jobs:
11 | test-windows:
12 | name: "Run mingwX64 tests on Windows"
13 | runs-on: windows-latest
14 | needs: test-ubuntu # Windows tests are costly. Run them only if Linux tests pass.
15 | steps:
16 | - uses: actions/checkout@v6
17 |
18 | - name: Set up JDK 17
19 | uses: actions/setup-java@v5
20 | with:
21 | java-version: '21'
22 | distribution: 'temurin'
23 |
24 | - name: Setup Gradle
25 | uses: gradle/gradle-build-action@v3
26 |
27 | - name: Run Windows Tests
28 | run: ./gradlew :sharedLib:mingwX64Test
29 |
30 | test-macos:
31 | name: "Run [mac + ios] tests on macOS"
32 | runs-on: macos-latest
33 | needs: test-ubuntu # MacOS tests are costly. Run them only if Linux tests pass.
34 | steps:
35 | - uses: actions/checkout@v6
36 |
37 | - name: Set up JDK 17
38 | uses: actions/setup-java@v5
39 | with:
40 | java-version: '21'
41 | distribution: 'temurin'
42 |
43 | - name: Setup Gradle
44 | uses: gradle/gradle-build-action@v3
45 |
46 | - name: Run macOS and iOS Tests
47 | run: |
48 | ./gradlew :sharedLib:macosArm64Test
49 | ./gradlew :sharedLib:iosSimulatorArm64Test
50 |
51 | test-ubuntu:
52 | name: "Run [jvm, web, linux] tests on Ubuntu"
53 | runs-on: ubuntu-latest
54 | steps:
55 | - uses: actions/checkout@v6
56 |
57 | - name: Set up JDK 17
58 | uses: actions/setup-java@v5
59 | with:
60 | java-version: '21'
61 | distribution: 'temurin'
62 |
63 | - name: Set up Node.js 22
64 | uses: actions/setup-node@v6
65 | with:
66 | node-version: '22'
67 |
68 | - name: Setup Gradle
69 | uses: gradle/gradle-build-action@v3
70 |
71 | - name: Check ABI Compatibility
72 | run: ./gradlew :sharedLib:checkLegacyAbi
73 |
74 | - name: Run Linux and JVM Tests
75 | run: |
76 | ./gradlew :sharedLib:linuxX64Test
77 | ./gradlew :sharedLib:jvmTest
78 | ./gradlew :sharedLib:wasmJsNodeTest
79 |
--------------------------------------------------------------------------------
/watchApp/src/main/java/tech/arnav/twofac/watch/presentation/MainActivity.kt:
--------------------------------------------------------------------------------
1 | /* While this template provides a good starting point for using Wear Compose, you can always
2 | * take a look at https://github.com/android/wear-os-samples/tree/main/ComposeStarter to find the
3 | * most up to date changes to the libraries and their usages.
4 | */
5 |
6 | package tech.arnav.twofac.watch.presentation
7 |
8 | import android.os.Bundle
9 | import androidx.activity.ComponentActivity
10 | import androidx.activity.compose.setContent
11 | import androidx.compose.foundation.background
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.fillMaxSize
14 | import androidx.compose.foundation.layout.fillMaxWidth
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.tooling.preview.Preview
21 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22 | import androidx.wear.compose.material.MaterialTheme
23 | import androidx.wear.compose.material.Text
24 | import androidx.wear.compose.material.TimeText
25 | import androidx.wear.tooling.preview.devices.WearDevices
26 | import tech.arnav.twofac.watch.R
27 | import tech.arnav.twofac.watch.presentation.theme.TwofacTheme
28 |
29 | class MainActivity : ComponentActivity() {
30 | override fun onCreate(savedInstanceState: Bundle?) {
31 | installSplashScreen()
32 |
33 | super.onCreate(savedInstanceState)
34 |
35 | setTheme(android.R.style.Theme_DeviceDefault)
36 |
37 | setContent {
38 | WearApp("Android")
39 | }
40 | }
41 | }
42 |
43 | @Composable
44 | fun WearApp(greetingName: String) {
45 | TwofacTheme {
46 | Box(
47 | modifier = Modifier
48 | .fillMaxSize()
49 | .background(MaterialTheme.colors.background),
50 | contentAlignment = Alignment.Center
51 | ) {
52 | TimeText()
53 | Greeting(greetingName = greetingName)
54 | }
55 | }
56 | }
57 |
58 | @Composable
59 | fun Greeting(greetingName: String) {
60 | Text(
61 | modifier = Modifier.fillMaxWidth(),
62 | textAlign = TextAlign.Center,
63 | color = MaterialTheme.colors.primary,
64 | text = stringResource(R.string.hello_world, greetingName)
65 | )
66 | }
67 |
68 | @Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true)
69 | @Composable
70 | fun DefaultPreview() {
71 | WearApp("Preview Android")
72 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/App.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.navigation.compose.NavHost
6 | import androidx.navigation.compose.composable
7 | import androidx.navigation.compose.rememberNavController
8 | import androidx.navigation.toRoute
9 | import org.jetbrains.compose.ui.tooling.preview.Preview
10 | import tech.arnav.twofac.navigation.AccountDetail
11 | import tech.arnav.twofac.navigation.Accounts
12 | import tech.arnav.twofac.navigation.AddAccount
13 | import tech.arnav.twofac.navigation.Home
14 | import tech.arnav.twofac.navigation.Settings
15 | import tech.arnav.twofac.screens.AccountDetailScreen
16 | import tech.arnav.twofac.screens.AccountsScreen
17 | import tech.arnav.twofac.screens.AddAccountScreen
18 | import tech.arnav.twofac.screens.HomeScreen
19 | import tech.arnav.twofac.screens.SettingsScreen
20 |
21 | @Composable
22 | @Preview
23 | fun App() {
24 | MaterialTheme {
25 | val navController = rememberNavController()
26 |
27 | NavHost(
28 | navController = navController,
29 | startDestination = Home
30 | ) {
31 | composable {
32 | HomeScreen(
33 | onNavigateToAccounts = { navController.navigate(Accounts) },
34 | onNavigateToSettings = { navController.navigate(Settings) }
35 | )
36 | }
37 |
38 | composable {
39 | AccountsScreen(
40 | onNavigateToAddAccount = { navController.navigate(AddAccount) },
41 | onNavigateToAccountDetail = { accountId ->
42 | navController.navigate(AccountDetail(accountId))
43 | },
44 | onNavigateBack = { navController.popBackStack() }
45 | )
46 | }
47 |
48 | composable { backStackEntry ->
49 | val accountDetail = backStackEntry.toRoute()
50 | AccountDetailScreen(
51 | accountId = accountDetail.accountId,
52 | onNavigateBack = { navController.popBackStack() }
53 | )
54 | }
55 |
56 | composable {
57 | SettingsScreen(
58 | onNavigateBack = { navController.popBackStack() }
59 | )
60 | }
61 |
62 | composable {
63 | AddAccountScreen(
64 | onNavigateBack = { navController.popBackStack() }
65 | )
66 | }
67 | }
68 | }
69 | }
--------------------------------------------------------------------------------
/sharedLib/src/nativeMain/kotlin/libtwofac.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalNativeApi::class, ExperimentalTime::class)
2 |
3 | import kotlinx.coroutines.runBlocking
4 | import tech.arnav.twofac.lib.PublicApi
5 | import tech.arnav.twofac.lib.crypto.CryptoTools
6 | import tech.arnav.twofac.lib.otp.HOTP
7 | import tech.arnav.twofac.lib.otp.TOTP
8 | import tech.arnav.twofac.lib.uri.OtpAuthURI
9 | import kotlin.experimental.ExperimentalNativeApi
10 | import kotlin.time.Clock
11 | import kotlin.time.ExperimentalTime
12 |
13 | @PublicApi
14 | enum class HashAlgo { SHA1, SHA256, SHA512 }
15 |
16 | @PublicApi
17 | @CName("gen_hotp")
18 | fun genHOTP(
19 | secret: String,
20 | counter: Long,
21 | digits: Int = 6,
22 | algorithm: HashAlgo = HashAlgo.SHA1,
23 | ): String {
24 | val hotp = HOTP(
25 | secret = secret,
26 | digits = digits,
27 | algorithm = when (algorithm) {
28 | HashAlgo.SHA1 -> CryptoTools.Algo.SHA1
29 | HashAlgo.SHA256 -> CryptoTools.Algo.SHA256
30 | HashAlgo.SHA512 -> CryptoTools.Algo.SHA512
31 | },
32 | accountName = "user@example.com",
33 | issuer = "TwoFac"
34 | )
35 | val otp = runBlocking { hotp.generateOTP(counter) }
36 | return otp
37 | }
38 |
39 | @PublicApi
40 | @CName("gen_hotp_from_uri")
41 | fun genHOTPFromUri(otpauthUri: String): String {
42 | val hotp = OtpAuthURI.parse(otpauthUri) as HOTP
43 | val counter = otpauthUri
44 | .substringAfter("counter=").substringBefore("&")
45 | .toLongOrNull() ?: 0L
46 | return runBlocking {
47 | hotp.generateOTP(counter)
48 | }
49 | }
50 |
51 | @PublicApi
52 | @CName("gen_totp")
53 | fun genTOTP(
54 | secret: String,
55 | timeInterval: Long = 30L,
56 | digits: Int = 6,
57 | algorithm: HashAlgo = HashAlgo.SHA1,
58 | ): String {
59 | val totp = TOTP(
60 | secret = secret,
61 | timeInterval = timeInterval,
62 | digits = digits,
63 | algorithm = when (algorithm) {
64 | HashAlgo.SHA1 -> CryptoTools.Algo.SHA1
65 | HashAlgo.SHA256 -> CryptoTools.Algo.SHA256
66 | HashAlgo.SHA512 -> CryptoTools.Algo.SHA512
67 | },
68 | accountName = "user@example.com",
69 | issuer = "TwoFac"
70 | )
71 | val currentTimeSeconds = Clock.System.now().epochSeconds
72 | return runBlocking { totp.generateOTP(currentTimeSeconds) }
73 | }
74 |
75 | @PublicApi
76 | @CName("gen_totp_from_uri")
77 | fun genTOTPFromUri(otpauthUri: String): String {
78 | val totp = OtpAuthURI.parse(otpauthUri) as TOTP
79 | val currentTimeSeconds = Clock.System.now().epochSeconds
80 | return runBlocking { totp.generateOTP(currentTimeSeconds) }
81 | }
82 |
--------------------------------------------------------------------------------
/sharedLib/src/commonTest/kotlin/tech/arnav/twofac/lib/crypto/EncodingTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import kotlinx.io.bytestring.ByteString
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 | import kotlin.test.assertFailsWith
7 |
8 | class EncodingTest {
9 | // Test cases for Base32 decoding
10 | val testCases = mapOf(
11 | "hello world!" to "NBSWY3DPEB3W64TMMQQQ====",
12 | "12345678901234567890" to "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
13 | )
14 |
15 | @Test
16 | fun testDecodeBase32() {
17 |
18 | for ((stringInput, base32Encoded) in testCases) {
19 | val decodedBytes = Encoding.decodeBase32(base32Encoded)
20 | val expectedBytes = stringInput.encodeToByteArray()
21 | assertEquals(ByteString(expectedBytes), ByteString(decodedBytes))
22 | }
23 | }
24 |
25 | @Test
26 | fun testEncodeBase32() {
27 | for ((stringInput, base32Encoded) in testCases) {
28 | val inputBytes = stringInput.encodeToByteArray()
29 | val encodedString = Encoding.encodeBase32(inputBytes)
30 | assertEquals(base32Encoded, encodedString)
31 | }
32 | }
33 |
34 | @Test
35 | fun testHexToByteString() {
36 | // Test valid hex strings
37 | assertEquals(
38 | ByteString(byteArrayOf(1, 35, 69, 103, -119, -85, -51, -17)),
39 | Encoding.hexToByteString("0123456789abcdef")
40 | )
41 | assertEquals(
42 | ByteString(byteArrayOf(1, 35, 69, 103, -119, -85, -51, -17)),
43 | Encoding.hexToByteString("0123456789ABCDEF")
44 | )
45 |
46 | // Test empty string
47 | assertEquals(ByteString(byteArrayOf()), Encoding.hexToByteString(""))
48 |
49 | // Test odd-length string (should throw an exception)
50 | assertFailsWith {
51 | Encoding.hexToByteString("123")
52 | }
53 |
54 | // Test invalid hex characters
55 | assertFailsWith {
56 | Encoding.hexToByteString("123G")
57 | }
58 | }
59 |
60 | @Test
61 | fun testByteStringToHex() {
62 | // Test valid ByteStrings
63 | assertEquals(
64 | "0123456789abcdef",
65 | Encoding.byteStringToHex(ByteString(byteArrayOf(1, 35, 69, 103, -119, -85, -51, -17)))
66 | )
67 |
68 | // Test empty ByteString
69 | assertEquals("", Encoding.byteStringToHex(ByteString(byteArrayOf())))
70 |
71 | // Test ByteString with values that need padding
72 | assertEquals(
73 | "0001020304050607",
74 | Encoding.byteStringToHex(ByteString(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7)))
75 | )
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/crypto/CryptoTools.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import kotlinx.io.bytestring.ByteString
4 |
5 | /**
6 | * All cryptographic tools we need for 2-factor authentication.
7 | * 1. HMAC-SHA1 (or HMAC-SHA256) for TOTP and HOTP
8 | * 2. PBKDF2 for creating keys from passwords
9 | * 3. AES for encrypting and decrypting secrets
10 | */
11 | interface CryptoTools {
12 |
13 | /**
14 | * SHA algorithm types supported for HMAC operations
15 | */
16 | enum class Algo {
17 | SHA1,
18 | SHA256,
19 | SHA512
20 | }
21 |
22 | public data class SigningKey(
23 | /**
24 | * The resulting key derived from the password and salt
25 | */
26 |
27 | val key: ByteString,
28 |
29 | /**
30 | * The salt used in the key derivation process
31 | */
32 | val salt: ByteString
33 | )
34 |
35 | /**
36 | * Generate an HMAC using the specified SHA algorithm
37 | *
38 | * @param algorithm The SHA algorithm to use (SHA1, SHA128, or SHA256)
39 | * @param key The key to use for the HMAC
40 | * @param data The data to generate the HMAC for
41 | * @return The generated HMAC as a ByteString
42 | */
43 | suspend fun hmacSha(algorithm: Algo, key: ByteString, data: ByteString): ByteString
44 |
45 | /**
46 | * Derive a key from a password using PBKDF2
47 | *
48 | * @param passKey The password to derive the key from
49 | * @param salt The salt to use for key derivation. If null, a random salt will be generated
50 | * @return The derived signing key as a ByteString
51 | */
52 | suspend fun createSigningKey(passKey: String, salt: ByteString? = null): SigningKey
53 |
54 | /**
55 | * Create a hash of the passKey using the specified SHA algorithm
56 | *
57 | * @param passKey The password to hash
58 | * @param algorithm The SHA algorithm to use (SHA1, SHA256, or SHA512)
59 | * @return The resulting hash as a ByteString
60 | */
61 | suspend fun createHash(passKey: String, algorithm: CryptoTools.Algo): ByteString
62 |
63 | /**
64 | * Encrypt data using a key
65 | *
66 | * @param key The key to use for encryption
67 | * @param secret The data to encrypt
68 | * @return The encrypted data as a ByteString
69 | */
70 | suspend fun encrypt(key: ByteString, secret: ByteString): ByteString
71 |
72 | /**
73 | * Decrypt data using a key
74 | *
75 | * @param encryptedData The encrypted data to decrypt
76 | * @param key The key to use for decryption
77 | * @return The decrypted data as a ByteString
78 | */
79 | suspend fun decrypt(encryptedData: ByteString, key: ByteString): ByteString
80 | }
81 |
--------------------------------------------------------------------------------
/sharedLib/src/commonTest/kotlin/tech/arnav/twofac/lib/TwoFacLibTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import kotlin.test.Test
5 | import kotlin.test.assertEquals
6 | import kotlin.test.assertFailsWith
7 | import kotlin.test.assertFalse
8 | import kotlin.test.assertTrue
9 |
10 | class TwoFacLibTest {
11 |
12 | @Test
13 | fun testInitialiseWithoutPasskey() {
14 | val lib = TwoFacLib.initialise()
15 | assertFalse(lib.isUnlocked(), "Library should not be unlocked when initialized without passkey")
16 | }
17 |
18 | @Test
19 | fun testInitialiseWithPasskey() {
20 | val lib = TwoFacLib.initialise(passKey = "testpasskey")
21 | assertTrue(lib.isUnlocked(), "Library should be unlocked when initialized with passkey")
22 | }
23 |
24 | @Test
25 | fun testUnlockFunction() = runTest {
26 | val lib = TwoFacLib.initialise()
27 | assertFalse(lib.isUnlocked(), "Library should not be unlocked initially")
28 |
29 | lib.unlock("testpasskey")
30 | assertTrue(lib.isUnlocked(), "Library should be unlocked after calling unlock()")
31 | }
32 |
33 | @Test
34 | fun testUnlockWithBlankPasskey() = runTest {
35 | val lib = TwoFacLib.initialise()
36 |
37 | assertFailsWith {
38 | lib.unlock("")
39 | }
40 |
41 | assertFailsWith {
42 | lib.unlock(" ")
43 | }
44 | }
45 |
46 | @Test
47 | fun testGetAllAccountOTPsWhenLocked() = runTest {
48 | val lib = TwoFacLib.initialise()
49 |
50 | assertFailsWith {
51 | lib.getAllAccountOTPs()
52 | }
53 | }
54 |
55 | @Test
56 | fun testAddAccountWhenLocked() = runTest {
57 | val lib = TwoFacLib.initialise()
58 |
59 | assertFailsWith {
60 | lib.addAccount("otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")
61 | }
62 | }
63 |
64 | @Test
65 | fun testGetAllAccountOTPsWhenUnlocked() = runTest {
66 | val lib = TwoFacLib.initialise()
67 | lib.unlock("testpasskey")
68 |
69 | // Should not throw exception when unlocked
70 | val otps = lib.getAllAccountOTPs()
71 | assertEquals(0, otps.size, "Should return empty list when no accounts are added")
72 | }
73 |
74 | @Test
75 | fun testThreadSafetyOfUnlockAndIsUnlocked() = runTest {
76 | val lib = TwoFacLib.initialise()
77 |
78 | // Test that multiple calls to unlock and isUnlocked work correctly
79 | lib.unlock("passkey1")
80 | assertTrue(lib.isUnlocked())
81 |
82 | lib.unlock("passkey2")
83 | assertTrue(lib.isUnlocked())
84 | }
85 | }
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/otp/TOTP.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.otp
2 |
3 | import tech.arnav.twofac.lib.crypto.CryptoTools
4 |
5 | /**
6 | * Implementation of Time-based One-Time Password (TOTP) as defined in RFC 6238.
7 | * TOTP uses HOTP with a counter derived from the current time.
8 | */
9 | class TOTP(
10 | override val digits: Int = 6,
11 | override val algorithm: CryptoTools.Algo = CryptoTools.Algo.SHA1,
12 | override val secret: String,
13 | override val accountName: String,
14 | override val issuer: String?,
15 | /**
16 | * Base time in seconds from which the TOTP counter starts.
17 | * Default is 0, which means the epoch (1970-01-01T00:00:00Z).
18 | */
19 | private val baseTime: Long = 0,
20 | /**
21 | * Time interval in seconds for TOTP generation.
22 | * Default is 30 seconds, which is the standard for TOTP.
23 | */
24 | val timeInterval: Long = 30
25 | ) : OTP {
26 |
27 | // Use HOTP internally for the actual OTP generation
28 | private val hotp = HOTP(digits, algorithm, secret, accountName, issuer)
29 |
30 | /**
31 | * Converts a timestamp to a counter value.
32 | *
33 | * @param currentTime The current Unix timestamp in seconds.
34 | * @return The counter value derived from the time.
35 | */
36 | private fun timeToCounter(currentTime: Long): Long {
37 | return (currentTime - baseTime) / timeInterval
38 | }
39 |
40 | fun nextCodeAt(currentTime: Long): Long {
41 | return currentTime + (timeInterval - (currentTime - baseTime) % timeInterval)
42 | }
43 |
44 |
45 | /**
46 | * Generate a new OTP based on the current time.
47 | * This is a convenience method that converts time to a counter value.
48 | *
49 | * @param currentTime The current Unix timestamp in seconds.
50 | * @return The generated OTP is a string with [digits] length.
51 | */
52 | @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
53 | override suspend fun generateOTP(currentTime: Long): String {
54 | val counter = timeToCounter(currentTime)
55 | return hotp.generateOTP(counter)
56 | }
57 |
58 | /**
59 | * Validate an OTP against the expected value for the current time.
60 | * This method checks the OTP for the current time window as well as
61 | * the previous (-1) and next (+1) time windows.
62 | *
63 | * @param otp The OTP to validate.
64 | * @param currentTime The current Unix timestamp in seconds.
65 | * @return True if the OTP is valid for any of the time windows, false otherwise.
66 | */
67 | @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
68 | override suspend fun validateOTP(otp: String, currentTime: Long): Boolean {
69 | val counter = timeToCounter(currentTime)
70 |
71 | // Check previous, current, and next time windows
72 | return hotp.validateOTP(otp, counter - 1) ||
73 | hotp.validateOTP(otp, counter) ||
74 | hotp.validateOTP(otp, counter + 1)
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TwoFac
2 |
3 | ##### Open Source, Native, Cross-Platform 2FA App for Watch, Mobile, Desktop, Web, CLI
4 |
5 | [](https://kotlinlang.org/)
6 | [](https://github.com/JetBrains/compose-multiplatform)
7 |
8 | ## ROADMAP
9 |
10 | - [ ] Common functionality
11 | - [x] Add new accounts
12 | - [x] Display accounts with 2FA codes
13 | - [x] Save accounts to a storage
14 | - [ ] Backup & Restore via a backup transport
15 | - [ ] Export & Import accounts (encrypted with passkey)
16 | - [ ] Import from other 2FA apps
17 | - [ ] Authy
18 | - [ ] 2FAS
19 | - [ ] Ente
20 | - [ ] Mobile App
21 | - [ ] Desktop App
22 | - [ ] Web Extension
23 | - [ ] CLI App
24 | - [ ] Add new accounts
25 | - [x] Display 2FA codes with auto-refresh
26 |
27 | ## CODE STRUCTURE
28 | This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop.
29 |
30 | * `/composeApp` is for code that will be shared across your Compose Multiplatform applications.
31 | It contains several subfolders:
32 | - `commonMain` is for code that’s common for all targets.
33 | - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
34 | For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
35 | `iosMain` would be the right folder for such calls.
36 |
37 | * `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
38 | you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
39 |
40 | * `/cliApp` contains the command-line interface application code.
41 | It uses the shared library to provide 2FA functionality through terminal commands.
42 | The CLI is built using Clikt framework and provides features like:
43 | - Display 2FA codes with auto-refresh
44 | - Add new accounts
45 | - Show platform information
46 |
47 | * `/sharedLib` is a shared library that contains the core 2FA functionality.
48 | It is used by all applications and provides the logic for managing 2FA accounts,
49 | generating codes, and handling encryption.
50 | The library is written in Kotlin and can be used across different platforms.
51 |
52 | ## DEPENDENCY STRUCTURE
53 |
54 | | App | Codebase | Depends on sharedLib variant |
55 | |-------------------|--------------------------------|------------------------------|
56 | | Android | `composeApp/androidMain` | `jvm` |
57 | | iOS (+ Simulator) | `iosApp -> composeApp/iosMain` | `native` (as framework) |
58 | | Desktop | `composeApp/desktopMain` | `jvm` |
59 | | Web | `composeApp/wasmJsMain` | `wasmJs` |
60 | | CLI | `cliApp` | `native` (as static lib) |
61 |
62 |
63 |
64 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=
74 |
75 |
76 | @rem Execute Gradle
77 | "%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" %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/cliApp/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/sharedLib/api/sharedLib.api:
--------------------------------------------------------------------------------
1 | public final class tech/arnav/twofac/lib/TwoFacLib {
2 | public static final field Companion Ltech/arnav/twofac/lib/TwoFacLib$Companion;
3 | public synthetic fun (Ltech/arnav/twofac/lib/storage/Storage;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
4 | public final fun addAccount (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
5 | public final fun getAllAccountOTPs (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
6 | public final fun getAllAccounts ()Ljava/util/List;
7 | public final fun getStorage ()Ltech/arnav/twofac/lib/storage/Storage;
8 | public final fun isUnlocked ()Z
9 | public final fun unlock (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
10 | }
11 |
12 | public abstract interface class tech/arnav/twofac/lib/otp/OTP {
13 | public abstract fun generateOTP (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
14 | public abstract fun getAccountName ()Ljava/lang/String;
15 | public abstract fun getAlgorithm ()Ltech/arnav/twofac/lib/crypto/CryptoTools$Algo;
16 | public abstract fun getDigits ()I
17 | public abstract fun getIssuer ()Ljava/lang/String;
18 | public abstract fun getSecret ()Ljava/lang/String;
19 | public abstract fun validateOTP (Ljava/lang/String;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
20 | }
21 |
22 | public abstract interface class tech/arnav/twofac/lib/storage/Storage {
23 | public abstract fun getAccount (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
24 | public abstract fun getAccount (Lkotlin/uuid/Uuid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
25 | public abstract fun getAccountList (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
26 | public abstract fun saveAccount (Ltech/arnav/twofac/lib/storage/StoredAccount;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
27 | }
28 |
29 | public final class tech/arnav/twofac/lib/storage/StoredAccount {
30 | public static final field Companion Ltech/arnav/twofac/lib/storage/StoredAccount$Companion;
31 | public fun (Lkotlin/uuid/Uuid;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
32 | public final fun component1 ()Lkotlin/uuid/Uuid;
33 | public final fun component2 ()Ljava/lang/String;
34 | public final fun component3 ()Ljava/lang/String;
35 | public final fun component4 ()Ljava/lang/String;
36 | public final fun copy (Lkotlin/uuid/Uuid;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ltech/arnav/twofac/lib/storage/StoredAccount;
37 | public static synthetic fun copy$default (Ltech/arnav/twofac/lib/storage/StoredAccount;Lkotlin/uuid/Uuid;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ltech/arnav/twofac/lib/storage/StoredAccount;
38 | public fun equals (Ljava/lang/Object;)Z
39 | public final fun forDisplay (Ljava/lang/Long;)Ltech/arnav/twofac/lib/storage/StoredAccount$DisplayAccount;
40 | public static synthetic fun forDisplay$default (Ltech/arnav/twofac/lib/storage/StoredAccount;Ljava/lang/Long;ILjava/lang/Object;)Ltech/arnav/twofac/lib/storage/StoredAccount$DisplayAccount;
41 | public final fun getAccountID ()Lkotlin/uuid/Uuid;
42 | public final fun getAccountLabel ()Ljava/lang/String;
43 | public final fun getEncryptedURI ()Ljava/lang/String;
44 | public final fun getSalt ()Ljava/lang/String;
45 | public fun hashCode ()I
46 | public fun toString ()Ljava/lang/String;
47 | }
48 |
49 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/SettingsScreen.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.screens
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
10 | import androidx.compose.material.icons.filled.ArrowBack
11 | import androidx.compose.material3.Card
12 | import androidx.compose.material3.CardDefaults
13 | import androidx.compose.material3.ExperimentalMaterial3Api
14 | import androidx.compose.material3.Icon
15 | import androidx.compose.material3.IconButton
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.material3.Scaffold
18 | import androidx.compose.material3.Text
19 | import androidx.compose.material3.TopAppBar
20 | import androidx.compose.runtime.Composable
21 | import androidx.compose.ui.Alignment
22 | import androidx.compose.ui.Modifier
23 | import androidx.compose.ui.text.style.TextAlign
24 | import androidx.compose.ui.unit.dp
25 | import tech.arnav.twofac.storage.getStoragePath
26 |
27 | @OptIn(ExperimentalMaterial3Api::class)
28 | @Composable
29 | fun SettingsScreen(
30 | onNavigateBack: () -> Unit
31 | ) {
32 | Scaffold(
33 | topBar = {
34 | TopAppBar(
35 | title = { Text("Settings") },
36 | navigationIcon = {
37 | IconButton(onClick = onNavigateBack) {
38 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
39 | }
40 | }
41 | )
42 | }
43 | ) { paddingValues ->
44 | Column(
45 | modifier = Modifier
46 | .fillMaxSize()
47 | .padding(paddingValues)
48 | .padding(16.dp),
49 | horizontalAlignment = Alignment.CenterHorizontally,
50 | verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top)
51 | ) {
52 |
53 | Card(
54 | modifier = Modifier.fillMaxWidth(),
55 | colors = CardDefaults.cardColors(
56 | containerColor = MaterialTheme.colorScheme.surfaceVariant
57 | )
58 | ) {
59 | Column(
60 | modifier = Modifier.padding(16.dp)
61 | ) {
62 | Text(
63 | text = "Storage Location",
64 | style = MaterialTheme.typography.titleMedium,
65 | modifier = Modifier.padding(bottom = 8.dp)
66 | )
67 |
68 | Text(
69 | text = "Accounts are saved at:",
70 | style = MaterialTheme.typography.bodyMedium,
71 | color = MaterialTheme.colorScheme.onSurfaceVariant
72 | )
73 |
74 | Text(
75 | text = getStoragePath(),
76 | style = MaterialTheme.typography.bodySmall,
77 | color = MaterialTheme.colorScheme.primary,
78 | modifier = Modifier.padding(top = 4.dp),
79 | textAlign = TextAlign.Start
80 | )
81 | }
82 | }
83 | }
84 | }
85 | }
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/crypto/DefaultCryptoTools.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import dev.whyoleg.cryptography.BinarySize.Companion.bits
4 | import dev.whyoleg.cryptography.CryptographyProvider
5 | import dev.whyoleg.cryptography.DelicateCryptographyApi
6 | import dev.whyoleg.cryptography.algorithms.AES
7 | import dev.whyoleg.cryptography.algorithms.HMAC
8 | import dev.whyoleg.cryptography.algorithms.PBKDF2
9 | import dev.whyoleg.cryptography.algorithms.SHA1
10 | import dev.whyoleg.cryptography.algorithms.SHA256
11 | import dev.whyoleg.cryptography.algorithms.SHA512
12 | import dev.whyoleg.cryptography.random.CryptographyRandom
13 | import kotlinx.io.bytestring.ByteString
14 |
15 | class DefaultCryptoTools(val cryptoProvider: CryptographyProvider) : CryptoTools {
16 |
17 | // TODO: make a CryptoConfig class to hold these constants and pass in
18 | companion object {
19 | const val SALT_LENGTH = 16 // 128 bits
20 | const val HASH_ITERATIONS = 200 // Number of iterations for PBKDF2
21 |
22 | }
23 |
24 | val hmac = cryptoProvider.get(HMAC)
25 | val pbkdf2 = cryptoProvider.get(PBKDF2)
26 | val aesGcm = cryptoProvider.get(AES.GCM)
27 |
28 |
29 | @OptIn(DelicateCryptographyApi::class)
30 | override suspend fun hmacSha(algorithm: CryptoTools.Algo, key: ByteString, data: ByteString): ByteString {
31 | val keyDecoder = when (algorithm) {
32 | CryptoTools.Algo.SHA1 -> hmac.keyDecoder(SHA1)
33 | CryptoTools.Algo.SHA256 -> hmac.keyDecoder(SHA256)
34 | CryptoTools.Algo.SHA512 -> hmac.keyDecoder(SHA512)
35 | }
36 | val hmacKey = keyDecoder.decodeFromByteString(HMAC.Key.Format.RAW, key)
37 | val signature = hmacKey.signatureGenerator().generateSignature(data.toByteArray())
38 | return ByteString(signature)
39 | }
40 |
41 | override suspend fun createSigningKey(passKey: String, salt: ByteString?): CryptoTools.SigningKey {
42 | // generate a salt
43 | val saltBytes = salt?.toByteArray() ?: CryptographyRandom.nextBytes(SALT_LENGTH) // 128-bit salt
44 | // derive a key using PBKDF2
45 | val secretDerivation = pbkdf2.secretDerivation(SHA256, HASH_ITERATIONS, 256.bits, saltBytes)
46 | val signingKey = secretDerivation.deriveSecretToByteArray(passKey.encodeToByteArray())
47 | return CryptoTools.SigningKey(key = ByteString(signingKey), salt = ByteString(saltBytes))
48 | }
49 |
50 | @OptIn(DelicateCryptographyApi::class)
51 | override suspend fun createHash(passKey: String, algorithm: CryptoTools.Algo): ByteString {
52 | val hashFunction = when (algorithm) {
53 | CryptoTools.Algo.SHA1 -> cryptoProvider.get(SHA1)
54 | CryptoTools.Algo.SHA256 -> cryptoProvider.get(SHA256)
55 | CryptoTools.Algo.SHA512 -> cryptoProvider.get(SHA512)
56 | }
57 | val hash = hashFunction.hasher().hash(passKey.encodeToByteArray())
58 | return ByteString(hash)
59 | }
60 |
61 | override suspend fun encrypt(key: ByteString, secret: ByteString): ByteString {
62 | val keyDecoder = aesGcm.keyDecoder()
63 | val signingKey = keyDecoder.decodeFromByteString(AES.Key.Format.RAW, key)
64 | val cipher = signingKey.cipher()
65 | val cipherText = cipher.encrypt(secret.toByteArray())
66 | return ByteString(cipherText)
67 | }
68 |
69 | override suspend fun decrypt(encryptedData: ByteString, key: ByteString): ByteString {
70 | val keyDecoder = aesGcm.keyDecoder()
71 | val signingKey = keyDecoder.decodeFromByteString(AES.Key.Format.RAW, key)
72 | val cipher = signingKey.cipher()
73 | // TODO: Handle decryption errors gracefully
74 | val plainText = cipher.decrypt(encryptedData.toByteArray())
75 | return ByteString(plainText)
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/otp/HOTP.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.otp
2 |
3 | import dev.whyoleg.cryptography.BinarySize.Companion.bytes
4 | import dev.whyoleg.cryptography.CryptographyProvider
5 | import kotlinx.io.bytestring.ByteString
6 | import tech.arnav.twofac.lib.crypto.CryptoTools
7 | import tech.arnav.twofac.lib.crypto.DefaultCryptoTools
8 | import tech.arnav.twofac.lib.crypto.Encoding
9 | import kotlin.experimental.and
10 | import kotlin.math.pow
11 |
12 | class HOTP(
13 | override val digits: Int = 6,
14 | override val algorithm: CryptoTools.Algo = CryptoTools.Algo.SHA1,
15 | override val secret: String,
16 | override val accountName: String,
17 | override val issuer: String?,
18 | ) : OTP {
19 | private companion object {
20 | private const val MSB_MASK = 0x7F // most significant bit mask 01111111
21 | private const val BYTE_MASK = 0xFF // byte mask 11111111
22 | }
23 | private val cryptoTools = DefaultCryptoTools(CryptographyProvider.Default)
24 |
25 | /**
26 | * Generate a new OTP based on the current counter.
27 | * The counter is typically incremented for each OTP generation.
28 | *
29 | * @param counter The current counter value, which is used to generate the OTP.
30 | * @return The generated OTP is a string with [digits] length.
31 | */
32 | override suspend fun generateOTP(counter: Long): String {
33 | // Convert the counter to a byte array (8 bytes, big-endian)
34 | val counterBytes = ByteArray(8) { i ->
35 | ((counter shr ((7 - i) * 8)) and 0xFF).toByte()
36 | }
37 |
38 | // Decode the Base32-encoded secret
39 | val secretBytes = Encoding.decodeBase32(secret)
40 |
41 | // Compute the HMAC using the CryptoTools
42 | val hmac = cryptoTools.hmacSha(
43 | algorithm,
44 | ByteString(secretBytes),
45 | ByteString(counterBytes)
46 | )
47 |
48 | // Dynamic truncation (get 4 bytes from the HMAC result)
49 | val fourBytes = dynamicTruncate(hmac)
50 |
51 | // Generate the OTP by modulo with 10^digits
52 | val otp = fourBytes % 10.0.pow(digits.toDouble()).toInt()
53 |
54 | // Pad with leading zeros if necessary
55 | return otp.toString().padStart(digits, '0')
56 | }
57 |
58 | /**
59 | * Validate an OTP against the expected value for a given counter.
60 | *
61 | * @param otp The OTP to validate.
62 | * @param counter The counter value used to generate the expected OTP.
63 | * @return True if the OTP is valid for the given counter, false otherwise.
64 | */
65 | override suspend fun validateOTP(otp: String, counter: Long): Boolean {
66 | val expectedOTP = generateOTP(counter)
67 | return otp == expectedOTP
68 | }
69 |
70 | /**
71 | * Performs dynamic truncation as per RFC 4226.
72 | * Extracts a 4-byte dynamic binary code from the HMAC result.
73 | * Works with different hash algorithms (SHA1, SHA256, SHA512) which produce
74 | * different-sized outputs.
75 | *
76 | * @param hmac The HMAC result to truncate (can be of any hash algorithm)
77 | * @return The truncated 31-bit integer
78 | */
79 | internal fun dynamicTruncate(hmac: ByteString): Int {
80 | // The last byte's 4 lowest bits determine the offset
81 | // This works for all hash algorithms as we only use the last byte
82 | val offset = (hmac.get(hmac.size - 1) and 0x0F).toInt()
83 |
84 | // Extract 4 bytes starting at the offset position
85 | // For all hash algorithms; we only need 4 bytes for the OTP calculation
86 | return ((hmac.get(offset).toInt() and MSB_MASK) shl 3.bytes.inBits) or
87 | ((hmac.get(offset + 1).toInt() and BYTE_MASK) shl 2.bytes.inBits) or
88 | ((hmac.get(offset + 2).toInt() and BYTE_MASK) shl 1.bytes.inBits) or
89 | (hmac.get(offset + 3).toInt() and BYTE_MASK)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/AddAccountScreen.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.screens
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
10 | import androidx.compose.material.icons.filled.ArrowBack
11 | import androidx.compose.material3.Button
12 | import androidx.compose.material3.ExperimentalMaterial3Api
13 | import androidx.compose.material3.Icon
14 | import androidx.compose.material3.IconButton
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.OutlinedTextField
17 | import androidx.compose.material3.Scaffold
18 | import androidx.compose.material3.Text
19 | import androidx.compose.material3.TopAppBar
20 | import androidx.compose.runtime.Composable
21 | import androidx.compose.runtime.collectAsState
22 | import androidx.compose.runtime.getValue
23 | import androidx.compose.runtime.mutableStateOf
24 | import androidx.compose.runtime.remember
25 | import androidx.compose.runtime.setValue
26 | import androidx.compose.ui.Alignment
27 | import androidx.compose.ui.Modifier
28 | import androidx.compose.ui.unit.dp
29 | import org.koin.compose.viewmodel.koinViewModel
30 | import tech.arnav.twofac.viewmodels.AccountsViewModel
31 |
32 | @OptIn(ExperimentalMaterial3Api::class)
33 | @Composable
34 | fun AddAccountScreen(
35 | onNavigateBack: () -> Unit,
36 | viewModel: AccountsViewModel = koinViewModel()
37 | ) {
38 | var uriText by remember { mutableStateOf("") }
39 | var passkeyText by remember { mutableStateOf("") }
40 |
41 | val isLoading by viewModel.isLoading.collectAsState()
42 | val error by viewModel.error.collectAsState()
43 |
44 | Scaffold(
45 | topBar = {
46 | TopAppBar(
47 | title = { Text("Add Account") },
48 | navigationIcon = {
49 | IconButton(onClick = onNavigateBack) {
50 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
51 | }
52 | }
53 | )
54 | }
55 | ) { paddingValues ->
56 | Column(
57 | modifier = Modifier
58 | .fillMaxSize()
59 | .padding(paddingValues)
60 | .padding(16.dp),
61 | horizontalAlignment = Alignment.CenterHorizontally,
62 | verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
63 | ) {
64 |
65 | OutlinedTextField(
66 | value = uriText,
67 | onValueChange = { uriText = it },
68 | label = { Text("2FA URI") },
69 | placeholder = { Text("otpauth://totp/...") },
70 | modifier = Modifier.fillMaxWidth()
71 | )
72 |
73 | OutlinedTextField(
74 | value = passkeyText,
75 | onValueChange = { passkeyText = it },
76 | label = { Text("Passkey") },
77 | placeholder = { Text("Enter your passkey") },
78 | modifier = Modifier.fillMaxWidth()
79 | )
80 |
81 | error?.let { errorMessage ->
82 | Text(
83 | text = "Error: $errorMessage",
84 | color = MaterialTheme.colorScheme.error,
85 | modifier = Modifier.padding(8.dp)
86 | )
87 | }
88 |
89 | Button(
90 | onClick = {
91 | if (uriText.isNotBlank() && passkeyText.isNotBlank()) {
92 | viewModel.addAccount(uriText, passkeyText)
93 | if (viewModel.error.value == null) {
94 | onNavigateBack()
95 | }
96 | }
97 | },
98 | enabled = !isLoading && uriText.isNotBlank() && passkeyText.isNotBlank()
99 | ) {
100 | Text(if (isLoading) "Adding..." else "Add Account")
101 | }
102 | }
103 | }
104 | }
--------------------------------------------------------------------------------
/cliApp/src/commonMain/kotlin/tech/arnav/twofac/cli/commands/DisplayCommand.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.cli.commands
2 |
3 | import com.github.ajalt.clikt.core.CliktCommand
4 | import com.github.ajalt.clikt.core.Context
5 | import com.github.ajalt.clikt.core.terminal
6 | import com.github.ajalt.clikt.parameters.options.option
7 | import com.github.ajalt.clikt.parameters.options.prompt
8 | import com.github.ajalt.mordant.animation.animation
9 | import com.github.ajalt.mordant.rendering.BorderType
10 | import com.github.ajalt.mordant.rendering.TextColors
11 | import com.github.ajalt.mordant.rendering.TextStyles
12 | import com.github.ajalt.mordant.table.Borders
13 | import com.github.ajalt.mordant.table.table
14 | import com.github.ajalt.mordant.terminal.warning
15 | import com.github.ajalt.mordant.widgets.Padding
16 | import com.github.ajalt.mordant.widgets.ProgressBar
17 | import kotlinx.coroutines.delay
18 | import kotlinx.coroutines.runBlocking
19 | import org.koin.core.component.KoinComponent
20 | import org.koin.core.component.inject
21 | import tech.arnav.twofac.cli.viewmodels.AccountsViewModel
22 | import tech.arnav.twofac.cli.viewmodels.DisplayAccountsStatic
23 | import kotlin.time.Clock
24 | import kotlin.time.Duration.Companion.minutes
25 | import kotlin.time.Duration.Companion.seconds
26 | import kotlin.time.ExperimentalTime
27 |
28 | class DisplayCommand : CliktCommand(), KoinComponent {
29 | override fun help(context: Context): String {
30 | return "(default) Displays all OTPs of registered accounts."
31 | }
32 |
33 | val accountsViewModel: AccountsViewModel by inject()
34 |
35 | val passkey by option("-p", "--passkey", help = "Passkey to display").prompt("Enter passkey", hideInput = true)
36 |
37 | override fun run() {
38 | if (passkey.isBlank()) {
39 | echo("Passkey cannot be blank")
40 | return
41 | }
42 |
43 | // Fetch and display all account OTPs
44 | val animation = terminal.animation { displayAccounts ->
45 | createTable(displayAccounts)
46 | }
47 | echo("\n")
48 | runBlocking {
49 | // Unlock the library with the provided passkey
50 | accountsViewModel.unlock(passkey)
51 |
52 | repeat(2.minutes.inWholeSeconds.toInt()) {
53 | animation.update(accountsViewModel.showAllAccountOTPs())
54 | delay(1.seconds.inWholeMilliseconds)
55 | }
56 | terminal.warning("Exiting display command after 2 minutes of inactivity.")
57 | return@runBlocking
58 | }
59 | }
60 |
61 | @OptIn(ExperimentalTime::class)
62 | private fun createTable(displayAccounts: DisplayAccountsStatic) = table {
63 | borderType = BorderType.SQUARE_DOUBLE_SECTION_SEPARATOR
64 | header {
65 | style = TextStyles.dim + TextColors.brightBlue
66 | row("Account", "OTP", "Validity")
67 | }
68 | body {
69 | column(1) {
70 | style = TextStyles.bold + TextColors.brightCyan
71 | cellBorders = Borders.ALL
72 | padding = Padding(0, 1, 0, 1) // Add padding to the cell
73 | }
74 | displayAccounts.forEach { (account, otp) ->
75 | val currentTimeSec = Clock.System.now().epochSeconds
76 | val elapsedTimeSec = currentTimeSec - (account.nextCodeAt - 30)
77 | val leftTimeSec = 30 - elapsedTimeSec
78 | row {
79 | cell(account.accountLabel)
80 | cell(otp.split("").joinToString(" "))
81 | cell(
82 | ProgressBar(
83 | total = 30L,
84 | completed = elapsedTimeSec.coerceIn(0L..30L),
85 | width = 15,
86 | separatorChar = "$leftTimeSec"
87 | )
88 | )
89 | }
90 | }
91 | }
92 | footer {
93 | cellBorders = Borders.NONE
94 | style = TextStyles.dim + TextColors.brightGreen
95 | row("Press Ctrl-C key to exit")
96 | }
97 | }
98 |
99 | }
100 |
--------------------------------------------------------------------------------
/sharedLib/build.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalAbiValidation::class)
2 |
3 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4 | import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation
5 | import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
6 |
7 | plugins {
8 | alias(libs.plugins.kotlinMultiplatform)
9 | alias(libs.plugins.kotlinxKover)
10 | alias(libs.plugins.kotlinxSerialization)
11 | alias(libs.plugins.dokka)
12 | }
13 |
14 | group = "tech.arnav.twofac"
15 | version = "0.1.0"
16 |
17 | kotlin {
18 |
19 | val frameworkName = "TwoFacKit"
20 | val libraryName = "twofac"
21 |
22 | // XCFramework for iOS targets
23 | listOf(
24 | iosArm64(),
25 | iosSimulatorArm64(),
26 | macosX64(),
27 | macosArm64(),
28 | ).forEach { iosTarget ->
29 | iosTarget.binaries.framework {
30 | baseName = frameworkName
31 | isStatic = true // Set to false if you want a dynamic framework
32 | }
33 | }
34 |
35 | // JVM library for Android and Desktop
36 | jvm {
37 | compilerOptions {
38 | jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
39 | }
40 | }
41 |
42 | // Native libraries for Linux, macOS, and Windows
43 | listOf(
44 | linuxX64(),
45 | linuxArm64(),
46 | macosArm64(),
47 | macosX64(),
48 | mingwX64(),
49 | ).forEach { nativeTarget ->
50 | nativeTarget.compilations.getByName("main") {
51 | cinterops {
52 | val zlib by creating {}
53 | zlib.apply {
54 | packageName = "zlib"
55 | definitionFile.set(project.file("src/nativeMain/cinterop/zlib.def"))
56 | }
57 | }
58 | }
59 | nativeTarget.binaries {
60 | staticLib {
61 | baseName = libraryName
62 | if (buildType == NativeBuildType.DEBUG) { // skip linking for debug builds
63 | linkTaskProvider.configure { enabled = false }
64 | }
65 | }
66 | sharedLib {
67 | baseName = libraryName
68 | if (buildType == NativeBuildType.DEBUG) { // skip linking for debug builds
69 | linkTaskProvider.configure { enabled = false }
70 | }
71 | }
72 | }
73 | }
74 |
75 | @OptIn(ExperimentalWasmDsl::class) wasmJs {
76 | outputModuleName = "${libraryName}.js"
77 | binaries.library()
78 | browser()
79 | nodejs()
80 | }
81 |
82 | applyDefaultHierarchyTemplate()
83 |
84 | abiValidation {
85 | // TODO: fix the gradle task name in github actions https://youtrack.jetbrains.com/projects/KT/issues/KT-80674
86 | enabled.set(true)
87 | filters {
88 | included {
89 | annotatedWith.add("tech.arnav.twofac.lib.PublicApi")
90 | }
91 | excluded {
92 | annotatedWith.add("tech.arnav.twofac.lib.InternalApi")
93 | }
94 | }
95 | }
96 |
97 | // Source set declarations.
98 | // Declaring a target automatically creates a source set with the same name. By default, the
99 | // Kotlin Gradle Plugin creates additional source sets that depend on each other, since it is
100 | // common to share sources between related targets.
101 | // See: https://kotlinlang.org/docs/multiplatform-hierarchy.html
102 | sourceSets {
103 | commonMain {
104 | dependencies {
105 | implementation(libs.kotlin.stdlib)
106 | implementation(libs.crypto.kt.core)
107 | api(libs.kotlinx.serialization.core)
108 | }
109 | }
110 |
111 | commonTest {
112 | dependencies {
113 | implementation(libs.kotlin.test)
114 | implementation(libs.kotlinx.coroutines.test)
115 | }
116 | }
117 |
118 | jvmMain {
119 | dependencies {
120 | implementation(libs.crypto.kt.jdk)
121 | }
122 | }
123 | nativeMain {
124 | dependencies {
125 | implementation(libs.kotlinx.coroutines.core)
126 | implementation(libs.crypto.kt.openssl)
127 | }
128 | }
129 | wasmJsMain {
130 | dependencies {
131 | implementation(libs.crypto.kt.web)
132 | implementation(libs.kotlinx.browser)
133 | }
134 | }
135 | }
136 |
137 | }
138 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/AccountsScreen.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.screens
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.lazy.LazyColumn
10 | import androidx.compose.foundation.lazy.items
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
13 | import androidx.compose.material.icons.filled.Add
14 | import androidx.compose.material3.Card
15 | import androidx.compose.material3.CircularProgressIndicator
16 | import androidx.compose.material3.ExperimentalMaterial3Api
17 | import androidx.compose.material3.FloatingActionButton
18 | import androidx.compose.material3.Icon
19 | import androidx.compose.material3.IconButton
20 | import androidx.compose.material3.MaterialTheme
21 | import androidx.compose.material3.Scaffold
22 | import androidx.compose.material3.Text
23 | import androidx.compose.material3.TopAppBar
24 | import androidx.compose.runtime.Composable
25 | import androidx.compose.runtime.collectAsState
26 | import androidx.compose.runtime.getValue
27 | import androidx.compose.ui.Alignment
28 | import androidx.compose.ui.Modifier
29 | import androidx.compose.ui.unit.dp
30 | import org.koin.compose.viewmodel.koinViewModel
31 | import tech.arnav.twofac.viewmodels.AccountsViewModel
32 |
33 | @OptIn(ExperimentalMaterial3Api::class)
34 | @Composable
35 | fun AccountsScreen(
36 | onNavigateToAddAccount: () -> Unit,
37 | onNavigateToAccountDetail: (String) -> Unit,
38 | onNavigateBack: () -> Unit = {},
39 | viewModel: AccountsViewModel = koinViewModel()
40 | ) {
41 | val accounts by viewModel.accounts.collectAsState()
42 | val isLoading by viewModel.isLoading.collectAsState()
43 | val error by viewModel.error.collectAsState()
44 |
45 | Scaffold(
46 | topBar = {
47 | TopAppBar(
48 | title = { Text("Accounts") },
49 | navigationIcon = {
50 | IconButton(onClick = onNavigateBack) {
51 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
52 | }
53 | }
54 | )
55 | },
56 | floatingActionButton = {
57 | FloatingActionButton(onClick = onNavigateToAddAccount) {
58 | Icon(Icons.Default.Add, contentDescription = "Add Account")
59 | }
60 | }
61 | ) { paddingValues ->
62 | Column(
63 | modifier = Modifier
64 | .fillMaxSize()
65 | .padding(paddingValues)
66 | .padding(16.dp)
67 | ) {
68 |
69 | when {
70 | isLoading -> {
71 | CircularProgressIndicator(
72 | modifier = Modifier.align(Alignment.CenterHorizontally)
73 | )
74 | }
75 |
76 | error != null -> {
77 | Text(
78 | text = "Error: $error",
79 | color = MaterialTheme.colorScheme.error,
80 | modifier = Modifier.padding(16.dp)
81 | )
82 | }
83 |
84 | else -> {
85 | LazyColumn(
86 | verticalArrangement = Arrangement.spacedBy(8.dp)
87 | ) {
88 | items(accounts) { account ->
89 | Card(
90 | modifier = Modifier.fillMaxWidth(),
91 | onClick = { onNavigateToAccountDetail(account.accountID) }
92 | ) {
93 | Row(
94 | modifier = Modifier.padding(16.dp),
95 | verticalAlignment = Alignment.CenterVertically
96 | ) {
97 | Text(
98 | text = account.accountLabel,
99 | style = MaterialTheme.typography.bodyLarge
100 | )
101 | }
102 | }
103 | }
104 | }
105 | }
106 | }
107 | }
108 | }
109 | }
--------------------------------------------------------------------------------
/iosApp/.gitignore:
--------------------------------------------------------------------------------
1 | ### Xcode template
2 | ## User settings
3 | xcuserdata/
4 |
5 | ## Xcode 8 and earlier
6 | *.xcscmblueprint
7 | *.xccheckout
8 |
9 | ### Objective-C template
10 | # Xcode
11 | #
12 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
13 |
14 | ## User settings
15 | xcuserdata/
16 |
17 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
18 | *.xcscmblueprint
19 | *.xccheckout
20 |
21 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
22 | build/
23 | DerivedData/
24 | *.moved-aside
25 | *.pbxuser
26 | !default.pbxuser
27 | *.mode1v3
28 | !default.mode1v3
29 | *.mode2v3
30 | !default.mode2v3
31 | *.perspectivev3
32 | !default.perspectivev3
33 |
34 | ## Obj-C/Swift specific
35 | *.hmap
36 |
37 | ## App packaging
38 | *.ipa
39 | *.dSYM.zip
40 | *.dSYM
41 |
42 | # CocoaPods
43 | #
44 | # We recommend against adding the Pods directory to your .gitignore. However
45 | # you should judge for yourself, the pros and cons are mentioned at:
46 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
47 | #
48 | # Pods/
49 | #
50 | # Add this line if you want to avoid checking in source code from the Xcode workspace
51 | # *.xcworkspace
52 |
53 | # Carthage
54 | #
55 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
56 | # Carthage/Checkouts
57 |
58 | Carthage/Build/
59 |
60 | # fastlane
61 | #
62 | # It is recommended to not store the screenshots in the git repo.
63 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
64 | # For more information about the recommended setup visit:
65 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
66 |
67 | fastlane/report.xml
68 | fastlane/Preview.html
69 | fastlane/screenshots/**/*.png
70 | fastlane/test_output
71 |
72 | # Code Injection
73 | #
74 | # After new code Injection tools there's a generated folder /iOSInjectionProject
75 | # https://github.com/johnno1962/injectionforxcode
76 |
77 | iOSInjectionProject/
78 |
79 | ### Swift template
80 | # Xcode
81 | #
82 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
83 |
84 | ## User settings
85 | xcuserdata/
86 |
87 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
88 | *.xcscmblueprint
89 | *.xccheckout
90 |
91 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
92 | build/
93 | DerivedData/
94 | *.moved-aside
95 | *.pbxuser
96 | !default.pbxuser
97 | *.mode1v3
98 | !default.mode1v3
99 | *.mode2v3
100 | !default.mode2v3
101 | *.perspectivev3
102 | !default.perspectivev3
103 |
104 | ## Obj-C/Swift specific
105 | *.hmap
106 |
107 | ## App packaging
108 | *.ipa
109 | *.dSYM.zip
110 | *.dSYM
111 |
112 | ## Playgrounds
113 | timeline.xctimeline
114 | playground.xcworkspace
115 |
116 | # Swift Package Manager
117 | #
118 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
119 | # Packages/
120 | # Package.pins
121 | # Package.resolved
122 | # *.xcodeproj
123 | #
124 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
125 | # hence it is not needed unless you have added a package configuration file to your project
126 | # .swiftpm
127 |
128 | .build/
129 |
130 | # CocoaPods
131 | #
132 | # We recommend against adding the Pods directory to your .gitignore. However
133 | # you should judge for yourself, the pros and cons are mentioned at:
134 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
135 | #
136 | # Pods/
137 | #
138 | # Add this line if you want to avoid checking in source code from the Xcode workspace
139 | # *.xcworkspace
140 |
141 | # Carthage
142 | #
143 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
144 | # Carthage/Checkouts
145 |
146 | Carthage/Build/
147 |
148 | # Accio dependency management
149 | Dependencies/
150 | .accio/
151 |
152 | # fastlane
153 | #
154 | # It is recommended to not store the screenshots in the git repo.
155 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
156 | # For more information about the recommended setup visit:
157 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
158 |
159 | fastlane/report.xml
160 | fastlane/Preview.html
161 | fastlane/screenshots/**/*.png
162 | fastlane/test_output
163 |
164 | # Code Injection
165 | #
166 | # After new code Injection tools there's a generated folder /iOSInjectionProject
167 | # https://github.com/johnno1962/injectionforxcode
168 |
169 | iOSInjectionProject/
170 |
171 |
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/TwoFacLib.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import tech.arnav.twofac.lib.crypto.DefaultCryptoTools
5 | import tech.arnav.twofac.lib.crypto.Encoding.toByteString
6 | import tech.arnav.twofac.lib.otp.HOTP
7 | import tech.arnav.twofac.lib.otp.TOTP
8 | import tech.arnav.twofac.lib.storage.MemoryStorage
9 | import tech.arnav.twofac.lib.storage.Storage
10 | import tech.arnav.twofac.lib.storage.StorageUtils.toOTP
11 | import tech.arnav.twofac.lib.storage.StorageUtils.toStoredAccount
12 | import tech.arnav.twofac.lib.storage.StoredAccount
13 | import tech.arnav.twofac.lib.uri.OtpAuthURI
14 | import kotlin.concurrent.Volatile
15 | import kotlin.time.Clock
16 | import kotlin.time.ExperimentalTime
17 |
18 | @PublicApi
19 | class TwoFacLib private constructor(
20 | val storage: Storage,
21 | @Volatile private var passKey: String?,
22 | ) {
23 |
24 | companion object {
25 |
26 | fun initialise(
27 | storage: Storage = MemoryStorage(), passKey: String? = null
28 | ): TwoFacLib {
29 | passKey?.let {
30 | require(it.isNotBlank()) { "Password key cannot be blank" }
31 | }
32 | if (storage is MemoryStorage) {
33 | println(
34 | """
35 | ⚠️[WARNING]: Using in-memory storage. This will not persist data across application restarts.
36 | Use a persistent storage implementation for production use.
37 | """.trimIndent()
38 | )
39 | }
40 | return TwoFacLib(storage, passKey)
41 | }
42 | }
43 |
44 | private val cryptoTools = DefaultCryptoTools(CryptographyProvider.Default)
45 |
46 | @Volatile
47 | private var accountList: List? = null
48 |
49 | /**
50 | * Unlocks the library with the provided passkey and loads accounts into memory
51 | */
52 | suspend fun unlock(passKey: String) {
53 | require(passKey.isNotBlank()) { "Password key cannot be blank" }
54 | this.passKey = passKey
55 | // Load accounts from storage into memory
56 | this.accountList = storage.getAccountList()
57 | }
58 |
59 | /**
60 | * Checks if the library is unlocked (passkey is set)
61 | */
62 | fun isUnlocked(): Boolean {
63 | return passKey != null
64 | }
65 |
66 | fun getAllAccounts(): List {
67 | check(isUnlocked()) { "TwoFacLib is not unlocked. Call unlock() with a valid passkey first." }
68 | val accounts = accountList ?: error("Account list is not loaded. This should not happen when unlocked.")
69 | return accounts.map(StoredAccount::forDisplay)
70 | }
71 |
72 | @OptIn(ExperimentalTime::class)
73 | suspend fun getAllAccountOTPs(): List> {
74 | check(isUnlocked()) { "TwoFacLib is not unlocked. Call unlock() with a valid passkey first." }
75 | val currentPassKey = passKey!! // Safe to use !! after isUnlocked() check
76 | val accounts = accountList ?: error("Account list is not loaded. This should not happen when unlocked.")
77 | return accounts.map { account ->
78 | val otpGen = account.toOTP(
79 | cryptoTools.createSigningKey(currentPassKey, account.salt.toByteString()),
80 | )
81 | val timeNow = Clock.System.now().epochSeconds
82 | val otpString: String = when (otpGen) {
83 | is HOTP -> otpGen.generateOTP(0)
84 | is TOTP -> otpGen.generateOTP(timeNow)
85 | else -> throw IllegalArgumentException("Unknown OTP type: ${otpGen::class.simpleName}")
86 | }
87 | val nextCodeAt = when (otpGen) {
88 | is HOTP -> 0L // HOTP does not have a next code time
89 | is TOTP -> otpGen.nextCodeAt(timeNow)
90 | else -> throw IllegalArgumentException("Unknown OTP type: ${otpGen::class.simpleName}")
91 | }
92 | return@map Pair(
93 | account.forDisplay(nextCodeAt), otpString
94 | )
95 | }
96 | }
97 |
98 | suspend fun addAccount(accountURI: String): Boolean {
99 | check(isUnlocked()) { "TwoFacLib is not unlocked. Call unlock() with a valid passkey first." }
100 | val currentPassKey = passKey!! // Safe to use !! after isUnlocked() check
101 | val otp = OtpAuthURI.parse(accountURI)
102 | val signingKey = cryptoTools.createSigningKey(currentPassKey)
103 | val account = otp.toStoredAccount(signingKey)
104 | val success = storage.saveAccount(account)
105 | if (success) {
106 | // Refresh the in-memory account list
107 | accountList = storage.getAccountList()
108 | }
109 | return success
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/composeApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.compose.desktop.application.dsl.TargetFormat
2 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
3 | import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
4 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
5 | import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
6 |
7 | plugins {
8 | alias(libs.plugins.kotlinMultiplatform)
9 | alias(libs.plugins.androidApplication)
10 | alias(libs.plugins.composeMultiplatform)
11 | alias(libs.plugins.composeCompiler)
12 | alias(libs.plugins.composeHotReload)
13 | alias(libs.plugins.kotlinxSerialization)
14 | }
15 |
16 | kotlin {
17 | androidTarget {
18 | @OptIn(ExperimentalKotlinGradlePluginApi::class)
19 | compilerOptions {
20 | jvmTarget.set(JvmTarget.JVM_21)
21 | }
22 | }
23 |
24 | listOf(
25 | iosArm64(),
26 | iosSimulatorArm64()
27 | ).forEach { iosTarget ->
28 | iosTarget.binaries.framework {
29 | baseName = "ComposeApp"
30 | isStatic = true
31 | }
32 | }
33 |
34 | jvm("desktop")
35 |
36 | @OptIn(ExperimentalWasmDsl::class)
37 | wasmJs {
38 | outputModuleName = "composeApp.js"
39 | browser {
40 | val rootDirPath = rootDir.path
41 | val projectDirPath = projectDir.path
42 | commonWebpackConfig {
43 | outputFileName = "composeApp.js"
44 | devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
45 | static(rootDirPath)
46 | static(projectDirPath)
47 | }
48 | }
49 | }
50 | binaries.executable()
51 | }
52 |
53 | sourceSets {
54 | val desktopMain by getting
55 |
56 | commonMain.dependencies {
57 | implementation(compose.runtime)
58 | implementation(compose.foundation)
59 | implementation(compose.material3)
60 | implementation(compose.ui)
61 | implementation(compose.components.resources)
62 | implementation(compose.components.uiToolingPreview)
63 | implementation(libs.androidx.lifecycle.viewmodel)
64 | implementation(libs.androidx.lifecycle.runtimeCompose)
65 | implementation(libs.androidx.navigation.compose)
66 | implementation(libs.material.icons.extended)
67 |
68 | implementation(project.dependencies.platform(libs.koin.bom))
69 | implementation(libs.koin.compose)
70 | implementation(libs.koin.compose.viewmodel)
71 |
72 | implementation(libs.kstore)
73 | implementation(project(":sharedLib"))
74 | }
75 | commonTest.dependencies {
76 | implementation(libs.kotlin.test)
77 | }
78 | androidMain.dependencies {
79 | implementation(compose.preview)
80 | implementation(libs.androidx.activity.compose)
81 | implementation(libs.kstore.file)
82 | implementation(libs.kotlin.multiplatform.appdirs)
83 | }
84 | iosMain.dependencies {
85 | implementation(libs.kstore.file)
86 | }
87 | desktopMain.dependencies {
88 | implementation(compose.desktop.currentOs)
89 | implementation(libs.kotlinx.coroutines.swing)
90 | implementation(libs.kstore.file)
91 | implementation(libs.kotlin.multiplatform.appdirs)
92 | implementation(project(":sharedLib"))
93 | }
94 | wasmJsMain.dependencies {
95 | implementation(libs.kstore.storage)
96 | }
97 | }
98 | }
99 |
100 | android {
101 | namespace = "tech.arnav.twofac"
102 | compileSdk = libs.versions.android.compileSdk.get().toInt()
103 |
104 | defaultConfig {
105 | applicationId = "tech.arnav.twofac"
106 | minSdk = libs.versions.android.minSdk.get().toInt()
107 | targetSdk = libs.versions.android.targetSdk.get().toInt()
108 | versionCode = 1
109 | versionName = "1.0"
110 | }
111 | packaging {
112 | resources {
113 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
114 | }
115 | }
116 | buildTypes {
117 | getByName("release") {
118 | isMinifyEnabled = false
119 | }
120 | }
121 | compileOptions {
122 | sourceCompatibility = JavaVersion.VERSION_21
123 | targetCompatibility = JavaVersion.VERSION_21
124 | }
125 | }
126 |
127 | dependencies {
128 | debugImplementation(compose.uiTooling)
129 | }
130 |
131 | compose.desktop {
132 | application {
133 | mainClass = "tech.arnav.twofac.MainKt"
134 |
135 | nativeDistributions {
136 | targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
137 | packageName = "tech.arnav.twofac"
138 | packageVersion = "1.0.0"
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/screens/AccountDetailScreen.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.screens
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
10 | import androidx.compose.material3.Button
11 | import androidx.compose.material3.ExperimentalMaterial3Api
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.IconButton
14 | import androidx.compose.material3.MaterialTheme
15 | import androidx.compose.material3.OutlinedTextField
16 | import androidx.compose.material3.Scaffold
17 | import androidx.compose.material3.Text
18 | import androidx.compose.material3.TopAppBar
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.collectAsState
21 | import androidx.compose.runtime.getValue
22 | import androidx.compose.runtime.mutableStateOf
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.runtime.setValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.unit.dp
28 | import org.koin.compose.viewmodel.koinViewModel
29 | import tech.arnav.twofac.viewmodels.AccountsViewModel
30 |
31 | @OptIn(ExperimentalMaterial3Api::class)
32 | @Composable
33 | fun AccountDetailScreen(
34 | accountId: String,
35 | onNavigateBack: () -> Unit,
36 | viewModel: AccountsViewModel = koinViewModel()
37 | ) {
38 | var passkeyText by remember { mutableStateOf("") }
39 | var currentOtp by remember { mutableStateOf(null) }
40 |
41 | val accounts by viewModel.accounts.collectAsState()
42 | val error by viewModel.error.collectAsState()
43 | val isLoading by viewModel.isLoading.collectAsState()
44 | val isLibUnlocked = viewModel.twoFacLibUnlocked
45 |
46 | val account = accounts.find { it.accountID == accountId }
47 |
48 | Scaffold(
49 | topBar = {
50 | TopAppBar(
51 | title = { Text("Account Details") },
52 | navigationIcon = {
53 | IconButton(onClick = onNavigateBack) {
54 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
55 | }
56 | }
57 | )
58 | }
59 | ) { paddingValues ->
60 | Column(
61 | modifier = Modifier
62 | .fillMaxSize()
63 | .padding(paddingValues)
64 | .padding(16.dp),
65 | horizontalAlignment = Alignment.CenterHorizontally,
66 | verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)
67 | ) {
68 |
69 | if (isLoading) {
70 | Text(
71 | text = "Loading account details...",
72 | style = MaterialTheme.typography.bodyLarge
73 | )
74 | return@Column
75 | }
76 |
77 | if (account != null) {
78 | Text(
79 | text = "Account: ${account.accountLabel}",
80 | style = MaterialTheme.typography.bodyLarge
81 | )
82 |
83 | if (!isLibUnlocked) {
84 | OutlinedTextField(
85 | value = passkeyText,
86 | onValueChange = { passkeyText = it },
87 | label = { Text("Passkey") },
88 | placeholder = { Text("Enter your passkey") },
89 | modifier = Modifier.fillMaxWidth()
90 | )
91 | }
92 |
93 | Button(
94 | onClick = {
95 | currentOtp = viewModel.getOtpForAccount(accountId)
96 | },
97 | enabled = isLibUnlocked || passkeyText.isNotBlank()
98 | ) {
99 | Text("Generate OTP")
100 | }
101 |
102 | currentOtp?.let { otp ->
103 | Text(
104 | text = "OTP: $otp",
105 | style = MaterialTheme.typography.headlineSmall
106 | )
107 | }
108 |
109 | error?.let { errorMessage ->
110 | Text(
111 | text = "Error: $errorMessage",
112 | color = MaterialTheme.colorScheme.error,
113 | modifier = Modifier.padding(8.dp)
114 | )
115 | }
116 | } else {
117 | Text(
118 | text = "Account not found",
119 | style = MaterialTheme.typography.bodyLarge
120 | )
121 | }
122 |
123 | }
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/watchApp/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
10 |
12 |
14 |
16 |
18 |
20 |
22 |
24 |
26 |
28 |
30 |
32 |
34 |
36 |
38 |
40 |
42 |
44 |
46 |
48 |
50 |
52 |
54 |
56 |
58 |
60 |
62 |
64 |
66 |
68 |
70 |
72 |
74 |
75 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/viewmodels/AccountsViewModel.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.viewmodels
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import kotlinx.coroutines.FlowPreview
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import kotlinx.coroutines.flow.asStateFlow
9 | import kotlinx.coroutines.flow.debounce
10 | import kotlinx.coroutines.flow.filter
11 | import kotlinx.coroutines.flow.launchIn
12 | import kotlinx.coroutines.flow.onEach
13 | import kotlinx.coroutines.launch
14 | import tech.arnav.twofac.lib.TwoFacLib
15 | import tech.arnav.twofac.lib.storage.StoredAccount
16 | import kotlin.time.Clock
17 | import kotlin.time.ExperimentalTime
18 |
19 | class AccountsViewModel(
20 | private val twoFacLib: TwoFacLib
21 | ) : ViewModel() {
22 |
23 | companion object {
24 | const val REFRESH_DEBOUNCE = 100L // milliseconds
25 | }
26 |
27 | private val _accounts = MutableStateFlow>(emptyList())
28 | val accounts: StateFlow> = _accounts.asStateFlow()
29 |
30 | private val _accountOtps = MutableStateFlow>>(emptyList())
31 | val accountOtps: StateFlow>> = _accountOtps.asStateFlow()
32 |
33 | private val _isLoading = MutableStateFlow(false)
34 | val isLoading: StateFlow = _isLoading.asStateFlow()
35 |
36 | private val _error = MutableStateFlow(null)
37 | val error: StateFlow = _error.asStateFlow()
38 |
39 | val twoFacLibUnlocked: Boolean get() = twoFacLib.isUnlocked()
40 |
41 | private val _refreshTrigger = MutableStateFlow(0L)
42 |
43 | @OptIn(FlowPreview::class)
44 | private val triggerRefreshFlow = _refreshTrigger
45 | .filter { it > 0 }
46 | .debounce(REFRESH_DEBOUNCE)
47 |
48 | init {
49 | loadAccounts()
50 |
51 | triggerRefreshFlow
52 | .onEach { refreshOtpsInternal() }
53 | .launchIn(viewModelScope)
54 | }
55 |
56 | fun loadAccounts() {
57 | viewModelScope.launch {
58 | _isLoading.value = true
59 | _error.value = null
60 |
61 | try {
62 | val accountList = twoFacLib.getAllAccounts()
63 | _accounts.value = accountList
64 | } catch (e: Exception) {
65 | _error.value = e.message ?: "Failed to load accounts"
66 | } finally {
67 | _isLoading.value = false
68 | }
69 | }
70 | }
71 |
72 | fun loadAccountsWithOtps(passkey: String?) {
73 | viewModelScope.launch {
74 | _isLoading.value = true
75 | _error.value = null
76 |
77 | if (!twoFacLibUnlocked) {
78 | if (passkey.isNullOrBlank()) {
79 | _error.value = "Passkey is required to load accounts with OTPs"
80 | _isLoading.value = false
81 | return@launch
82 | } else {
83 | try {
84 | twoFacLib.unlock(passkey)
85 | } catch (e: Exception) {
86 | _error.value = e.message ?: "Failed to unlock with passkey"
87 | _isLoading.value = false
88 | return@launch
89 | }
90 | }
91 | }
92 |
93 | val accountOtpList = twoFacLib.getAllAccountOTPs()
94 | _accountOtps.value = accountOtpList
95 | _accounts.value = accountOtpList.map { it.first }
96 |
97 | _isLoading.value = false
98 | }
99 | }
100 |
101 | fun addAccount(uri: String, passkey: String) {
102 | viewModelScope.launch {
103 | _isLoading.value = true
104 | _error.value = null
105 |
106 | try {
107 | twoFacLib.unlock(passkey)
108 | val success = twoFacLib.addAccount(uri)
109 | if (success) {
110 | loadAccounts()
111 | } else {
112 | _error.value = "Failed to add account"
113 | }
114 | } catch (e: Exception) {
115 | _error.value = e.message ?: "Failed to add account"
116 | } finally {
117 | _isLoading.value = false
118 | }
119 | }
120 | }
121 |
122 | fun getOtpForAccount(accountId: String): String? {
123 | if (!twoFacLibUnlocked) {
124 | _error.value = "Accounts are not loaded. Please unlock accounts first"
125 | return null
126 | }
127 | return _accountOtps.value.find { it.first.accountID == accountId }?.second
128 | }
129 |
130 | @OptIn(ExperimentalTime::class)
131 | fun refreshOtps() {
132 | _refreshTrigger.value = Clock.System.now().toEpochMilliseconds()
133 | }
134 |
135 | private suspend fun refreshOtpsInternal() {
136 | try {
137 | val accountOtpList = twoFacLib.getAllAccountOTPs()
138 | _accountOtps.value = accountOtpList
139 | } catch (e: Exception) {
140 | _error.value = e.message ?: "Failed to refresh OTPs"
141 | }
142 | }
143 |
144 | fun clearError() {
145 | _error.value = null
146 | }
147 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/components/PasskeyDialog.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.components
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.text.KeyboardActions
9 | import androidx.compose.foundation.text.KeyboardOptions
10 | import androidx.compose.material3.AlertDialog
11 | import androidx.compose.material3.CircularProgressIndicator
12 | import androidx.compose.material3.MaterialTheme
13 | import androidx.compose.material3.OutlinedTextField
14 | import androidx.compose.material3.Text
15 | import androidx.compose.material3.TextButton
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.runtime.LaunchedEffect
18 | import androidx.compose.runtime.getValue
19 | import androidx.compose.runtime.mutableStateOf
20 | import androidx.compose.runtime.remember
21 | import androidx.compose.runtime.setValue
22 | import androidx.compose.ui.Alignment
23 | import androidx.compose.ui.Modifier
24 | import androidx.compose.ui.focus.FocusRequester
25 | import androidx.compose.ui.focus.focusRequester
26 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController
27 | import androidx.compose.ui.text.input.ImeAction
28 | import androidx.compose.ui.text.input.KeyboardType
29 | import androidx.compose.ui.text.input.PasswordVisualTransformation
30 | import androidx.compose.ui.unit.dp
31 | import org.jetbrains.compose.ui.tooling.preview.Preview
32 |
33 | @Composable
34 | fun PasskeyDialog(
35 | isVisible: Boolean,
36 | isLoading: Boolean,
37 | error: String?,
38 | onPasskeySubmit: (String) -> Unit,
39 | onDismiss: () -> Unit
40 | ) {
41 | var passkey by remember { mutableStateOf("") }
42 | val focusRequester = remember { FocusRequester() }
43 | val keyboardController = LocalSoftwareKeyboardController.current
44 |
45 | if (isVisible) {
46 | AlertDialog(
47 | onDismissRequest = onDismiss,
48 | title = {
49 | Text(
50 | text = "Enter Passkey",
51 | style = MaterialTheme.typography.headlineSmall
52 | )
53 | },
54 | text = {
55 | Column(
56 | verticalArrangement = Arrangement.spacedBy(16.dp)
57 | ) {
58 | Text(
59 | text = "Enter your passkey to decrypt and view your accounts",
60 | style = MaterialTheme.typography.bodyMedium
61 | )
62 |
63 | OutlinedTextField(
64 | value = passkey,
65 | onValueChange = { passkey = it },
66 | label = { Text("Passkey") },
67 | visualTransformation = PasswordVisualTransformation(),
68 | keyboardOptions = KeyboardOptions(
69 | keyboardType = KeyboardType.Password,
70 | imeAction = ImeAction.Done
71 | ),
72 | keyboardActions = KeyboardActions(
73 | onDone = {
74 | keyboardController?.hide()
75 | if (passkey.isNotBlank()) {
76 | onPasskeySubmit(passkey)
77 | }
78 | }
79 | ),
80 | modifier = Modifier
81 | .fillMaxWidth()
82 | .focusRequester(focusRequester),
83 | enabled = !isLoading,
84 | singleLine = true
85 | )
86 |
87 | if (error != null) {
88 | Text(
89 | text = error,
90 | color = MaterialTheme.colorScheme.error,
91 | style = MaterialTheme.typography.bodySmall
92 | )
93 | }
94 | }
95 | },
96 | confirmButton = {
97 | Row(
98 | horizontalArrangement = Arrangement.spacedBy(8.dp),
99 | verticalAlignment = Alignment.CenterVertically
100 | ) {
101 | if (isLoading) {
102 | CircularProgressIndicator(
103 | modifier = Modifier.padding(end = 8.dp)
104 | )
105 | }
106 | TextButton(
107 | onClick = {
108 | if (passkey.isNotBlank()) {
109 | onPasskeySubmit(passkey)
110 | }
111 | },
112 | enabled = passkey.isNotBlank() && !isLoading
113 | ) {
114 | Text("Unlock")
115 | }
116 | }
117 | },
118 | dismissButton = {
119 | TextButton(
120 | onClick = onDismiss,
121 | enabled = !isLoading
122 | ) {
123 | Text("Cancel")
124 | }
125 | }
126 | )
127 |
128 | LaunchedEffect(isVisible) {
129 | if (isVisible) {
130 | focusRequester.requestFocus()
131 | }
132 | }
133 | }
134 | }
135 |
136 | @Preview
137 | @Composable
138 | fun PasskeyDialogPreview() {
139 | PasskeyDialog(
140 | isVisible = true,
141 | isLoading = false,
142 | error = null,
143 | onPasskeySubmit = {},
144 | onDismiss = {}
145 | )
146 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/tech/arnav/twofac/components/OTPCard.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.components
2 |
3 | import androidx.compose.animation.core.LinearEasing
4 | import androidx.compose.animation.core.RepeatMode
5 | import androidx.compose.animation.core.animateFloat
6 | import androidx.compose.animation.core.infiniteRepeatable
7 | import androidx.compose.animation.core.rememberInfiniteTransition
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.foundation.layout.Arrangement
10 | import androidx.compose.foundation.layout.Column
11 | import androidx.compose.foundation.layout.Row
12 | import androidx.compose.foundation.layout.fillMaxWidth
13 | import androidx.compose.foundation.layout.padding
14 | import androidx.compose.material3.Card
15 | import androidx.compose.material3.CardDefaults
16 | import androidx.compose.material3.LinearProgressIndicator
17 | import androidx.compose.material3.MaterialTheme
18 | import androidx.compose.material3.Text
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.LaunchedEffect
21 | import androidx.compose.runtime.getValue
22 | import androidx.compose.runtime.mutableLongStateOf
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.runtime.setValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.text.font.FontFamily
28 | import androidx.compose.ui.text.font.FontWeight
29 | import androidx.compose.ui.unit.dp
30 | import androidx.compose.ui.unit.sp
31 | import kotlinx.coroutines.delay
32 | import tech.arnav.twofac.lib.storage.StoredAccount
33 | import kotlin.time.Clock
34 | import kotlin.time.ExperimentalTime
35 |
36 | @OptIn(ExperimentalTime::class)
37 | @Composable
38 | fun OTPCard(
39 | account: StoredAccount.DisplayAccount,
40 | otpCode: String,
41 | timeInterval: Long = 30L,
42 | onRefreshOTP: () -> Unit,
43 | modifier: Modifier = Modifier
44 | ) {
45 | fun currentTimeMillis() = Clock.System.now().epochSeconds
46 | var currentTime by remember { mutableLongStateOf(currentTimeMillis()) }
47 |
48 |
49 | // Calculate the starting progress and create a synchronized animation
50 | val startTime = remember { Clock.System.now().epochSeconds }
51 | val timeInInterval = startTime % timeInterval
52 | val initialProgress = timeInInterval.toFloat() / timeInterval.toFloat()
53 |
54 | // Create a truly smooth infinite animation that syncs with TOTP timing
55 | val infiniteTransition = rememberInfiniteTransition(label = "progress")
56 |
57 | // This animation will run continuously, synced to the TOTP interval
58 | val rawProgress by infiniteTransition.animateFloat(
59 | initialValue = 0f,
60 | targetValue = 1f,
61 | animationSpec = infiniteRepeatable(
62 | animation = tween(durationMillis = (timeInterval * 1000).toInt(), easing = LinearEasing),
63 | repeatMode = RepeatMode.Restart
64 | ),
65 | label = "progress"
66 | )
67 |
68 | // Adjust the progress to start from the correct position
69 | val progress = (rawProgress + initialProgress) % 1f
70 |
71 | // Monitor for OTP refresh - check every second
72 | LaunchedEffect(Unit) {
73 | while (true) {
74 | val newTime = Clock.System.now().epochSeconds
75 | val timeInInterval = newTime % timeInterval
76 |
77 | // Check if we need to refresh OTP (when interval resets)
78 | if (timeInInterval == 0L && newTime != currentTime) {
79 | onRefreshOTP()
80 | }
81 |
82 | currentTime = newTime
83 | delay(1000) // Check every second for OTP refresh
84 | }
85 | }
86 |
87 | Card(
88 | modifier = modifier.fillMaxWidth(),
89 | elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
90 | ) {
91 | Column(
92 | modifier = Modifier
93 | .fillMaxWidth()
94 | .padding(16.dp),
95 | verticalArrangement = Arrangement.spacedBy(8.dp)
96 | ) {
97 | // Account label
98 | Text(
99 | text = account.accountLabel,
100 | style = MaterialTheme.typography.titleMedium,
101 | fontWeight = FontWeight.Medium
102 | )
103 |
104 | // OTP Code
105 | Row(
106 | modifier = Modifier.fillMaxWidth(),
107 | horizontalArrangement = Arrangement.SpaceBetween,
108 | verticalAlignment = Alignment.CenterVertically
109 | ) {
110 | Text(
111 | text = formatOTPCode(otpCode),
112 | style = MaterialTheme.typography.headlineMedium,
113 | fontFamily = FontFamily.Monospace,
114 | fontWeight = FontWeight.Bold,
115 | fontSize = 24.sp,
116 | color = MaterialTheme.colorScheme.primary
117 | )
118 |
119 | // Time remaining
120 | val timeRemaining = timeInterval - (currentTime % timeInterval)
121 | Text(
122 | text = "${timeRemaining}s",
123 | style = MaterialTheme.typography.bodyMedium,
124 | color = MaterialTheme.colorScheme.onSurfaceVariant
125 | )
126 | }
127 |
128 | // Progress bar
129 | LinearProgressIndicator(
130 | progress = { progress },
131 | modifier = Modifier.fillMaxWidth(),
132 | color = when {
133 | progress < 0.5f -> MaterialTheme.colorScheme.primary
134 | progress < 0.8f -> MaterialTheme.colorScheme.secondary
135 | else -> MaterialTheme.colorScheme.error
136 | }
137 | )
138 | }
139 | }
140 | }
141 |
142 | private fun formatOTPCode(code: String): String {
143 | return if (code.length == 6) {
144 | "${code.substring(0, 3)} ${code.substring(3)}"
145 | } else {
146 | code
147 | }
148 | }
--------------------------------------------------------------------------------
/sharedLib/src/commonTest/kotlin/tech/arnav/twofac/lib/storage/StorageUtilsTest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.storage
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import kotlinx.coroutines.test.runTest
5 | import tech.arnav.twofac.lib.crypto.DefaultCryptoTools
6 | import tech.arnav.twofac.lib.crypto.Encoding.toHexString
7 | import tech.arnav.twofac.lib.otp.TOTP
8 | import tech.arnav.twofac.lib.storage.StorageUtils.toOTP
9 | import tech.arnav.twofac.lib.storage.StorageUtils.toStoredAccount
10 | import kotlin.test.Test
11 | import kotlin.test.assertEquals
12 | import kotlin.test.assertFails
13 | import kotlin.test.assertNotNull
14 | import kotlin.uuid.ExperimentalUuidApi
15 |
16 | @OptIn(ExperimentalUuidApi::class)
17 | class StorageUtilsTest {
18 |
19 | private val cryptoTools = DefaultCryptoTools(CryptographyProvider.Default)
20 |
21 | @OptIn(ExperimentalUuidApi::class)
22 | @Test
23 | fun testOtpToStoredAccount() = runTest {
24 | // Create a test OTP
25 | val totp = TOTP(
26 | digits = 6,
27 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
28 | accountName = "test@example.com",
29 | issuer = "Test"
30 | )
31 |
32 | // Create a signing key
33 | val signingKey = cryptoTools.createSigningKey("test-password")
34 |
35 | // Convert OTP to StoredAccount
36 | val storedAccount = totp.toStoredAccount(signingKey)
37 |
38 | // Verify the stored account properties
39 | assertNotNull(storedAccount.accountID)
40 | assertEquals("Test:test@example.com", storedAccount.accountLabel)
41 | assertEquals(signingKey.salt.toHexString(), storedAccount.salt)
42 | assertNotNull(storedAccount.encryptedURI)
43 | }
44 |
45 | @Test
46 | fun testStoredAccountToOtp() = runTest {
47 | // Create a test OTP
48 | val originalTotp = TOTP(
49 | digits = 6,
50 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
51 | accountName = "test@example.com",
52 | issuer = "Test"
53 | )
54 |
55 | // Create a signing key
56 | val signingKey = cryptoTools.createSigningKey("test-password")
57 |
58 | // Convert OTP to StoredAccount
59 | val storedAccount = originalTotp.toStoredAccount(signingKey)
60 |
61 | // Convert StoredAccount back to OTP
62 | val recoveredOtp = storedAccount.toOTP(signingKey)
63 |
64 | // Verify the recovered OTP properties
65 | assertEquals(originalTotp.digits, recoveredOtp.digits)
66 | assertEquals(originalTotp.algorithm, recoveredOtp.algorithm)
67 | assertEquals(originalTotp.secret, recoveredOtp.secret)
68 | // Skip accountName check as it may be URL-encoded
69 | assertEquals(originalTotp.issuer, recoveredOtp.issuer)
70 | }
71 |
72 | @Test
73 | fun testRoundTripConversion() = runTest {
74 | // Create a test OTP
75 | val originalTotp = TOTP(
76 | digits = 8, // Non-default value
77 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
78 | accountName = "test@example.com",
79 | issuer = "Test",
80 | timeInterval = 60 // Non-default value
81 | )
82 |
83 | // Create a signing key
84 | val signingKey = cryptoTools.createSigningKey("test-password")
85 |
86 | // Convert OTP to StoredAccount
87 | val storedAccount = originalTotp.toStoredAccount(signingKey)
88 |
89 | // Convert StoredAccount back to OTP
90 | val recoveredOtp = storedAccount.toOTP(signingKey) as TOTP
91 |
92 | // Verify all properties match
93 | assertEquals(originalTotp.digits, recoveredOtp.digits)
94 | assertEquals(originalTotp.algorithm, recoveredOtp.algorithm)
95 | assertEquals(originalTotp.secret, recoveredOtp.secret)
96 | // Skip accountName check as it may be URL-encoded
97 | assertEquals(originalTotp.issuer, recoveredOtp.issuer)
98 | assertEquals(originalTotp.timeInterval, recoveredOtp.timeInterval)
99 |
100 | // Verify OTP generation works the same
101 | val time = 1000L
102 | assertEquals(originalTotp.generateOTP(time), recoveredOtp.generateOTP(time))
103 | }
104 |
105 | @Test
106 | fun testOtpWithEmptyIssuer() = runTest {
107 | // Create a test OTP with empty issuer (not null)
108 | val totp = TOTP(
109 | digits = 6,
110 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
111 | accountName = "test@example.com",
112 | issuer = "Default" // Use a default issuer instead of null
113 | )
114 |
115 | // Create a signing key
116 | val signingKey = cryptoTools.createSigningKey("test-password")
117 |
118 | // Convert OTP to StoredAccount
119 | val storedAccount = totp.toStoredAccount(signingKey)
120 |
121 | // Verify the account label has the default issuer prefix
122 | assertEquals("Default:test@example.com", storedAccount.accountLabel)
123 |
124 | // Convert StoredAccount back to OTP
125 | val recoveredOtp = storedAccount.toOTP(signingKey)
126 |
127 | // Verify the recovered OTP has the default issuer
128 | assertEquals("Default", recoveredOtp.issuer)
129 | }
130 |
131 | @Test
132 | fun testWithDifferentSigningKey() = runTest {
133 | // Create a test OTP
134 | val totp = TOTP(
135 | digits = 6,
136 | secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
137 | accountName = "test@example.com",
138 | issuer = "Test"
139 | )
140 |
141 | // Create two different signing keys
142 | val signingKey1 = cryptoTools.createSigningKey("password1")
143 | val signingKey2 = cryptoTools.createSigningKey("password2")
144 |
145 | // Convert OTP to StoredAccount with first key
146 | val storedAccount = totp.toStoredAccount(signingKey1)
147 |
148 | // Try to convert StoredAccount back to OTP with second key
149 | assertFails("Should fail with wrong signing key") {
150 | storedAccount.toOTP(signingKey2)
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/sharedLib/src/commonMain/kotlin/tech/arnav/twofac/lib/crypto/Encoding.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.crypto
2 |
3 | import kotlinx.io.bytestring.ByteString
4 | import kotlinx.io.bytestring.ByteStringBuilder
5 | import kotlin.experimental.ExperimentalNativeApi
6 | import kotlin.js.ExperimentalJsStatic
7 | import kotlin.js.JsStatic
8 | import kotlin.jvm.JvmStatic
9 | import kotlin.native.CName
10 |
11 |
12 | @OptIn(ExperimentalJsStatic::class, ExperimentalNativeApi::class)
13 | object Encoding {
14 | // Base32 alphabet (RFC 4648)
15 | const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
16 |
17 | /**
18 | * Decode a Base32-encoded string into a byte array.
19 | *
20 | * @param base32 The Base32-encoded string to decode.
21 | * @return The decoded byte array.
22 | */
23 | @JvmStatic
24 | @JsStatic
25 | @CName("decode_base32")
26 | fun decodeBase32(base32: String): ByteArray {
27 | // Remove padding and convert to uppercase
28 | val cleanInput = base32.replace("=", "").uppercase()
29 |
30 | // Calculate the output length
31 | val outputLength = (cleanInput.length * 5) / 8
32 | val result = ByteArray(outputLength)
33 |
34 | var buffer = 0
35 | var bitsLeft = 0
36 | var outputIndex = 0
37 |
38 | for (c in cleanInput) {
39 | val value = ALPHABET.indexOf(c)
40 | if (value < 0) continue // Skip invalid characters
41 |
42 | // Add the 5 bits to the buffer
43 | buffer = (buffer shl 5) or value
44 | bitsLeft += 5
45 |
46 | // If we have at least 8 bits, write a byte
47 | if (bitsLeft >= 8) {
48 | bitsLeft -= 8
49 | result[outputIndex++] = (buffer shr bitsLeft).toByte()
50 | }
51 | }
52 |
53 | return result
54 | }
55 |
56 | /**
57 | * Encode a byte array into a Base32-encoded string.
58 | *
59 | * @param bytes The byte array to encode.
60 | * @return The Base32-encoded string.
61 | */
62 | fun encodeBase32(bytes: ByteArray): String {
63 | val output = StringBuilder()
64 | var buffer = 0
65 | var bitsLeft = 0
66 |
67 | for (byte in bytes) {
68 | buffer = (buffer shl 8) or (byte.toInt() and 0xFF)
69 | bitsLeft += 8
70 |
71 | while (bitsLeft >= 5) {
72 | bitsLeft -= 5
73 | val index = (buffer shr bitsLeft) and 0x1F
74 | output.append(ALPHABET[index])
75 | }
76 | }
77 |
78 | // Handle remaining bits
79 | if (bitsLeft > 0) {
80 | buffer = buffer shl (5 - bitsLeft)
81 | output.append(ALPHABET[buffer and 0x1F])
82 | }
83 |
84 | // Add padding if necessary
85 | while (output.length % 8 != 0) {
86 | output.append('=')
87 | }
88 |
89 | return output.toString()
90 | }
91 |
92 | /**
93 | * Decode an ASCII string into a byte array.
94 | *
95 | * @param ascii The ASCII string to decode.
96 | * @return The decoded byte array.
97 | */
98 | @JvmStatic
99 | @JsStatic
100 | @CName("decode_ascii")
101 | fun decodeAscii(ascii: String): ByteArray {
102 | // Convert ASCII string to byte array
103 | return ByteArray(ascii.length) { i ->
104 | ascii[i].code.toByte()
105 | }
106 | }
107 |
108 | /**
109 | * Encode a string for use in a URI.
110 | */
111 | internal fun encodeURIComponent(s: String): String {
112 | return s.replace(" ", "%20")
113 | .replace(":", "%3A")
114 | .replace("/", "%2F")
115 | .replace("?", "%3F")
116 | .replace("&", "%26")
117 | .replace("=", "%3D")
118 | .replace("+", "%2B")
119 | .replace("#", "%23")
120 | .replace("@", "%40")
121 | }
122 |
123 | /**
124 | * Decode a URI-encoded string.
125 | */
126 | internal fun decodeURIComponent(s: String): String {
127 | return s.replace("%20", " ")
128 | .replace("%3A", ":")
129 | .replace("%2F", "/")
130 | .replace("%3F", "?")
131 | .replace("%26", "&")
132 | .replace("%3D", "=")
133 | .replace("%2B", "+")
134 | .replace("%23", "#")
135 | .replace("%40", "@")
136 | }
137 |
138 | /**
139 | * Convert a hexadecimal string to a ByteString.
140 | *
141 | * @param this The hexadecimal string to convert.
142 | * @return The resulting ByteString.
143 | * @throws NumberFormatException If the string is not a valid hexadecimal representation.
144 | */
145 | @Throws(NumberFormatException::class)
146 | internal fun String.toByteString(): ByteString {
147 | require(length % 2 == 0) { "Hex string must have an even length" }
148 | return ByteStringBuilder().apply {
149 | chunked(2).forEach {
150 | append(it.toInt(16).toByte())
151 | }
152 | }.toByteString()
153 | }
154 |
155 | /**
156 | * Convert a ByteString to a hexadecimal string.
157 | *
158 | * @return The hexadecimal representation of the ByteString.
159 | */
160 | internal fun ByteString.toHexString(): String {
161 | return this.toByteArray().joinToString("") { byte ->
162 | byte.toUByte().toString(16).padStart(2, '0')
163 | }
164 | }
165 |
166 | /**
167 | * Wrapper function for testing: Convert a hexadecimal string to a ByteString.
168 | *
169 | * @param hexString The hexadecimal string to convert.
170 | * @return The resulting ByteString.
171 | * @throws NumberFormatException If the string is not a valid hexadecimal representation.
172 | */
173 | @JvmStatic
174 | @JsStatic
175 | @CName("hex_to_bytestring")
176 | @Throws(NumberFormatException::class)
177 | fun hexToByteString(hexString: String): ByteString {
178 | return hexString.toByteString()
179 | }
180 |
181 | /**
182 | * Wrapper function for testing: Convert a ByteString to a hexadecimal string.
183 | *
184 | * @param byteString The ByteString to convert.
185 | * @return The hexadecimal representation of the ByteString.
186 | */
187 | @JvmStatic
188 | @JsStatic
189 | @CName("bytestring_to_hex")
190 | fun byteStringToHex(byteString: ByteString): String {
191 | return byteString.toHexString()
192 | }
193 |
194 | }
195 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
--------------------------------------------------------------------------------
/sharedLib/src/commonTest/kotlin/tech/arnav/twofac/lib/uri/OtpAuthURITest.kt:
--------------------------------------------------------------------------------
1 | package tech.arnav.twofac.lib.uri
2 |
3 | import kotlinx.coroutines.test.runTest
4 | import tech.arnav.twofac.lib.crypto.CryptoTools
5 | import tech.arnav.twofac.lib.otp.HOTP
6 | import tech.arnav.twofac.lib.otp.TOTP
7 | import kotlin.test.Test
8 | import kotlin.test.assertEquals
9 | import kotlin.test.assertTrue
10 |
11 | class OtpAuthURITest {
12 |
13 | // Test secret (same as used in HOTP and TOTP tests)
14 | private val testSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
15 |
16 | @Test
17 | fun testCreateTOTPUri() = runTest {
18 | val totp = TOTP(
19 | digits = 6,
20 | algorithm = CryptoTools.Algo.SHA1,
21 | secret = testSecret,
22 | timeInterval = 30,
23 | accountName = "alice@example.com",
24 | issuer = "Example"
25 | )
26 |
27 | val uri = OtpAuthURI.create(totp)
28 |
29 | // Verify the URI format
30 | assertTrue(
31 | uri.startsWith("otpauth://totp/Example%3Aalice%40example.com?"),
32 | "URI should start with correct prefix"
33 | )
34 | assertTrue(uri.contains("secret=$testSecret"), "URI should contain the secret")
35 | assertTrue(uri.contains("issuer=Example"), "URI should contain the issuer")
36 | }
37 |
38 | @Test
39 | fun testCreateHOTPUri() = runTest {
40 | val hotp = HOTP(
41 | digits = 6,
42 | algorithm = CryptoTools.Algo.SHA1,
43 | secret = testSecret,
44 | accountName = "bob@example.com",
45 | issuer = "Example"
46 | )
47 |
48 | val uri = OtpAuthURI.create(hotp)
49 |
50 | // Verify the URI format
51 | assertTrue(
52 | uri.startsWith("otpauth://hotp/Example%3Abob%40example.com?"),
53 | "URI should start with correct prefix"
54 | )
55 | assertTrue(uri.contains("secret=$testSecret"), "URI should contain the secret")
56 | assertTrue(uri.contains("issuer=Example"), "URI should contain the issuer")
57 | assertTrue(uri.contains("counter=0"), "URI should contain the counter")
58 | }
59 |
60 | @Test
61 | fun testParseTOTPUri() = runTest {
62 | val uri = "otpauth://totp/Example%3Aalice%40example.com?secret=$testSecret&issuer=Example&period=30"
63 | val otp = OtpAuthURI.parse(uri)
64 |
65 | // Verify the OTP object
66 | assertTrue(otp is TOTP, "Parsed OTP should be a TOTP instance")
67 | assertEquals(testSecret, otp.secret, "Secret should match")
68 | assertEquals(6, otp.digits, "Digits should match")
69 | assertEquals(CryptoTools.Algo.SHA1, otp.algorithm, "Algorithm should match")
70 |
71 | // Generate an OTP and verify it's a 6-digit string
72 | val generatedOtp = otp.generateOTP(0)
73 | assertEquals(6, generatedOtp.length, "Generated OTP should have 6 digits")
74 | }
75 |
76 | @Test
77 | fun testParseHOTPUri() = runTest {
78 | val uri = "otpauth://hotp/Example%3Abob%40example.com?secret=$testSecret&issuer=Example&counter=42"
79 | val otp = OtpAuthURI.parse(uri)
80 |
81 | // Verify the OTP object
82 | assertTrue(otp is HOTP, "Parsed OTP should be a HOTP instance")
83 | assertEquals(testSecret, otp.secret, "Secret should match")
84 | assertEquals(6, otp.digits, "Digits should match")
85 | assertEquals(CryptoTools.Algo.SHA1, otp.algorithm, "Algorithm should match")
86 |
87 | // Generate an OTP and verify it's a 6-digit string
88 | val generatedOtp = otp.generateOTP(42)
89 | assertEquals(6, generatedOtp.length, "Generated OTP should have 6 digits")
90 | }
91 |
92 | @Test
93 | fun testBuilderPattern() {
94 | val uri = OtpAuthURI.Builder()
95 | .type(OtpAuthURI.Type.TOTP)
96 | .label("Example:charlie@example.com")
97 | .secret(testSecret)
98 | .issuer("Example")
99 | .algorithm(CryptoTools.Algo.SHA256)
100 | .digits(8)
101 | .period(60)
102 | .build()
103 |
104 | // Verify the URI format
105 | assertTrue(
106 | uri.startsWith("otpauth://totp/Example%3Acharlie%40example.com?"),
107 | "URI should start with correct prefix"
108 | )
109 | assertTrue(uri.contains("secret=$testSecret"), "URI should contain the secret")
110 | assertTrue(uri.contains("issuer=Example"), "URI should contain the issuer")
111 | assertTrue(uri.contains("algorithm=SHA256"), "URI should contain the algorithm")
112 | assertTrue(uri.contains("digits=8"), "URI should contain the digits")
113 | assertTrue(uri.contains("period=60"), "URI should contain the period")
114 | }
115 |
116 | @Test
117 | fun testBuilderWithHOTP() {
118 | val uri = OtpAuthURI.Builder()
119 | .type(OtpAuthURI.Type.HOTP)
120 | .label("Example:dave@example.com")
121 | .secret(testSecret)
122 | .issuer("Example")
123 | .counter(100)
124 | .build()
125 |
126 | // Verify the URI format
127 | assertTrue(
128 | uri.startsWith("otpauth://hotp/Example%3Adave%40example.com?"),
129 | "URI should start with correct prefix"
130 | )
131 | assertTrue(uri.contains("secret=$testSecret"), "URI should contain the secret")
132 | assertTrue(uri.contains("issuer=Example"), "URI should contain the issuer")
133 | assertTrue(uri.contains("counter=100"), "URI should contain the counter")
134 | }
135 |
136 | @Test
137 | fun testRoundTrip() = runTest {
138 | // Create a TOTP object with default timeInterval (30)
139 | val originalTotp = TOTP(
140 | digits = 8,
141 | algorithm = CryptoTools.Algo.SHA256,
142 | secret = testSecret,
143 | accountName = "user@example.com",
144 | issuer = "Test"
145 | // Using default timeInterval (30)
146 | )
147 |
148 | // Convert to URI
149 | val uri = OtpAuthURI.create(originalTotp)
150 |
151 | // Parse back to OTP
152 | val parsedOtp = OtpAuthURI.parse(uri)
153 |
154 | // Verify the parsed OTP matches the original
155 | assertTrue(parsedOtp is TOTP, "Parsed OTP should be a TOTP instance")
156 | assertEquals(testSecret, parsedOtp.secret, "Secret should match")
157 | assertEquals(8, parsedOtp.digits, "Digits should match")
158 | assertEquals(CryptoTools.Algo.SHA256, parsedOtp.algorithm, "Algorithm should match")
159 |
160 | // Generate OTPs with both objects and verify they match
161 | val timestamp = 1234567890L
162 | val originalOtp = originalTotp.generateOTP(timestamp)
163 | val parsedOtpValue = parsedOtp.generateOTP(timestamp)
164 | assertEquals(originalOtp, parsedOtpValue, "Generated OTPs should match")
165 | }
166 | }
167 |
--------------------------------------------------------------------------------