feature: Add OTP code by scanning Google Authenticator single export code #556

This commit is contained in:
Artem Chepurnoy 2024-09-14 17:16:36 +03:00
parent 5e074d9586
commit 3714670287
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
16 changed files with 386 additions and 13 deletions

View File

@ -119,6 +119,7 @@ kotlin {
api(libs.kotlinx.datetime) api(libs.kotlinx.datetime)
api(libs.kotlinx.serialization.json) api(libs.kotlinx.serialization.json)
api(libs.kotlinx.serialization.cbor) api(libs.kotlinx.serialization.cbor)
api(libs.kotlinx.serialization.protobuf)
api(libs.arrow.arrow.core) api(libs.arrow.arrow.core)
api(libs.arrow.arrow.optics) api(libs.arrow.arrow.optics)
api(libs.kodein.kodein.di) api(libs.kodein.kodein.di)

View File

@ -4,4 +4,5 @@ enum class CryptoHashAlgorithm {
SHA_1, SHA_1,
SHA_256, SHA_256,
SHA_512, SHA_512,
MD5,
} }

View File

@ -157,12 +157,8 @@ private fun parseTotpAuth(
keyBase32 = secretParam keyBase32 = secretParam
} }
params["algorithm"]?.also { algorithmParam -> params["algorithm"]?.also { algorithmParam ->
val alg = when (algorithmParam.lowercase()) { val alg = parseHashAlgorithmOrNull(algorithmParam)
"sha1" -> CryptoHashAlgorithm.SHA_1 ?: return@also
"sha256" -> CryptoHashAlgorithm.SHA_256
"sha512" -> CryptoHashAlgorithm.SHA_512
else -> return@also
}
builder.algorithm = alg builder.algorithm = alg
} }
@ -201,12 +197,8 @@ private fun parseHotpAuth(
keyBase32 = secretParam keyBase32 = secretParam
} }
params["algorithm"]?.also { algorithmParam -> params["algorithm"]?.also { algorithmParam ->
val alg = when (algorithmParam.lowercase()) { val alg = parseHashAlgorithmOrNull(algorithmParam)
"sha1" -> CryptoHashAlgorithm.SHA_1 ?: return@also
"sha256" -> CryptoHashAlgorithm.SHA_256
"sha512" -> CryptoHashAlgorithm.SHA_512
else -> return@also
}
builder.algorithm = alg builder.algorithm = alg
} }
@ -220,6 +212,14 @@ private fun parseHotpAuth(
) )
} }
private fun parseHashAlgorithmOrNull(name: String) = when (name.lowercase()) {
"sha1" -> CryptoHashAlgorithm.SHA_1
"sha256" -> CryptoHashAlgorithm.SHA_256
"sha512" -> CryptoHashAlgorithm.SHA_512
"md5" -> CryptoHashAlgorithm.MD5
else -> null
}
private fun parseOtpSteam( private fun parseOtpSteam(
url: String, url: String,
): Either<Throwable, TotpToken.SteamAuth> = Either.catch { ): Either<Throwable, TotpToken.SteamAuth> = Either.catch {

View File

@ -0,0 +1,13 @@
package com.artemchep.keyguard.common.service.googleauthenticator
import arrow.core.Either
interface OtpMigrationService {
/**
* Returns a migrations service to use, or
* `null` if there no tool to use.
*/
fun handler(uri: String): OtpMigrationService?
fun convert(uri: String): Either<Throwable, String>
}

View File

@ -0,0 +1,40 @@
package com.artemchep.keyguard.common.service.googleauthenticator.impl
import arrow.core.Either
import arrow.core.flatMap
import arrow.core.flatten
import com.artemchep.keyguard.common.service.googleauthenticator.util.OtpMigrationConst
import com.artemchep.keyguard.common.service.googleauthenticator.OtpMigrationService
import com.artemchep.keyguard.common.service.googleauthenticator.util.build
import com.artemchep.keyguard.common.service.googleauthenticator.util.OtpMigrationParser
import com.artemchep.keyguard.common.service.text.Base32Service
import org.kodein.di.DirectDI
import org.kodein.di.instance
class OtpMigrationServiceImpl(
private val base32Service: Base32Service,
private val otpMigrationParser: OtpMigrationParser,
) : OtpMigrationService {
constructor(
directDI: DirectDI,
) : this(
base32Service = directDI.instance(),
otpMigrationParser = directDI.instance(),
)
override fun handler(uri: String): OtpMigrationService? =
this.takeIf { uri.startsWith(OtpMigrationConst.PREFIX) }
override fun convert(
uri: String,
): Either<Throwable, String> = Either.catch {
otpMigrationParser.parse(uri)
.flatMap {
it.otpParameters
.first()
.build(
base32Service = base32Service,
)
}
}.flatten()
}

View File

@ -0,0 +1,144 @@
package com.artemchep.keyguard.common.service.googleauthenticator.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
/*
Schema is available here
https://github.com/dim13/otpauth/blob/master/migration/migration.proto
under ISC License 2020 Dimitri Sokolyuk <demon@dim13.org>
message Payload {
message OtpParameters {
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
ALGORITHM_SHA1 = 1;
ALGORITHM_SHA256 = 2;
ALGORITHM_SHA512 = 3;
ALGORITHM_MD5 = 4;
}
enum DigitCount {
DIGIT_COUNT_UNSPECIFIED = 0;
DIGIT_COUNT_SIX = 1;
DIGIT_COUNT_EIGHT = 2;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
OTP_TYPE_HOTP = 1;
OTP_TYPE_TOTP = 2;
}
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
DigitCount digits = 5;
OtpType type = 6;
uint64 counter = 7;
}
repeated OtpParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class OtpAuthMigrationData(
@ProtoNumber(1)
@SerialName("otp_parameters")
val otpParameters: List<OtpParameters>,
@ProtoNumber(2)
val version: Int = 0,
@ProtoNumber(3)
@SerialName("batch_size")
val batchSize: Int = 0,
@ProtoNumber(4)
@SerialName("batch_index")
val batchIndex: Int = 0,
@ProtoNumber(5)
@SerialName("batch_id")
val batchId: Int = 0,
) {
@Serializable
data class OtpParameters(
@ProtoNumber(1)
val secret: ByteArray,
@ProtoNumber(2)
val name: String? = null,
@ProtoNumber(3)
val issuer: String? = null,
@ProtoNumber(4)
val algorithm: Algorithm = Algorithm.ALGORITHM_UNSPECIFIED,
@ProtoNumber(5)
val digits: DigitCount = DigitCount.DIGIT_COUNT_UNSPECIFIED,
@ProtoNumber(6)
val type: Type = Type.OTP_TYPE_UNSPECIFIED,
@ProtoNumber(7)
val counter: Int? = null,
) {
@Serializable
enum class Algorithm {
@ProtoNumber(0)
ALGORITHM_UNSPECIFIED,
@ProtoNumber(1)
ALGORITHM_SHA1,
@ProtoNumber(2)
ALGORITHM_SHA256,
@ProtoNumber(3)
ALGORITHM_SHA512,
@ProtoNumber(4)
ALGORITHM_MD5,
}
@Serializable
enum class DigitCount {
@ProtoNumber(0)
DIGIT_COUNT_UNSPECIFIED,
@ProtoNumber(1)
DIGIT_COUNT_SIX,
@ProtoNumber(2)
DIGIT_COUNT_EIGHT,
}
@Serializable
enum class Type {
@ProtoNumber(0)
OTP_TYPE_UNSPECIFIED,
@ProtoNumber(1)
OTP_TYPE_HOTP,
@ProtoNumber(2)
OTP_TYPE_TOTP,
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as OtpParameters
if (!secret.contentEquals(other.secret)) return false
if (name != other.name) return false
if (issuer != other.issuer) return false
if (algorithm != other.algorithm) return false
if (digits != other.digits) return false
if (type != other.type) return false
if (counter != other.counter) return false
return true
}
override fun hashCode(): Int {
var result = secret.contentHashCode()
result = 31 * result + (name?.hashCode() ?: 0)
result = 31 * result + (issuer?.hashCode() ?: 0)
result = 31 * result + algorithm.hashCode()
result = 31 * result + digits.hashCode()
result = 31 * result + type.hashCode()
result = 31 * result + (counter ?: 0)
return result
}
}
}

View File

@ -0,0 +1,5 @@
package com.artemchep.keyguard.common.service.googleauthenticator.util
object OtpMigrationConst {
const val PREFIX = "otpauth-migration://"
}

View File

@ -0,0 +1,87 @@
package com.artemchep.keyguard.common.service.googleauthenticator.util
import arrow.core.Either
import com.artemchep.keyguard.common.service.googleauthenticator.model.OtpAuthMigrationData
import com.artemchep.keyguard.common.service.text.Base32Service
import io.ktor.http.*
/**
* Builds a valid OTP URI for use with all other
* than Google Authenticator apps.
*/
fun OtpAuthMigrationData.OtpParameters.build(
base32Service: Base32Service,
): Either<Throwable, String> = Either.catch {
when (type) {
OtpAuthMigrationData.OtpParameters.Type.OTP_TYPE_TOTP -> buildTotp(base32Service)
OtpAuthMigrationData.OtpParameters.Type.OTP_TYPE_HOTP -> buildHotp(base32Service)
else -> throw IllegalArgumentException("Unsupported OTP type!")
}
}
private val OtpAuthMigrationData.OtpParameters.DigitCount.count
get() = when (this) {
OtpAuthMigrationData.OtpParameters.DigitCount.DIGIT_COUNT_EIGHT -> 8
OtpAuthMigrationData.OtpParameters.DigitCount.DIGIT_COUNT_SIX -> 6
OtpAuthMigrationData.OtpParameters.DigitCount.DIGIT_COUNT_UNSPECIFIED -> null
}
private val OtpAuthMigrationData.OtpParameters.Algorithm.str
get() = when (this) {
OtpAuthMigrationData.OtpParameters.Algorithm.ALGORITHM_SHA1 -> "sha1"
OtpAuthMigrationData.OtpParameters.Algorithm.ALGORITHM_SHA256 -> "sha256"
OtpAuthMigrationData.OtpParameters.Algorithm.ALGORITHM_SHA512 -> "sha512"
OtpAuthMigrationData.OtpParameters.Algorithm.ALGORITHM_MD5 -> "md5"
OtpAuthMigrationData.OtpParameters.Algorithm.ALGORITHM_UNSPECIFIED -> null
}
private fun OtpAuthMigrationData.OtpParameters.buildTotp(
base32Service: Base32Service,
): String = build(
"totp",
base32Service = base32Service,
) {
// period
parameters.append("period", "30")
}
private fun OtpAuthMigrationData.OtpParameters.buildHotp(
base32Service: Base32Service,
): String = build(
"hotp",
base32Service = base32Service,
) {
// counter
val counter = counter
if (counter != null) {
parameters.append("counter", counter.toString())
}
}
private fun OtpAuthMigrationData.OtpParameters.build(
host: String,
base32Service: Base32Service,
builder: URLBuilder.() -> Unit,
): String {
return URLBuilder("otpauth://$host/").apply {
val path = issuer.orEmpty() + ":" + name.orEmpty()
appendPathSegments(path)
// digits
val digits = digits.count
if (digits != null) {
parameters.append("digits", digits.toString())
}
// secret
val secret = base32Service.encodeToString(secret)
parameters.append("secret", secret)
// algorithm
val algorithm = algorithm.str
if (algorithm != null) {
parameters.append("algorithm", algorithm)
}
builder()
}.buildString()
}

View File

@ -0,0 +1,40 @@
package com.artemchep.keyguard.common.service.googleauthenticator.util
import arrow.core.Either
import com.artemchep.keyguard.common.service.googleauthenticator.model.OtpAuthMigrationData
import com.artemchep.keyguard.common.service.text.Base64Service
import io.ktor.http.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import org.kodein.di.DirectDI
import org.kodein.di.instance
class OtpMigrationParser(
private val base64Service: Base64Service,
) {
constructor(
directDI: DirectDI,
) : this(
base64Service = directDI.instance(),
)
fun parse(
uri: String,
): Either<Throwable, OtpAuthMigrationData> = Either.catch {
parseData(uri)
}
@OptIn(ExperimentalSerializationApi::class)
private fun parseData(
uri: String,
): OtpAuthMigrationData {
val protoDataBase64 = Url(uri)
.parameters["data"]
requireNotNull(protoDataBase64) {
"URI must have the data parameter!"
}
val protoData = base64Service.decode(protoDataBase64)
return ProtoBuf.decodeFromByteArray<OtpAuthMigrationData>(protoData)
}
}

View File

@ -197,6 +197,7 @@ class TotpServiceImpl(
CryptoHashAlgorithm.SHA_1 -> "HmacSHA1" CryptoHashAlgorithm.SHA_1 -> "HmacSHA1"
CryptoHashAlgorithm.SHA_256 -> "HmacSHA256" CryptoHashAlgorithm.SHA_256 -> "HmacSHA256"
CryptoHashAlgorithm.SHA_512 -> "HmacSHA512" CryptoHashAlgorithm.SHA_512 -> "HmacSHA512"
CryptoHashAlgorithm.MD5 -> "MD5"
} }
val hash = Mac.getInstance(algorithmName).run { val hash = Mac.getInstance(algorithmName).run {
init(SecretKeySpec(key, "RAW")) // The hard-coded value 'RAW' is specified in the RFC init(SecretKeySpec(key, "RAW")) // The hard-coded value 'RAW' is specified in the RFC

View File

@ -455,7 +455,7 @@ private fun TotpTextField(
maxLines = 1, maxLines = 1,
trailing = { trailing = {
ScanQrButton( ScanQrButton(
onValueChange = state.value.onChange, onValueChange = state.onScanned,
) )
}, },
leading = { leading = {

View File

@ -101,6 +101,7 @@ sealed interface AddStateItem {
data class State( data class State(
val copyText: CopyText, val copyText: CopyText,
val value: TextFieldModel2, val value: TextFieldModel2,
val onScanned: ((String) -> Unit)? = null,
val totpToken: TotpToken? = null, val totpToken: TotpToken? = null,
) )
} }

View File

@ -79,6 +79,7 @@ import com.artemchep.keyguard.common.model.firstOrNull
import com.artemchep.keyguard.common.model.title import com.artemchep.keyguard.common.model.title
import com.artemchep.keyguard.common.model.titleH import com.artemchep.keyguard.common.model.titleH
import com.artemchep.keyguard.common.service.clipboard.ClipboardService import com.artemchep.keyguard.common.service.clipboard.ClipboardService
import com.artemchep.keyguard.common.service.googleauthenticator.OtpMigrationService
import com.artemchep.keyguard.common.service.logging.LogRepository import com.artemchep.keyguard.common.service.logging.LogRepository
import com.artemchep.keyguard.common.usecase.AddCipher import com.artemchep.keyguard.common.usecase.AddCipher
import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
@ -198,6 +199,7 @@ fun produceAddScreenState(
getMarkdown = instance(), getMarkdown = instance(),
logRepository = instance(), logRepository = instance(),
clipboardService = instance(), clipboardService = instance(),
otpMigrationService = instance(),
cipherUnsecureUrlCheck = instance(), cipherUnsecureUrlCheck = instance(),
showMessage = instance(), showMessage = instance(),
addCipher = instance(), addCipher = instance(),
@ -227,6 +229,7 @@ fun produceAddScreenState(
getMarkdown: GetMarkdown, getMarkdown: GetMarkdown,
logRepository: LogRepository, logRepository: LogRepository,
clipboardService: ClipboardService, clipboardService: ClipboardService,
otpMigrationService: OtpMigrationService,
cipherUnsecureUrlCheck: CipherUnsecureUrlCheck, cipherUnsecureUrlCheck: CipherUnsecureUrlCheck,
showMessage: ShowMessage, showMessage: ShowMessage,
addCipher: AddCipher, addCipher: AddCipher,
@ -300,6 +303,7 @@ fun produceAddScreenState(
copyText = copyText, copyText = copyText,
getTotpCode = getTotpCode, getTotpCode = getTotpCode,
getGravatarUrl = getGravatarUrl, getGravatarUrl = getGravatarUrl,
otpMigrationService = otpMigrationService,
) )
val cardHolder = produceCardState( val cardHolder = produceCardState(
args = args, args = args,
@ -2160,6 +2164,7 @@ private suspend fun RememberStateFlowScope.produceLoginState(
copyText: CopyText, copyText: CopyText,
getTotpCode: GetTotpCode, getTotpCode: GetTotpCode,
getGravatarUrl: GetGravatarUrl, getGravatarUrl: GetGravatarUrl,
otpMigrationService: OtpMigrationService,
): TmpLogin { ): TmpLogin {
val prefix = "login" val prefix = "login"
@ -2328,6 +2333,13 @@ private suspend fun RememberStateFlowScope.produceLoginState(
AddStateItem.Totp.State( AddStateItem.Totp.State(
value = value, value = value,
copyText = copyText, copyText = copyText,
onScanned = { uri ->
val convertedUri = otpMigrationService.handler(uri)
?.convert(uri)
?.getOrNull()
?: uri
state.value = convertedUri
},
totpToken = totp, totpToken = totp,
) )
} }

View File

@ -16,6 +16,9 @@ import com.artemchep.keyguard.common.service.export.JsonExportService
import com.artemchep.keyguard.common.service.export.impl.JsonExportServiceImpl import com.artemchep.keyguard.common.service.export.impl.JsonExportServiceImpl
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute import com.artemchep.keyguard.common.service.extract.impl.LinkInfoExtractorExecute
import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor import com.artemchep.keyguard.common.service.extract.impl.LinkInfoPlatformExtractor
import com.artemchep.keyguard.common.service.googleauthenticator.OtpMigrationService
import com.artemchep.keyguard.common.service.googleauthenticator.impl.OtpMigrationServiceImpl
import com.artemchep.keyguard.common.service.googleauthenticator.util.OtpMigrationParser
import com.artemchep.keyguard.common.service.gpmprivapps.PrivilegedAppsService import com.artemchep.keyguard.common.service.gpmprivapps.PrivilegedAppsService
import com.artemchep.keyguard.common.service.gpmprivapps.impl.PrivilegedAppsServiceImpl import com.artemchep.keyguard.common.service.gpmprivapps.impl.PrivilegedAppsServiceImpl
import com.artemchep.keyguard.common.service.id.IdRepository import com.artemchep.keyguard.common.service.id.IdRepository
@ -1200,6 +1203,16 @@ fun globalModuleJvm() = DI.Module(
directDI = this, directDI = this,
) )
} }
bindSingleton<OtpMigrationService> {
OtpMigrationServiceImpl(
directDI = this,
)
}
bindSingleton<OtpMigrationParser> {
OtpMigrationParser(
directDI = this,
)
}
bindSingleton<PrivilegedAppsService> { bindSingleton<PrivilegedAppsService> {
PrivilegedAppsServiceImpl( PrivilegedAppsServiceImpl(
directDI = this, directDI = this,

View File

@ -24,6 +24,7 @@ import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.model.MasterSession import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.model.PersistedSession import com.artemchep.keyguard.common.model.PersistedSession
import com.artemchep.keyguard.common.model.ToastMessage import com.artemchep.keyguard.common.model.ToastMessage
import com.artemchep.keyguard.common.service.googleauthenticator.model.OtpAuthMigrationData
import com.artemchep.keyguard.common.service.session.VaultSessionLocker import com.artemchep.keyguard.common.service.session.VaultSessionLocker
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
import com.artemchep.keyguard.common.usecase.GetAccounts import com.artemchep.keyguard.common.usecase.GetAccounts
@ -76,6 +77,9 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.protobuf.schema.ProtoBufSchemaGenerator
import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@ -89,8 +93,11 @@ import org.kodein.di.direct
import org.kodein.di.instance import org.kodein.di.instance
import java.security.Security import java.security.Security
import java.util.Locale import java.util.Locale
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.reflect.KClass import kotlin.reflect.KClass
@OptIn(ExperimentalEncodingApi::class)
fun main() { fun main() {
// Add BouncyCastle as the first security provider // Add BouncyCastle as the first security provider
// to make OkHTTP use its TLS instead of a platform // to make OkHTTP use its TLS instead of a platform
@ -99,6 +106,13 @@ fun main() {
Security.insertProviderAt(BouncyCastleProvider(), 1) Security.insertProviderAt(BouncyCastleProvider(), 1)
Security.insertProviderAt(BouncyCastleJsseProvider(), 2) Security.insertProviderAt(BouncyCastleJsseProvider(), 2)
val g = ProtoBufSchemaGenerator.generateSchemaText(listOf(OtpAuthMigrationData.serializer().descriptor))
println("help!")
println(g)
println("help!")
val f = ProtoBuf.decodeFromByteArray<OtpAuthMigrationData>(Base64.decode("Cj4KCpgu67BGYPX7QLkSH01pY3Jvc29mdDphcnRlbWNoZXBAb3V0bG9vay5jb20aCU1pY3Jvc29mdCABKAEwAgoxChRAhdYwy3lUa0se1A2wvpXq5vZnlxIJYXJ0ZW1jaGVwGghGYWNlYm9vayABKAEwAhABGAEgACjeyrmK/f////8B"))
println(f)
val kamelConfig = KamelConfig { val kamelConfig = KamelConfig {
this.takeFrom(KamelConfig.Default) this.takeFrom(KamelConfig.Default)
mapper(FaviconUrlMapper) mapper(FaviconUrlMapper)

View File

@ -222,6 +222,7 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDatetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDatetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" }
kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinSerialization" }
kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinSerialization" }
ktor-ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }