Add authenticated protocol 2 and TypeScript WASM client
This commit is contained in:
1 parent
88ceeb2ae9
commit
237d404bcd
52 files changed
+3249
-150
No files matched your search
@@ -19,6 +19,7 @@ import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private var clientIds = 0
|
||||
|
||||
@@ -60,7 +61,7 @@ class KiloClientConnection<S>(
|
||||
|
||||
// client transport has no dedicated commands (unlike the server's),
|
||||
// it is a calling party:
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams)
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams, allowHandshakeCallbacks = true)
|
||||
val transport = Transport(device, l0Interface, Unit)
|
||||
|
||||
job = launch { transport.run() }
|
||||
@@ -69,29 +70,27 @@ class KiloClientConnection<S>(
|
||||
val pair = deferredKeyPair.await()
|
||||
debug { "keypair ready (1)" }
|
||||
|
||||
val serverHe = transport.call(L0Request, Handshake(1u, pair.publicKey))
|
||||
val serverHe = transport.call(L0Request, Handshake(KILO_PROTOCOL_VERSION, pair.publicKey))
|
||||
|
||||
debug { "got server HE (2)" }
|
||||
if (serverHe.version != KILO_PROTOCOL_VERSION)
|
||||
throw RemoteInterface.SecurityException("unsupported Kiloparsec protocol")
|
||||
val sk = pair.clientSessionKey(serverHe.publicKey)
|
||||
var params = KiloParams(false, transport, sk, session, null, this@KiloClientConnection)
|
||||
|
||||
// Check ID if any
|
||||
serverHe.signature?.let { s ->
|
||||
if (!s.isValid(params.token))
|
||||
if (!s.isValid(serverAuthentication(params.token)))
|
||||
throw RemoteInterface.SecurityException("wrong signature")
|
||||
params = params.copy(remoteIdentity = s.publicKey)
|
||||
params = params.authenticated(s.publicKey)
|
||||
}
|
||||
|
||||
transport.call(
|
||||
L0ClientId, params.encrypt(
|
||||
pack(
|
||||
ClientIdentity(
|
||||
secretIdKey?.verifyingKey,
|
||||
secretIdKey?.sign(params.token)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val identity = pack(ClientIdentity(secretIdKey?.verifyingKey,
|
||||
secretIdKey?.sign(clientAuthentication(params.token))))
|
||||
val finished = params.decryptMessage(transport.call(L0ClientId, params.encrypt(identity)))
|
||||
if (finished.kind != ProtectedKind.SERVER_FINISHED || finished.correlation != 0uL ||
|
||||
finished.priority != 0 || !finished.payload.contentEquals(identity))
|
||||
throw RemoteInterface.SecurityException("invalid server finished")
|
||||
deferredParams.complete(params)
|
||||
kiloRemoteInterface.complete(
|
||||
KiloRemoteInterface(deferredParams, clientInterface)
|
||||
|
||||
@@ -1,56 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private val idCounter = AtomicCounter(0)
|
||||
|
||||
/**
|
||||
* This class is not normally used directly. This is a local interface that supports
|
||||
* secure transport command layer (encrypted calls/results) to work with [KiloRemoteInterface].
|
||||
*
|
||||
* It is recommended to use [KiloClientConnection] and [KiloServerConnection] instead.
|
||||
*/
|
||||
/** Authenticates dispatch metadata before looking up or deserializing an application command. */
|
||||
internal class KiloL0Interface<T>(
|
||||
private val clientInterface: LocalInterface<KiloScope<T>>,
|
||||
private val deferredParams: CompletableDeferred<KiloParams<T>>,
|
||||
private val allowHandshakeCallbacks: Boolean = false,
|
||||
) : LocalInterface<Unit>() {
|
||||
|
||||
override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
|
||||
init { addErrorProvider(clientInterface) }
|
||||
// Plaintext transport errors must never use application exception constructors.
|
||||
override fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? = null
|
||||
|
||||
init {
|
||||
// local interface uses the same session as a client:
|
||||
addErrorProvider(clientInterface)
|
||||
|
||||
on(L0Call) {
|
||||
val params = deferredParams.await()
|
||||
val call = Command.unpackCall(params.decrypt(it))
|
||||
// tricky part: we need to encrypt a result or error: we can't allow default
|
||||
// error transport (as it is not encrypted), so we re-use transport blocks here:
|
||||
val result: Transport.Block = try {
|
||||
Transport.Block.Response(
|
||||
0u,
|
||||
if (call.name == connectionPing.name) connectionPing.exec(call.serializedArgs) { it }
|
||||
else clientInterface.execute(params.scope, call.name, call.serializedArgs)
|
||||
)
|
||||
} catch(t: BreakConnectionException) {
|
||||
throw t
|
||||
}
|
||||
catch (t: Throwable) {
|
||||
clientInterface.encodeError(0u, t)
|
||||
}
|
||||
params.encrypt(pack(result))
|
||||
}
|
||||
override suspend fun executeTransportBlock(scope: Unit, block: Transport.Block.Call): UByteArray {
|
||||
if (block.name != L0Call.name) return super.executeTransportBlock(scope, block)
|
||||
if (!allowHandshakeCallbacks && !deferredParams.isCompleted)
|
||||
throw RemoteInterface.SecurityException("application call before authentication")
|
||||
val params = deferredParams.await()
|
||||
val frame: ProtectedMessage = try { params.decryptMessage(unpack(block.packedArgs)) }
|
||||
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid authenticated call") }
|
||||
val expected = if (block.id == 0u) ProtectedKind.PUSH else ProtectedKind.CALL
|
||||
if (frame.kind != expected || frame.correlation != block.id.toULong() || frame.priority != block.priority)
|
||||
throw RemoteInterface.SecurityException("authenticated routing mismatch")
|
||||
val call = try { Command.unpackCall(frame.payload) }
|
||||
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid command encoding") }
|
||||
val result: Transport.Block = try {
|
||||
Transport.Block.Response(0u,
|
||||
if (call.name == connectionPing.name) connectionPing.exec(call.serializedArgs) { it }
|
||||
else clientInterface.execute(params.scope, call.name, call.serializedArgs))
|
||||
} catch (e: BreakConnectionException) { throw e }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Throwable) { clientInterface.encodeError(0u, e) }
|
||||
// Push has no reply; its authenticated kind cannot be changed by rewriting id=0.
|
||||
if (frame.kind == ProtectedKind.PUSH) return pack(Unit)
|
||||
val kind = if (result is Transport.Block.Error) ProtectedKind.ERROR else ProtectedKind.RESPONSE
|
||||
return pack(params.encryptMessage(ProtectedMessage(kind, frame.correlation, frame.priority, pack(result))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ data class KiloParams<S>(
|
||||
)
|
||||
|
||||
private var nonce = 0UL
|
||||
private var highestReceived: ULong? = null
|
||||
private val received = mutableSetOf<ULong>()
|
||||
|
||||
val scope: KiloScope<S> by lazy {
|
||||
object : KiloScope<S> {
|
||||
@@ -72,18 +74,43 @@ data class KiloParams<S>(
|
||||
else
|
||||
null
|
||||
|
||||
val n = protectedOp.invoke { nonce++ }
|
||||
val n = protectedOp.invoke {
|
||||
if (nonce == ULong.MAX_VALUE) throw RemoteInterface.SecurityException("session nonce exhausted")
|
||||
nonce++
|
||||
}
|
||||
|
||||
return pack(
|
||||
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
|
||||
)
|
||||
}
|
||||
|
||||
// fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray()
|
||||
fun decrypt(encryptedMessage: UByteArray): UByteArray =
|
||||
protectDecryption {
|
||||
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
|
||||
sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
|
||||
/** Authenticate and consume one incoming nonce, allowing bounded out-of-order delivery. */
|
||||
fun decrypt(encryptedMessage: UByteArray): UByteArray = protectDecryption {
|
||||
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
|
||||
protectedOp.invoke {
|
||||
val high = highestReceived
|
||||
if (p.nonce in received || (high != null && high >= 4096uL && p.nonce <= high - 4096uL))
|
||||
throw RemoteInterface.SecurityException("replayed or stale encrypted packet")
|
||||
val clear = sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
|
||||
received.add(p.nonce)
|
||||
if (high == null || p.nonce > high) {
|
||||
highestReceived = p.nonce
|
||||
if (p.nonce >= 4096uL) received.removeAll { it <= p.nonce - 4096uL }
|
||||
}
|
||||
clear
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
/** Identity completion must not reset counters/replay history already used by the handshake. */
|
||||
internal fun authenticated(identity: VerifyingPublicKey): KiloParams<S> = protectedOp.invoke {
|
||||
copy(remoteIdentity = identity).also {
|
||||
it.nonce = nonce
|
||||
it.highestReceived = highestReceived
|
||||
it.received.addAll(received)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun encryptMessage(message: ProtectedMessage): UByteArray = encrypt(pack(message))
|
||||
internal fun decryptMessage(message: UByteArray): ProtectedMessage =
|
||||
net.sergeych.utools.unpack(decrypt(message))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bipack.Unsigned
|
||||
|
||||
/** Protocol 2 never falls back to the unauthenticated v1 RPC envelope. */
|
||||
internal const val KILO_PROTOCOL_VERSION: UInt = 2u
|
||||
|
||||
@Serializable
|
||||
internal enum class ProtectedKind { CALL, PUSH, RESPONSE, ERROR, SERVER_FINISHED }
|
||||
|
||||
/** All dispatch/correlation metadata is authenticated by the session cipher. */
|
||||
@Serializable
|
||||
internal data class ProtectedMessage(
|
||||
val kind: ProtectedKind,
|
||||
@Unsigned val correlation: ULong,
|
||||
val priority: Int,
|
||||
val payload: UByteArray,
|
||||
)
|
||||
|
||||
internal fun serverAuthentication(token: UByteArray): UByteArray =
|
||||
"kiloparsec/v2/server\u0000".encodeToByteArray().toUByteArray() + token
|
||||
internal fun clientAuthentication(token: UByteArray): UByteArray =
|
||||
"kiloparsec/v2/client\u0000".encodeToByteArray().toUByteArray() + token
|
||||
@@ -1,58 +1,66 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private var L1IdCounter = 0
|
||||
|
||||
// Same encrypted L0Call wire command, sent as an existing id=0 notification.
|
||||
// The result serializer is unused: neither endpoint sends/decodes a reply to a push.
|
||||
private val l0Notification = Command(L0Call.name, L0Call.argsSerializer, Unit.serializer())
|
||||
|
||||
/**
|
||||
* Represents a remote interface for interacting with a Kiloparsec service.
|
||||
*
|
||||
* @param S the scope type of the Kiloparsec service which will be used to call local functions remotely
|
||||
* @property deferredParams a [CompletableDeferred] that resolves to the parameters required for making remote calls
|
||||
* @property clientInterface a [LocalInterface] used to communicate with the local client
|
||||
*/
|
||||
/** Full-duplex remote interface with authenticated request/result correlation. */
|
||||
class KiloRemoteInterface<S>(
|
||||
private val deferredParams: CompletableDeferred<KiloParams<S>>,
|
||||
private val clientInterface: LocalInterface<KiloScope<S>>,
|
||||
) : RemoteInterface, Loggable by LogTag("L1TR:${++L1IdCounter}") {
|
||||
|
||||
) : RemoteInterface {
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
|
||||
val params = deferredParams.await()
|
||||
val block: Transport.Block = unpack(
|
||||
params.decrypt(
|
||||
params.transport.call(L0Call, params.encrypt(cmd.packCall(args)))
|
||||
)
|
||||
)
|
||||
return when (block) {
|
||||
is Transport.Block.Response -> {
|
||||
cmd.unpackResult(block.packedResult)
|
||||
val transport = params.transport as Transport<*>
|
||||
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
|
||||
var requestId = 0u
|
||||
val call = cmd.packCall(args)
|
||||
val encoded = try {
|
||||
transport.callWithId(L0Call) { id ->
|
||||
requestId = id
|
||||
params.encryptMessage(ProtectedMessage(ProtectedKind.CALL, id.toULong(), priority, call))
|
||||
}
|
||||
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: RemoteInterface.ClosedException) { throw e }
|
||||
catch (e: Throwable) {
|
||||
// An outer plaintext error is not an authenticated business result.
|
||||
transport.abortSecurity()
|
||||
throw RemoteInterface.SecurityException("unauthenticated outer error")
|
||||
}
|
||||
val frame: ProtectedMessage
|
||||
val block: Transport.Block
|
||||
try {
|
||||
frame = params.decryptMessage(encoded)
|
||||
if (frame.correlation != requestId.toULong() || frame.priority != priority)
|
||||
throw RemoteInterface.SecurityException("authenticated response routing mismatch")
|
||||
block = unpack(frame.payload)
|
||||
when (block) {
|
||||
is Transport.Block.Response -> if (frame.kind != ProtectedKind.RESPONSE || block.forId != 0u)
|
||||
throw RemoteInterface.SecurityException("invalid response kind")
|
||||
is Transport.Block.Error -> if (frame.kind != ProtectedKind.ERROR || block.forId != 0u)
|
||||
throw RemoteInterface.SecurityException("invalid error kind")
|
||||
else -> throw RemoteInterface.SecurityException("unexpected response frame")
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
transport.abortSecurity()
|
||||
throw RemoteInterface.SecurityException("invalid authenticated response")
|
||||
}
|
||||
return when (block) {
|
||||
is Transport.Block.Response -> cmd.unpackResult(block.packedResult)
|
||||
is Transport.Block.Error -> clientInterface.decodeAndThrow(block)
|
||||
else -> throw RemoteInterface.Exception("unexpected block type: $block")
|
||||
else -> error("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
val params = deferredParams.await()
|
||||
params.transport.push(l0Notification, params.encrypt(cmd.packCall(args)))
|
||||
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
|
||||
params.transport.push(l0Notification, params.encryptMessage(
|
||||
ProtectedMessage(ProtectedKind.PUSH, 0uL, priority, cmd.packCall(args))))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
@@ -57,38 +59,39 @@ class KiloServerConnection<S>(
|
||||
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply {
|
||||
var params: KiloParams<S>? = null
|
||||
on(L0Request) {
|
||||
val sk = pair.serverSessionKey(it.publicKey)
|
||||
val handshakeLock = Mutex()
|
||||
var established = false
|
||||
val pair = SafeKeyExchange()
|
||||
on(L0Request) { request -> handshakeLock.withLock {
|
||||
if (request.version != KILO_PROTOCOL_VERSION || params != null || established)
|
||||
throw RemoteInterface.SecurityException("unsupported or repeated handshake")
|
||||
val sk = pair.serverSessionKey(request.publicKey)
|
||||
params = KiloParams(true, deferredTransport.await(), sk, session, null, this@KiloServerConnection)
|
||||
Handshake(KILO_PROTOCOL_VERSION, pair.publicKey,
|
||||
serverSigningKey?.seal(serverAuthentication(params!!.token)))
|
||||
} }
|
||||
|
||||
params = KiloParams(
|
||||
true,
|
||||
deferredTransport.await(),
|
||||
sk,
|
||||
session,
|
||||
null,
|
||||
this@KiloServerConnection
|
||||
)
|
||||
Handshake(1u, pair.publicKey, serverSigningKey?.seal(params!!.token))
|
||||
}
|
||||
|
||||
on(L0ClientId) {
|
||||
var p = params ?: throw RemoteInterface.ClosedException("wrong handshake sequence")
|
||||
val ci = unpack<ClientIdentity>(p.decrypt(it))
|
||||
if( ci.clientIdKey != null ) {
|
||||
if( ci.signature == null )
|
||||
throw RemoteInterface.SecurityException("missing signature")
|
||||
if( !ci.clientIdKey.verify(ci.signature, params!!.token))
|
||||
on(L0ClientId) { encoded -> handshakeLock.withLock {
|
||||
if (established) throw RemoteInterface.SecurityException("repeated client identity")
|
||||
var p = params ?: throw RemoteInterface.SecurityException("wrong handshake sequence")
|
||||
val identity = p.decrypt(encoded)
|
||||
val ci = unpack<ClientIdentity>(identity)
|
||||
if ((ci.clientIdKey == null) != (ci.signature == null))
|
||||
throw RemoteInterface.SecurityException("incomplete client identity")
|
||||
if (ci.clientIdKey != null) {
|
||||
if (!ci.clientIdKey.verify(ci.signature!!, clientAuthentication(p.token)))
|
||||
throw RemoteInterface.SecurityException("wrong signature")
|
||||
p = p.copy(remoteIdentity = ci.clientIdKey)
|
||||
p = p.authenticated(ci.clientIdKey)
|
||||
params = p
|
||||
}
|
||||
established = true
|
||||
deferredParams.complete(p)
|
||||
kiloRemoteInterface.complete(
|
||||
KiloRemoteInterface(deferredParams, clientInterface)
|
||||
)
|
||||
kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams, clientInterface))
|
||||
connectedScope = p.scope
|
||||
clientInterface.onConnectHandlers.invokeAll(connectedScope)
|
||||
}
|
||||
p.encryptMessage(ProtectedMessage(ProtectedKind.SERVER_FINISHED, 0uL, 0, identity))
|
||||
} }
|
||||
|
||||
}
|
||||
|
||||
val transport = Transport(device, l0Interface, Unit)
|
||||
@@ -103,9 +106,6 @@ class KiloServerConnection<S>(
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val pair = SafeKeyExchange()
|
||||
}
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
|
||||
return kiloRemoteInterface.await().call(cmd, args)
|
||||
|
||||
@@ -82,6 +82,10 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
|
||||
.invoke(scope, packedArgs)
|
||||
|
||||
|
||||
/** Transport hook keeps outer routing metadata available to the authenticated layer. */
|
||||
internal open suspend fun executeTransportBlock(scope: S, block: Transport.Block.Call): UByteArray =
|
||||
execute(scope, block.name, block.packedArgs)
|
||||
|
||||
private val errorByClass = mutableMapOf<KClass<*>, String>()
|
||||
private val errorBuilder = mutableMapOf<String, (String, UByteArray?) -> Throwable>()
|
||||
|
||||
|
||||
@@ -126,7 +126,11 @@ class Transport<S>(
|
||||
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
|
||||
* @throws RemoteInterface.ClosedException
|
||||
*/
|
||||
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray {
|
||||
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray =
|
||||
sendBoundCallBlock(name) { packedArgs }
|
||||
|
||||
/** Allocate the outer ID before sealing its authenticated inner counterpart. */
|
||||
private suspend fun sendBoundCallBlock(name: String, packedArgs: (UInt) -> UByteArray): UByteArray {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
|
||||
val b: Block.Call
|
||||
@@ -136,7 +140,9 @@ class Transport<S>(
|
||||
access.withLock {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
// the order is important: first id in use MUST BE >= 1, not zero:
|
||||
b = Block.Call(++lastId, name, packedArgs).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
|
||||
if (lastId == UInt.MAX_VALUE) throw RemoteInterface.ClosedException("RPC ID space exhausted")
|
||||
val id = ++lastId
|
||||
b = Block.Call(id, name, packedArgs(id)).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
|
||||
calls[b.id] = deferred
|
||||
}
|
||||
|
||||
@@ -172,6 +178,17 @@ class Transport<S>(
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun callWithId(
|
||||
cmd: Command<UByteArray, UByteArray>,
|
||||
build: (UInt) -> UByteArray,
|
||||
): UByteArray = unpack(cmd.resultSerializer,
|
||||
sendBoundCallBlock(cmd.name) { id -> pack(cmd.argsSerializer, build(id)) })
|
||||
|
||||
internal suspend fun abortSecurity() {
|
||||
isClosed = true
|
||||
device.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the remote procedure with specified args and return its result
|
||||
*/
|
||||
@@ -217,12 +234,12 @@ class Transport<S>(
|
||||
try {
|
||||
if (b.id == 0u)
|
||||
// Command does not waits return
|
||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
||||
localInterface.executeTransportBlock(commandContext, b)
|
||||
else
|
||||
send(
|
||||
Block.Response(
|
||||
b.id,
|
||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
||||
localInterface.executeTransportBlock(commandContext, b)
|
||||
).also { it.priority = b.priority }
|
||||
)
|
||||
} catch (x: LocalInterface.BreakConnectionException) {
|
||||
@@ -231,6 +248,8 @@ class Transport<S>(
|
||||
isClosed = true
|
||||
if (x.flushSendQueue) device.flush()
|
||||
device.close()
|
||||
} catch (x: CancellationException) {
|
||||
throw x
|
||||
} catch (x: RemoteInterface.RemoteException) {
|
||||
send(Block.Error(b.id, x.code, x.text, x.extra).also { it.priority = b.priority })
|
||||
} catch (t: Throwable) {
|
||||
@@ -272,6 +291,8 @@ class Transport<S>(
|
||||
c.completeExceptionally(RemoteInterface.ClosedException())
|
||||
calls.clear()
|
||||
debug { "calls clear has been called" }
|
||||
// Closing a device also releases dispatch children waiting on unfinished handshakes.
|
||||
currentCoroutineContext().cancelChildren()
|
||||
}
|
||||
debug { "no more active: $isActive / ${calls.size}" }
|
||||
}
|
||||
|
||||
@@ -25,5 +25,5 @@ data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: U
|
||||
|
||||
// Level 0 command: request key exchange
|
||||
internal val L0Request by command<Handshake, Handshake>()
|
||||
internal val L0ClientId by command<UByteArray, Unit>()
|
||||
internal val L0ClientId by command<UByteArray, UByteArray>()
|
||||
internal val L0Call by command<UByteArray,UByteArray>()
|
||||
@@ -0,0 +1,186 @@
|
||||
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.*
|
||||
import net.sergeych.crypto2.SigningSecretKey
|
||||
import net.sergeych.crypto2.initCrypto
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
import kotlin.test.*
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** Wire adversary regressions: mutations occur after serialization and outside the encryption layer. */
|
||||
class AuthenticatedEnvelopeTest {
|
||||
private fun intercept(device: Transport.Device,
|
||||
mutate: suspend (Transport.Block, suspend (Transport.Block) -> Unit) -> Unit) =
|
||||
object : Transport.Device by device {
|
||||
override suspend fun send(block: UByteArray) {
|
||||
mutate(unpack(block)) { replacement -> device.send(pack<Transport.Block>(replacement)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun authenticatedDuplexAndSessionArePreserved() = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val echo by command<String, String>()
|
||||
val callback by command<String, String>()
|
||||
val (a, b) = createTestDevice()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val serverKey = SigningSecretKey.new()
|
||||
val clientKey = SigningSecretKey.new()
|
||||
val server = KiloServerConnection(KiloInterface<String>().apply {
|
||||
on(echo) {
|
||||
assertEquals("server-session", session)
|
||||
assertEquals(clientKey.verifyingKey, remoteIdentity)
|
||||
remote.call(callback, it)
|
||||
}
|
||||
}, b, "server-session", serverKey)
|
||||
val client = KiloClientConnection(KiloInterface<String>().apply {
|
||||
on(callback) {
|
||||
assertEquals("client-session", session)
|
||||
assertEquals(serverKey.verifyingKey, remoteIdentity)
|
||||
"callback:$it"
|
||||
}
|
||||
}, a, "client-session", clientKey)
|
||||
val jobs = listOf(launch { server.run() }, launch { client.run { if (it) ready.complete(Unit) } })
|
||||
try {
|
||||
ready.await()
|
||||
assertEquals("callback:duplex", client.call(echo, "duplex"))
|
||||
} finally { jobs.forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
|
||||
@Test fun swappingOuterResponseIdsCannotSwapConcurrentResults() = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val echo by command<String, String>()
|
||||
val (a, b) = createTestDevice()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
var armed = false
|
||||
val held = mutableListOf<Transport.Block.Response>()
|
||||
val malicious = intercept(b) { block, send ->
|
||||
if (armed && block is Transport.Block.Response) {
|
||||
held += block
|
||||
if (held.size == 2) {
|
||||
send(held[0].copy(forId = held[1].forId).also { it.priority = held[0].priority })
|
||||
send(held[1].copy(forId = held[0].forId).also { it.priority = held[1].priority })
|
||||
}
|
||||
} else send(block)
|
||||
}
|
||||
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new())
|
||||
val client = KiloClientConnection(KiloInterface<Unit>(), a, Unit)
|
||||
val serverJob = launch { runCatching { server.run() } }
|
||||
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
|
||||
try {
|
||||
ready.await(); armed = true
|
||||
val first = async { runCatching { client.call(echo, "first") } }
|
||||
val second = async { runCatching { client.call(echo, "second") } }
|
||||
assertTrue(withTimeout(2000) { first.await() }.isFailure)
|
||||
assertTrue(withTimeout(2000) { second.await() }.isFailure)
|
||||
assertEquals(2, held.size, "Both authenticated responses must reach the mutation point")
|
||||
withTimeout(2000) { clientJob.join() }
|
||||
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
|
||||
@Test fun changingOuterCallIdToPushCannotDispatch() = rejectTamperedCall(changeId = true)
|
||||
@Test fun changingOuterPriorityCannotDispatch() = rejectTamperedCall(changeId = false)
|
||||
|
||||
private fun rejectTamperedCall(changeId: Boolean) = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val mutate by command<Int, Int>()
|
||||
val (a, b) = createTestDevice()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
var executions = 0
|
||||
var attacked = false
|
||||
val malicious = intercept(a) { block, send ->
|
||||
if (block is Transport.Block.Call && block.name == "L0Call") {
|
||||
attacked = true
|
||||
send(block.copy(id = if (changeId) 0u else block.id).also {
|
||||
it.priority = if (changeId) block.priority else block.priority + 1
|
||||
})
|
||||
} else send(block)
|
||||
}
|
||||
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(mutate) { executions++; it } }, b, Unit, SigningSecretKey.new())
|
||||
val client = KiloClientConnection(KiloInterface<Unit>(), malicious, Unit)
|
||||
val jobs = listOf(launch { runCatching { server.run() } }, launch { runCatching { client.run { if (it) ready.complete(Unit) } } })
|
||||
try {
|
||||
ready.await()
|
||||
assertTrue(withTimeout(2000) { runCatching { client.call(mutate, 1) } }.isFailure)
|
||||
assertTrue(attacked)
|
||||
withTimeout(2000) { jobs[0].join() }
|
||||
assertEquals(0, executions, "Unauthenticated routing metadata must be rejected before dispatch")
|
||||
} finally { jobs.forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
|
||||
@Test fun replayCannotExecuteTwice() = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val mutate by command<Int, Int>()
|
||||
val (a, b) = createTestDevice()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
var executions = 0
|
||||
var attacked = false
|
||||
val malicious = intercept(a) { block, send ->
|
||||
send(block)
|
||||
if (block is Transport.Block.Call && block.name == "L0Call") { attacked = true; send(block) }
|
||||
}
|
||||
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(mutate) { executions++; it } }, b, Unit, SigningSecretKey.new())
|
||||
val client = KiloClientConnection(KiloInterface<Unit>(), malicious, Unit)
|
||||
val serverJob = launch { runCatching { server.run() } }
|
||||
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
|
||||
try {
|
||||
ready.await()
|
||||
withTimeout(2000) { runCatching { client.call(mutate, 1) } }
|
||||
withTimeout(2000) { serverJob.join() }
|
||||
assertTrue(attacked)
|
||||
assertTrue(executions <= 1, "The replay must never execute the application operation again")
|
||||
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
|
||||
@Test fun forgedPlaintextIdentityAcknowledgementCannotConnect() = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val (a, b) = createTestDevice()
|
||||
var identityId: UInt? = null
|
||||
var connected = false
|
||||
var attacked = false
|
||||
val observedClient = intercept(a) { block, send ->
|
||||
if (block is Transport.Block.Call && block.name == "L0ClientId") identityId = block.id
|
||||
send(block)
|
||||
}
|
||||
val maliciousServer = intercept(b) { block, send ->
|
||||
if (block is Transport.Block.Response && block.forId == identityId) {
|
||||
attacked = true
|
||||
send(block.copy(packedResult = pack(Unit)))
|
||||
} else send(block)
|
||||
}
|
||||
val server = KiloServerConnection(KiloInterface<Unit>(), maliciousServer, Unit, SigningSecretKey.new())
|
||||
val client = KiloClientConnection(KiloInterface<Unit>(), observedClient, Unit)
|
||||
val serverJob = launch { runCatching { server.run() } }
|
||||
val clientJob = launch { runCatching { client.run { if (it) connected = true } } }
|
||||
try {
|
||||
withTimeout(2000) { clientJob.join() }
|
||||
assertTrue(attacked)
|
||||
assertFalse(connected, "Connected must await the authenticated server Finished")
|
||||
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
|
||||
@Test fun legacyHandshakeVersionIsRejectedBeforeConnection() = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val (a, b) = createTestDevice()
|
||||
var connected = false
|
||||
var attacked = false
|
||||
val downgraded = intercept(a) { block, send ->
|
||||
if (block is Transport.Block.Call && block.name == "L0Request") {
|
||||
attacked = true
|
||||
val request = unpack<Handshake>(block.packedArgs)
|
||||
send(block.copy(packedArgs = pack(request.copy(version = 1u))))
|
||||
} else send(block)
|
||||
}
|
||||
val server = KiloServerConnection(KiloInterface<Unit>(), b, Unit, SigningSecretKey.new())
|
||||
val client = KiloClientConnection(KiloInterface<Unit>(), downgraded, Unit)
|
||||
val serverJob = launch { runCatching { server.run() } }
|
||||
val clientJob = launch { runCatching { client.run { if (it) connected = true } } }
|
||||
try {
|
||||
withTimeout(2000) { clientJob.join() }
|
||||
assertTrue(attacked)
|
||||
assertFalse(connected)
|
||||
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.*
|
||||
import net.sergeych.crypto2.SigningSecretKey
|
||||
import net.sergeych.crypto2.initCrypto
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
import kotlin.test.*
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** Regression checks at the boundary between untrusted outer transport and authenticated RPC. */
|
||||
class SecurityBoundaryTest {
|
||||
@Test fun plaintextRegisteredBusinessErrorCannotReachCallerAsBusinessResult() =
|
||||
rejectPlaintextResponse(registeredError = true)
|
||||
|
||||
@Test fun malformedPlaintextResultClosesConnection() = rejectPlaintextResponse(registeredError = false)
|
||||
|
||||
private fun rejectPlaintextResponse(registeredError: Boolean) = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val echo by command<String, String>()
|
||||
val (clientDevice, serverDevice) = createTestDevice()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
var armed = false
|
||||
var attacked = false
|
||||
val malicious = object : Transport.Device by serverDevice {
|
||||
override suspend fun send(block: UByteArray) {
|
||||
val decoded = unpack<Transport.Block>(block)
|
||||
if (armed && decoded is Transport.Block.Response) {
|
||||
attacked = true
|
||||
val forged: Transport.Block = if (registeredError)
|
||||
Transport.Block.Error(decoded.forId, "IllegalArgumentException", "forged quota rejection")
|
||||
else decoded.copy(packedResult = ubyteArrayOf())
|
||||
serverDevice.send(pack(forged))
|
||||
} else serverDevice.send(block)
|
||||
}
|
||||
}
|
||||
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new())
|
||||
val client = KiloClientConnection(KiloInterface<Unit>(), clientDevice, Unit)
|
||||
val serverJob = launch { runCatching { server.run() } }
|
||||
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
|
||||
try {
|
||||
ready.await(); armed = true
|
||||
val result = withTimeout(2000) { runCatching { client.call(echo, "payload") } }
|
||||
assertTrue(attacked)
|
||||
assertIs<RemoteInterface.SecurityException>(result.exceptionOrNull(), "Plaintext outer errors/data must never impersonate authenticated business results")
|
||||
withTimeout(2000) { clientJob.join() }
|
||||
} finally { listOf(clientJob, serverJob).forEach { it.cancelAndJoin() } }
|
||||
}
|
||||
|
||||
@Test fun unestablishedProtectedCallDoesNotKeepDisconnectedServerAlive() = runTest(timeout = 10.seconds) {
|
||||
initCrypto()
|
||||
val (clientDevice, serverDevice) = createTestDevice()
|
||||
val server = KiloServerConnection(KiloInterface<Unit>(), serverDevice, Unit, SigningSecretKey.new())
|
||||
val serverJob = launch { runCatching { server.run() } }
|
||||
try {
|
||||
clientDevice.send(pack<Transport.Block>(Transport.Block.Call(1u, "L0Call", pack(ubyteArrayOf()))))
|
||||
runCurrent()
|
||||
clientDevice.close()
|
||||
withTimeout(2000) { serverJob.join() }
|
||||
} finally { serverJob.cancelAndJoin() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user