refactoring signing keys

This commit is contained in:
Sergey Chernov 2023-11-22 11:52:43 +03:00
parent e619b45485
commit 7042e41d70
23 changed files with 221 additions and 76 deletions

View File

@ -82,7 +82,7 @@ kotlin {
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("io.ktor:ktor-client-netty:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
}
}
val jvmTest by getting

View File

@ -1,4 +1,4 @@
package net.sergeych.crypto
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.LibsodiumInitializer
import kotlinx.coroutines.sync.Mutex

View File

@ -1,4 +1,4 @@
package net.sergeych.crypto
package net.sergeych.crypto2
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@ -29,14 +29,14 @@ class SignedBox(
* key, or return unchanged (same) object if it is already signed by this key; you
* _can't assume it always returns a copied object!_
*/
operator fun plus(key: Key.Signing): SignedBox =
operator fun plus(key: SigningKey.Secret): SignedBox =
if (key.verifying in this) this
else SignedBox(message, seals + Seal.create(key, message), false)
/**
* Check that it is signed with a specified key.
*/
operator fun contains(verifyingKey: Key.Verifying): Boolean {
operator fun contains(verifyingKey: SigningKey.Public): Boolean {
return seals.any { it.key == verifyingKey }
}
@ -53,12 +53,12 @@ class SignedBox(
* add seals.
*/
@Serializable
data class Seal(val key: Key.Verifying, val signature: UByteArray) {
data class Seal(val key: SigningKey.Public, val signature: UByteArray) {
fun verify(message: UByteArray): Boolean = key.verify(signature, message)
companion object {
fun create(key: Key.Signing, message: UByteArray): Seal {
fun create(key: SigningKey.Secret, message: UByteArray): Seal {
return Seal(key.verifying, key.sign(message))
}
}
@ -75,7 +75,7 @@ class SignedBox(
* @param keys a list of keys to sign with, should be at least one key.
* @throws IllegalArgumentException if keys are not specified.
*/
operator fun invoke(data: UByteArray, vararg keys: Key.Signing): SignedBox =
operator fun invoke(data: UByteArray, vararg keys: SigningKey.Secret): SignedBox =
SignedBox(data, keys.map { Seal.create(it, data) }, false)
}
}

View File

@ -1,24 +1,24 @@
package net.sergeych.crypto
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.signature.InvalidSignatureException
import com.ionspin.kotlin.crypto.signature.Signature
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.sergeych.crypto.Key.Signing
import net.sergeych.crypto2.SigningKey.Secret
/**
* Keys in general: public, secret and later symmetric too.
* Keys could be compared to each other for equality and used
* as a Map keys (not sure about js).
*
* Use [Signing.pair] to create new keys.
* Use [Secret.pair] to create new keys.
*/
@Serializable
sealed class Key {
sealed class SigningKey {
abstract val packed: UByteArray
override fun equals(other: Any?): Boolean {
return other is Key && other.packed contentEquals packed
return other is SigningKey && other.packed contentEquals packed
}
override fun hashCode(): Int {
@ -31,8 +31,8 @@ sealed class Key {
* Public key to verify signatures only
*/
@Serializable
@SerialName("pvk")
class Verifying(override val packed: UByteArray) : Key() {
@SerialName("p")
class Public(override val packed: UByteArray) : SigningKey() {
/**
* Verify the signature and return true if it is correct.
*/
@ -51,22 +51,22 @@ sealed class Key {
* Secret key to sign only
*/
@Serializable
@SerialName("ssk")
class Signing(override val packed: UByteArray) : Key() {
@SerialName("s")
class Secret(override val packed: UByteArray) : SigningKey() {
val verifying: Verifying by lazy {
Verifying(Signature.ed25519SkToPk(packed))
val verifying: Public by lazy {
Public(Signature.ed25519SkToPk(packed))
}
fun sign(message: UByteArray): UByteArray = Signature.detached(message, packed)
override fun toString(): String = "Sct:${super.toString()}"
companion object {
data class Pair(val signing: Signing, val verifying: Verifying)
data class Pair(val signing: Secret, val aPublic: Public)
fun pair(): Pair {
val p = Signature.keypair()
return Pair(Signing(p.secretKey), Verifying(p.publicKey))
return Pair(Secret(p.secretKey), Public(p.publicKey))
}
}
}

View File

@ -1,4 +1,4 @@
package net.sergeych.crypto
package net.sergeych.crypto2
import net.sergeych.bintools.CRC

View File

@ -1,6 +1,6 @@
@file:Suppress("unused")
package net.sergeych.crypto
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.secretbox.SecretBox
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES

View File

@ -1,4 +1,4 @@
package net.sergeych.crypto
package net.sergeych.crypto2
import net.sergeych.bintools.toDump
import net.sergeych.mp_tools.encodeToBase64Url

View File

@ -6,7 +6,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import net.sergeych.crypto.Key
import net.sergeych.crypto2.SigningKey
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug
@ -21,7 +21,7 @@ import net.sergeych.mp_tools.globalLaunch
*/
class KiloClient<S>(
localInterface: KiloInterface<S>,
secretKey: Key.Signing? = null,
secretKey: SigningKey.Secret? = null,
connectionDataFactory: ConnectionDataFactory<S>,
) : RemoteInterface,
Loggable by LogTag("CLIF") {
@ -80,7 +80,7 @@ class KiloClient<S>(
suspend fun token() = deferredClient.await().token()
/**
* Remote party shared key ([Key.Verifying]]), could be used ti ensure server is what we expected and
* Remote party shared key ([SigningKey.Public]]), could be used ti ensure server is what we expected and
* there is no active MITM attack.
*
* Non-null value means the key was successfully authenticated, null means remote party did not provide
@ -99,7 +99,7 @@ class KiloClient<S>(
}
private var connectionBuilder: (suspend () -> Transport.Device)? = null
var secretIdKey: Key.Signing? = null
var secretIdKey: SigningKey.Secret? = null
/**
* Build local command implementations (remotely callable ones), exception

View File

@ -2,7 +2,7 @@ package net.sergeych.kiloparsec
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
import kotlinx.coroutines.*
import net.sergeych.crypto.Key
import net.sergeych.crypto2.SigningKey
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug
@ -15,17 +15,17 @@ class KiloClientConnection<S>(
private val clientInterface: KiloInterface<S>,
private val device: Transport.Device,
private val session: S,
private val secretIdKey: Key.Signing? = null,
private val secretIdKey: SigningKey.Secret? = null,
) : RemoteInterface, Loggable by LogTag("KPC:${++clientIds}") {
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: Key.Signing? = null)
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: SigningKey.Secret? = null)
: this(localInterface, connection.device, connection.session, secretIdKey)
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
private val deferredParams = CompletableDeferred<KiloParams<S>>()
suspend fun remoteId(): Key.Verifying? = deferredParams.await().remoteIdentity
suspend fun remoteId(): SigningKey.Public? = deferredParams.await().remoteIdentity
/**
* Run the client, blocking until the device is closed, or some critical exception
@ -59,7 +59,7 @@ class KiloClientConnection<S>(
var params = KiloParams(false, transport, sk, session, null, this@KiloClientConnection)
// Check ID if any
serverHe.serverSharedKey?.let { k ->
serverHe.serverPublicKey?.let { k ->
if (serverHe.signature == null)
throw RemoteInterface.SecurityException("missing signature")
if (!k.verify(serverHe.signature, params.token))

View File

@ -9,10 +9,10 @@ import kotlinx.serialization.Serializable
import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
import net.sergeych.crypto.DecryptionFailedException
import net.sergeych.crypto.Key
import net.sergeych.crypto.randomBytes
import net.sergeych.crypto.randomUInt
import net.sergeych.crypto2.DecryptionFailedException
import net.sergeych.crypto2.SigningKey
import net.sergeych.crypto2.randomBytes
import net.sergeych.crypto2.randomUInt
import net.sergeych.tools.ProtectedOp
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
@ -34,7 +34,7 @@ data class KiloParams<S>(
val transport: RemoteInterface,
val sessionKeyPair: KeyExchangeSessionKeyPair,
val scopeSession: S,
val remoteIdentity: Key.Verifying?,
val remoteIdentity: SigningKey.Public?,
val remoteTransport: RemoteInterface
) {
@Serializable
@ -56,7 +56,7 @@ data class KiloParams<S>(
override val session = scopeSession
override val remote: RemoteInterface = remoteTransport
override val sessionToken: UByteArray = token
override val remoteIdentity: Key.Verifying? = this@KiloParams.remoteIdentity
override val remoteIdentity: SigningKey.Public? = this@KiloParams.remoteIdentity
}
}

View File

@ -1,6 +1,6 @@
package net.sergeych.kiloparsec
import net.sergeych.crypto.Key
import net.sergeych.crypto2.SigningKey
/**
* Scope for Kiloparsec client/server commands execution, contain per-connection specific data. The scope
@ -26,8 +26,8 @@ interface KiloScope<S> {
val sessionToken: UByteArray
/**
* If the remote part has provided a secret key, e.g., gave non-null [Key.Signing] on construction,
* the kiloparsec checks it in the MITM-safe way and provides its [Key.Verifying] shared key here.
* If the remote part has provided a secret key, e.g., gave non-null [SigningKey.Secret] on construction,
* the kiloparsec checks it in the MITM-safe way and provides its [SigningKey.Public] shared key here.
* Knowing a remote party shared key, it is possible to be sure that the connection is made directly
* to this party with no middle point intruders.
*
@ -37,6 +37,6 @@ interface KiloScope<S> {
* In spite of the above said, which means, non-null value in this field means the key is authorized, but
* It is up to the caller to ensure it is expected key of the remote party.
*/
val remoteIdentity: Key.Verifying?
val remoteIdentity: SigningKey.Public?
}

View File

@ -3,7 +3,7 @@ package net.sergeych.kiloparsec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import net.sergeych.crypto.Key
import net.sergeych.crypto2.SigningKey
import net.sergeych.kiloparsec.adapter.InetTransportDevice
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.debug
@ -17,7 +17,7 @@ private val instances = AtomicCounter()
class KiloServer<S>(
private val clientInterface: KiloInterface<S>,
private val connections: Flow<InetTransportDevice>,
private val serverSigningKey: Key.Signing? = null,
private val serverSecretKey: SigningKey.Secret? = null,
private val sessionBuilder: ()->S,
): LogTag("KS:${instances.incrementAndGet()}") {
@ -26,7 +26,7 @@ class KiloServer<S>(
launch {
try {
info { "connected ${device}" }
KiloServerConnection(clientInterface, device, sessionBuilder(), serverSigningKey)
KiloServerConnection(clientInterface, device, sessionBuilder(), serverSecretKey)
.apply { debug { "server connection is ready" }}
.run()
}

View File

@ -2,7 +2,7 @@ package net.sergeych.kiloparsec
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
import kotlinx.coroutines.CompletableDeferred
import net.sergeych.crypto.Key
import net.sergeych.crypto2.SigningKey
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug
@ -23,15 +23,15 @@ class KiloServerConnection<S>(
private val clientInterface: KiloInterface<S>,
private val device: Transport.Device,
private val session: S,
private val serverSigningKey: Key.Signing? = null
private val serverSigningKey: SigningKey.Secret? = null
) : RemoteInterface, Loggable by LogTag("SRV${++serverIds}") {
/**
* Shortcut to construct with [KiloConnectionData] intance
*/
@Suppress("unused")
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSigningKey: Key.Signing? = null)
: this(localInterface, connection.device, connection.session, serverSigningKey)
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSecretKey: SigningKey.Secret? = null)
: this(localInterface, connection.device, connection.session, serverSecretKey)
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
@ -60,13 +60,13 @@ class KiloServerConnection<S>(
this@KiloServerConnection
)
var verifying: Key.Verifying? = null
var public: SigningKey.Public? = null
var signature: UByteArray? = null
if( serverSigningKey != null ) {
verifying = serverSigningKey.verifying
public = serverSigningKey.verifying
signature = serverSigningKey.sign(params!!.token)
}
Handshake(1u, pair.publicKey, verifying, signature)
Handshake(1u, pair.publicKey, public, signature)
}
on(L0ClientId) {
var p = params ?: throw RemoteInterface.ClosedException("wrong handshake sequence")

View File

@ -13,7 +13,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.serializer
import net.sergeych.crypto.toDump
import net.sergeych.crypto2.toDump
import net.sergeych.kiloparsec.Transport.Device
import net.sergeych.mp_logger.*
import net.sergeych.utools.pack

View File

@ -0,0 +1,76 @@
package net.sergeych.kiloparsec.adapter
import io.ktor.client.*
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import net.sergeych.crypto2.SigningKey
import net.sergeych.kiloparsec.KiloClient
import net.sergeych.kiloparsec.KiloConnectionData
import net.sergeych.kiloparsec.KiloInterface
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.exception
import net.sergeych.mp_logger.info
import net.sergeych.mp_logger.warning
import net.sergeych.mp_tools.globalLaunch
import net.sergeych.tools.AtomicCounter
private val counter = AtomicCounter()
fun <S>websocketClient(
path: String,
clientInterface: KiloInterface<S> = KiloInterface(),
client: HttpClient = HttpClient { install(WebSockets) },
secretKey: SigningKey.Secret? = null,
sessionMaker: () -> S,
): KiloClient<S> {
var u = Url(path)
if (u.encodedPath.length <= 1)
u = URLBuilder(u).apply {
encodedPath = "/kp"
}.build()
return KiloClient(clientInterface, secretKey) {
val input = Channel<UByteArray>()
val output = Channel<UByteArray>()
globalLaunch {
val log = LogTag("KC:${counter.incrementAndGet()}:$u")
client.webSocket({
url.protocol = u.protocol
url.host = u.host
url.encodedPath = u.encodedPath
url.parameters.appendAll(u.parameters)
}) {
try {
log.info { "connected to server" }
launch {
for (block in output) {
send(block.toByteArray())
}
log.info { "input is closed, closing the websocket" }
cancel()
}
for (f in incoming) {
if (f is Frame.Binary) {
input.send(f.readBytes().toUByteArray())
} else {
log.warning { "ignoring unexpected frame of type ${f.frameType}" }
}
}
}
catch(_:CancellationException) {
}
catch(t: Throwable) {
log.exception { "unexpected error" to t }
}
log.info { "closing connection" }
}
}
val device = ProxyDevice(input,output) { input.close() }
KiloConnectionData(device, sessionMaker())
}
}

View File

@ -1,16 +1,16 @@
package net.sergeych.kiloparsec
import kotlinx.serialization.Serializable
import net.sergeych.crypto.Key
import net.sergeych.crypto2.SigningKey
// L0 commands - key exchange and check:
@Serializable
data class Handshake(val version: UInt, val publicKey: UByteArray,
val serverSharedKey: Key.Verifying? = null,
val serverPublicKey: SigningKey.Public? = null,
val signature: UByteArray? = null)
@Serializable
data class ClientIdentity(val clientIdKey: Key.Verifying?, val signature: UByteArray?)
data class ClientIdentity(val clientIdKey: SigningKey.Public?, val signature: UByteArray?)
// Level 0 command: request key exchange
internal val L0Request by command<Handshake, Handshake>()

View File

@ -2,7 +2,7 @@ import com.ionspin.kotlin.crypto.secretbox.SecretBox
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto.*
import net.sergeych.crypto2.*
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
import kotlin.test.*
@ -11,11 +11,11 @@ class KeysTest {
@Test
fun testCreationAndMap() = runTest {
initCrypto()
val (stk,pbk) = Key.Signing.pair()
val (stk,pbk) = SigningKey.Secret.pair()
val x = mapOf( stk to "STK!", pbk to "PBK!")
assertEquals("STK!", x[stk])
val s1 = Key.Signing(stk.packed)
val s1 = SigningKey.Secret(stk.packed)
assertEquals(stk, s1)
assertEquals("STK!", x[s1])
assertEquals("PBK!", x[pbk])
@ -27,8 +27,8 @@ class KeysTest {
data1[0] = 0x01u
assertFalse(s.verify(data1))
val p2 = Key.Signing.pair()
val p3 = Key.Signing.pair()
val p2 = SigningKey.Secret.pair()
val p3 = SigningKey.Secret.pair()
val ms = SignedBox(data, s1) + p2.signing
@ -36,8 +36,8 @@ class KeysTest {
val ms1 = unpack<SignedBox>(pack(ms))
assertContentEquals(data, ms1.message)
assertTrue(pbk in ms1)
assertTrue(p2.verifying in ms1)
assertTrue(p3.verifying !in ms1)
assertTrue(p2.aPublic in ms1)
assertTrue(p3.aPublic !in ms1)
assertThrows<IllegalSignatureException> {
unpack<SignedBox>(pack(ms).also { it[3] = 1u })

View File

@ -1,6 +1,6 @@
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import net.sergeych.crypto.initCrypto
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.Transport
import net.sergeych.utools.nowToSeconds
import net.sergeych.utools.pack

View File

@ -1,7 +1,7 @@
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto.createContrail
import net.sergeych.crypto.initCrypto
import net.sergeych.crypto.isValidContrail
import net.sergeych.crypto2.createContrail
import net.sergeych.crypto2.initCrypto
import net.sergeych.crypto2.isValidContrail
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

View File

@ -4,8 +4,8 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto.Key
import net.sergeych.crypto.initCrypto
import net.sergeych.crypto2.SigningKey
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.*
import net.sergeych.mp_logger.Log
import kotlin.test.*
@ -162,7 +162,7 @@ class TransportTest {
val cmdPing by command<String, String>()
val cmdPush by command<String, String>()
val cmdGetToken by command<Unit, UByteArray>()
val cmdGetClientId by command<Unit, Key.Verifying?>()
val cmdGetClientId by command<Unit, SigningKey.Public?>()
val cmdChainCallServer1 by command<String, String>()
val cmdChainCallClient1 by command<String, String>()
val cmdChainCallServer2 by command<String, String>()
@ -171,8 +171,8 @@ class TransportTest {
// Log.defaultLevel = Log.Level.DEBUG
val (d1, d2) = createTestDevice()
val serverId = Key.Signing.pair()
val clientId = Key.Signing.pair()
val serverId = SigningKey.Secret.pair()
val clientId = SigningKey.Secret.pair()
val serverInterface = KiloInterface<String>().apply {
on(cmdPing) {

View File

@ -0,0 +1,64 @@
package net.sergeych.kiloparsec.adapter
import io.ktor.server.application.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import net.sergeych.crypto2.SigningKey
import net.sergeych.kiloparsec.KiloInterface
import net.sergeych.kiloparsec.KiloServerConnection
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.debug
import net.sergeych.mp_logger.warning
import net.sergeych.tools.AtomicCounter
import java.time.Duration
fun <S> Application.setupWebsocketServer(
localInterface: KiloInterface<S>,
path: String = "/kp",
serverKey: SigningKey.Secret? = null,
createSession: () -> S,
) {
install(Routing)
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE
masking = false
}
val counter = AtomicCounter()
routing {
webSocket(path) {
val log = LogTag("KWS:${counter.incrementAndGet()}")
log.debug { "opening the connection" }
val input = Channel<UByteArray>(256)
val output = Channel<UByteArray>(256)
launch {
while (isActive) {
send(output.receive().toByteArray())
}
}
val server = KiloServerConnection(
localInterface,
ProxyDevice(input, output) { input.close() },
createSession(),
serverKey
)
launch { server.run() }
for( f in incoming) {
if (f is Frame.Binary)
input.send(f.readBytes().toUByteArray())
else
log.warning { "unknown frame type ${f.frameType}, ignoring" }
}
log.debug { "closing the server" }
cancel()
}
}
}

View File

@ -4,8 +4,8 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.flow.MutableStateFlow
import net.sergeych.crypto.encodeVarUnsigned
import net.sergeych.crypto.readVarUnsigned
import net.sergeych.crypto2.encodeVarUnsigned
import net.sergeych.crypto2.readVarUnsigned
import net.sergeych.kiloparsec.Transport
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.warning

View File

@ -1,7 +1,7 @@
package net.sergeych.kiloparsec
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto.initCrypto
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.adapter.acceptTcpDevice
import net.sergeych.kiloparsec.adapter.connectTcpDevice
import net.sergeych.mp_logger.Log
@ -49,4 +49,9 @@ class ClientTest {
// client.close()
// Todo
}
@Test
fun webSocketTest() = runTest {
// val server = setupWebsoketServer()
}
}