diff --git a/README.md b/README.md index 2afe3f9..481fbed 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Kiloparsec -> 0.7.0-SNAPSHOT is recommended for newer builds; due to extremely poor Kotlin time migration architecture, on Apple targets it is the only working version. - - -__Recommended version is `0.6.8`: to keep the code compatible with current and further versions we -ask to upgrade to `0.4.2` at least.__ Starting from this version some package names are changed for -better clarity and fast UDP endpoints are added. +The Kotlin implementation and the native [TypeScript + WASM client](typescript/README.md) +are maintained here together. Current source version **0.8.0-SNAPSHOT** uses +**protocol 2**, which authenticates RPC correlation metadata inside encrypted +packets. Both peers must be upgraded together; protocol 1 peers are rejected with +no automatic downgrade. See [the security analysis](typescript/docs/SECURITY.md). The new generation of __PARanoid SECurity__ protocol, advanced, faster, more secure. It also allows connecting any " block device" transport to the same local interface. Out if the box it @@ -22,20 +21,16 @@ provides the following transports: ### Note on version compatibility -We recommend using `0.6.12` - -Since version 0.6.9 websocket protocol supports both text and binary frames; old clients are backward compatible with -mew servers, but new clients only can work with older servers only in default binary frame mode. Upgrade also your -servers to get better websocket compatibility[^1]. - -Version 0.5.1 could be backward incompatible due to the upgrade of the crypto2. - -Protocols >= 0.3.0 are not binary compatible with the previous version due to a more compact binary -format. The format from 0.3.0 onwards is supposed to keep compatible. +Version 0.8.0 introduces an intentional wire break to close the response +correlation vulnerability. Earlier 0.3–0.7 versions share the old envelope and +must not be assumed compatible with current source. Application command schemas +and the Kotlin interface/session programming model remain unchanged. #### ID calculation algorithm is changed since 0.4.1 -We recommend to upgrade to 0.4+ ASAP as public/shared key id derivation method was changed for even higher security. +Historical note: 0.4.1 changed public/shared key ID derivation. Current deployments +should target protocol 2; upgrading only to an older 0.4 release does not fix the +authenticated correlation issue. ### Supported native targets @@ -350,3 +345,18 @@ output and the registry rather than assuming a target was skipped. `bin/pubdocs` generates Dokka and uploads it to the existing Kiloparsec documentation site. For development-only Maven Local publication without configuring the remote repository, pass `-PlocalPublishingOnly=true`. + +## Verify Kotlin and TypeScript together + +Requires JDK 21, Node.js 22+, and the Android SDK configuration used by this +multiplatform build. Install the module's pinned npm dependencies once: + +```sh +npm ci --prefix typescript --ignore-scripts +bin/test-all.sh +``` + +The script runs JVM and Kotlin/JS Node tests, TypeScript unit tests, then cross-language tests against +a disposable localhost JVM server built from this checkout. It does not publish +or contact deployment hosts. Additional Gradle options can be passed to the script. +For TypeScript-only commands and examples, see [the module README](typescript/README.md). diff --git a/bin/test-all.sh b/bin/test-all.sh new file mode 100755 index 0000000..3f07ed4 --- /dev/null +++ b/bin/test-all.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Local-only verification; no publishing or deployment. +set -euo pipefail +repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_dir/gradlew" -p "$repo_dir" jvmTest jsNodeTest typescriptTest "$@" +npm --prefix "$repo_dir/typescript" run test:interop diff --git a/build.gradle.kts b/build.gradle.kts index db274c9..b3a5163 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,7 @@ plugins { } group = "net.sergeych" -version = "0.7.2-SNAPSHOT" +version = "0.8.0-SNAPSHOT" repositories { google() @@ -192,3 +192,12 @@ android { targetCompatibility = JavaVersion.VERSION_17 } } + +// This task stays local: the interoperability build is run sequentially by +// bin/test-all.sh, avoiding a nested Gradle build of the same checkout. +tasks.register("typescriptTest") { + group = "verification" + description = "Build and test the TypeScript/WASM client (run npm ci first)." + workingDir(layout.projectDirectory.dir("typescript")) + commandLine("npm", "test") +} diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt index fa5827a..6b499ef 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt @@ -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( // 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( 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) diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt index c8e747b..17c3f2c 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt @@ -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( private val clientInterface: LocalInterface>, private val deferredParams: CompletableDeferred>, + private val allowHandshakeCallbacks: Boolean = false, ) : LocalInterface() { - 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)))) } -} \ No newline at end of file +} diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt index dcc66b2..862d39a 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt @@ -45,6 +45,8 @@ data class KiloParams( ) private var nonce = 0UL + private var highestReceived: ULong? = null + private val received = mutableSetOf() val scope: KiloScope by lazy { object : KiloScope { @@ -72,18 +74,43 @@ data class KiloParams( 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 } + } -} \ No newline at end of file + /** Identity completion must not reset counters/replay history already used by the handshake. */ + internal fun authenticated(identity: VerifyingPublicKey): KiloParams = 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)) +} diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloProtocol.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloProtocol.kt new file mode 100644 index 0000000..046e81a --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloProtocol.kt @@ -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 diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt index 04ca22e..a8521e5 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt @@ -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( private val deferredParams: CompletableDeferred>, private val clientInterface: LocalInterface>, -) : RemoteInterface, Loggable by LogTag("L1TR:${++L1IdCounter}") { - +) : RemoteInterface { override suspend fun call(cmd: Command, 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 push(cmd: Command, 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)))) } } diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt index 1506157..61e7c3b 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt @@ -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( val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply { var params: KiloParams? = 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(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(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( } } - companion object { - val pair = SafeKeyExchange() - } override suspend fun call(cmd: Command, args: A): R { return kiloRemoteInterface.await().call(cmd, args) diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt index 2f60aac..11f09ac 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt @@ -82,6 +82,10 @@ open class LocalInterface : 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, String>() private val errorBuilder = mutableMapOf Throwable>() diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt index 56bcd16..2658ad3 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt @@ -126,7 +126,11 @@ class Transport( * @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( 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( } } + internal suspend fun callWithId( + cmd: Command, + 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( 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( 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( 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}" } } diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt index a160f03..89e2cf3 100644 --- a/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt @@ -25,5 +25,5 @@ data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: U // Level 0 command: request key exchange internal val L0Request by command() -internal val L0ClientId by command() +internal val L0ClientId by command() internal val L0Call by command() diff --git a/src/commonTest/kotlin/AuthenticatedEnvelopeTest.kt b/src/commonTest/kotlin/AuthenticatedEnvelopeTest.kt new file mode 100644 index 0000000..dff089d --- /dev/null +++ b/src/commonTest/kotlin/AuthenticatedEnvelopeTest.kt @@ -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(replacement)) } + } + } + + @Test fun authenticatedDuplexAndSessionArePreserved() = runTest(timeout = 10.seconds) { + initCrypto() + val echo by command() + val callback by command() + val (a, b) = createTestDevice() + val ready = CompletableDeferred() + val serverKey = SigningSecretKey.new() + val clientKey = SigningSecretKey.new() + val server = KiloServerConnection(KiloInterface().apply { + on(echo) { + assertEquals("server-session", session) + assertEquals(clientKey.verifyingKey, remoteIdentity) + remote.call(callback, it) + } + }, b, "server-session", serverKey) + val client = KiloClientConnection(KiloInterface().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() + val (a, b) = createTestDevice() + val ready = CompletableDeferred() + var armed = false + val held = mutableListOf() + 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().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new()) + val client = KiloClientConnection(KiloInterface(), 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() + val (a, b) = createTestDevice() + val ready = CompletableDeferred() + 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().apply { on(mutate) { executions++; it } }, b, Unit, SigningSecretKey.new()) + val client = KiloClientConnection(KiloInterface(), 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() + val (a, b) = createTestDevice() + val ready = CompletableDeferred() + 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().apply { on(mutate) { executions++; it } }, b, Unit, SigningSecretKey.new()) + val client = KiloClientConnection(KiloInterface(), 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(), maliciousServer, Unit, SigningSecretKey.new()) + val client = KiloClientConnection(KiloInterface(), 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(block.packedArgs) + send(block.copy(packedArgs = pack(request.copy(version = 1u)))) + } else send(block) + } + val server = KiloServerConnection(KiloInterface(), b, Unit, SigningSecretKey.new()) + val client = KiloClientConnection(KiloInterface(), 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() } } + } +} diff --git a/src/commonTest/kotlin/SecurityBoundaryTest.kt b/src/commonTest/kotlin/SecurityBoundaryTest.kt new file mode 100644 index 0000000..9cd682e --- /dev/null +++ b/src/commonTest/kotlin/SecurityBoundaryTest.kt @@ -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() + val (clientDevice, serverDevice) = createTestDevice() + val ready = CompletableDeferred() + var armed = false + var attacked = false + val malicious = object : Transport.Device by serverDevice { + override suspend fun send(block: UByteArray) { + val decoded = unpack(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().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new()) + val client = KiloClientConnection(KiloInterface(), 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(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(), serverDevice, Unit, SigningSecretKey.new()) + val serverJob = launch { runCatching { server.run() } } + try { + clientDevice.send(pack(Transport.Block.Call(1u, "L0Call", pack(ubyteArrayOf())))) + runCurrent() + clientDevice.close() + withTimeout(2000) { serverJob.join() } + } finally { serverJob.cancelAndJoin() } + } +} diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 0000000..cb90c63 --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +*.tgz +measurement/ +evaluation/.gradle/ +evaluation/.kotlin/ +evaluation/build/ +evaluation/kotlin-js-store/ + +# Generated size/build measurement reports +/evaluation/artifacts.json +/evaluation/measurements*.json +/evaluation/wasm-measurements.json diff --git a/typescript/LICENSE b/typescript/LICENSE new file mode 100644 index 0000000..3cde2a4 --- /dev/null +++ b/typescript/LICENSE @@ -0,0 +1,4 @@ +Copyright (c) 2026 Sergey S. Chernov. All rights reserved. +Use, distribution and modification require the author's private license, +consistent with the original Kiloparsec and crypto2 libraries. +Contact: real dot sergeych at gmail. diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..ed6b24a --- /dev/null +++ b/typescript/README.md @@ -0,0 +1,126 @@ +# Kiloparsec TypeScript + WASM + +Full-protocol TypeScript client for the existing Kotlin Kiloparsec server, backed +by libsodium WASM. No Kotlin runtime, no light handshake, no TLS-only identity +fallback. This is an evaluated implementation candidate, **not a production release**. + +This module is maintained alongside the Kotlin implementation in this repository. +Both peers use the hardened 0.8 protocol: encrypted packets authenticate their +RPC envelope as well as their payload. Legacy peers are deliberately rejected; +there is no automatic downgrade. See [security and migration](docs/SECURITY.md). + +The [assessment](docs/ASSESSMENT.md) records the original implementation choice +and historical size measurements. Re-run the measurement script for this revision; +those recorded measurements describe the earlier published protocol baseline. + +## API + +```ts +import { + Client, LocalInterface, command, string, generateSigningKey, +} from '@sergeych/kiloparsec'; + +const echo = command('echo', string, string); +const clientEcho = command('clientEcho', string, string); +const local = new LocalInterface<{ requests: number }>() + .on(clientEcho, (scope, text) => { + scope.session.requests++; + return text; + }); +const identity = await generateSigningKey(); +// Persist identity.exportSecret() in your secure store before registering it. +// Register identity.publicKey with the application separately. +const client = await Client.connect('wss://your-service.example/kp', { + expectedServerKey, // trusted raw 32-byte Ed25519 key, provisioned independently + identity, + local, + session: { requests: 0 }, +}); +try { + const text = await client.call(echo, 'Hello'); +} finally { + client.close(); + identity.destroy(); +} +``` + +`expectedServerKey` is mandatory. A signature by an arbitrary key supplied by the +peer is insufficient. Anonymous *client* sessions are available by omitting +identity; their permissions remain a server application decision. `restoreSigningKey` +restores raw 64-byte secret exports; generic Kiloparsec uses bytes, while an SDK +such as GrandMobile may wrap them in its own opaque versioned format. + +`command(name, argsCodec, resultCodec)` mirrors Kotlin `Command`. +`LocalInterface.on` registers typed callbacks before connection; each connection +gets a snapshot of the registry and a session object. A callback can call its +peer and await the result without blocking packet dispatch. Methods on `Client`: + +- `call(command, args, {signal, timeoutMs, priority})`: typed authenticated payload + or `RemoteError`. `ConnectionError` describes local transport failure. +- `push(command, args)`: sends an id=0 notification; successful return means local + enqueue only, not acknowledged remote execution. +- `ping()`: encrypted liveness check. Automatic heartbeat defaults to 30 seconds; + set `heartbeatMs: 0` to disable it. +- `close()` and `closed`: dispose the connection and observe local disposal. + +Timeout/abort stops waiting, not remote execution. No transparent replay or +reconnect: create a fresh connection and let the application resolve uncertain +business outcomes using its durable operation IDs. Transport IDs are unrelated. +Local handler failures are sanitized unless explicitly returned as `RemoteError`. + +Node >=22 uses its standard WebSocket. Other runtimes can provide `socketFactory`. +Both ws and wss carry the same encrypted protocol; deployment policy chooses the +URL. Browser type/build compatibility is present but browser runtime tests have +not been performed. Do not treat Node interoperability as browser qualification. + +## Serialization compatibility + +Interop tests build the Kotlin server from this checkout through a Gradle +composite build; no published Kiloparsec snapshot is needed. +`src/bipack.ts` implements explicit codecs; it does not dynamically recreate all +Kotlin serialization. Exported codecs cover strings, bytes, bool, Unit, integers, +nullable values and lists. Compose records with `codec`, `BipackReader` and +`BipackWriter` in the same field order as Kotlin; no field names go on the wire. + +`uint32`/`uint64` encode unsigned Smartint (`@Unsigned` semantics). Published +mp_bintools 0.3.2 serializes unannotated Kotlin UInt through its signed backing +Int: the handshake codec preserves this behavior. Newer source 0.4.0 changes that +behavior. Do not upgrade either peer silently. Signed Smartint cannot represent +Long.MIN_VALUE correctly in this baseline; TS rejects it explicitly. + +Packet sizes, concurrent incoming work, pending calls and send buffers are bounded. +Receive replay tracking permits out-of-order counters within 4096 positions and +fails closed outside the window. Authenticated envelopes additionally bind packet kind, RPC ID and priority +to the encrypted payload. Priority trailers +are preserved, but this first client uses WebSocket FIFO rather than a priority +scheduler. No TCP/UDP adapters, carrier cycling, automatic reconnection, or code +generator are included in this version. + +## Build and verify + +```sh +npm ci --ignore-scripts +npm test # build + local codec/crypto/RPC regression tests +npm run test:interop # build current Kotlin fixture + cross-language tests +npm run test:all # both suites +# Optional optimized Kotlin comparison baseline: +npm run baseline:build +npm ci --prefix evaluation --ignore-scripts +npm run measure +``` + +JDK 21 and the Android SDK configuration required by the parent Kotlin build must +be available for interoperability and baseline builds. `test:interop` starts and +stops its own disposable localhost JVM fixture. Generated outputs and dependencies +are not source-controlled. Package publication remains disabled pending release +qualification. From the parent repository, `bin/test-all.sh` runs Kotlin and TS +unit tests followed by interoperability tests sequentially. + +## Follow-up: reusable utility exports + +After the GrandMobile game API, complete the supported public utility surface: +BiPack, key generation/restoration, signing/verification, standalone encryption +and hashes. BiPack and signing identities are already exported from index.ts; +hash and session-crypto implementations currently remain internal package modules. +Define documented stable entry points, formats and usage examples before exposing +additional utilities. This is deferred work, not a prerequisite for the game API. diff --git a/typescript/docs/ASSESSMENT.md b/typescript/docs/ASSESSMENT.md new file mode 100644 index 0000000..8ba1961 --- /dev/null +++ b/typescript/docs/ASSESSMENT.md @@ -0,0 +1,144 @@ +# Оценка: полноценный Kiloparsec на TypeScript + WASM + +Историческая оценка протокола 1, 18 октября 2026. После оценки библиотека перенесена +в модуль typescript основного репозитория Kiloparsec; протокол 2 исправляет найденные +KP-01/KP-02. Текущее состояние и регрессии: [SECURITY.md](SECURITY.md). +Числа ниже относятся к первому совместимому v1-варианту, не к новой v2-сборке. + +Решение: **продолжать отдельную TS/WASM-библиотеку**. +Вариант light исключён. Kotlin/JS-обёртка оставлена как измеряемый эталон, а не +вторая продуктовая реализация. Сервер и исходный Kiloparsec не изменялись. + +## Что сравнивали + +Контрольные суммы использованных JVM-артефактов: [artifacts.json](../evaluation/artifacts.json). + +Опубликованная Kotlin-библиотека поставляется как `.klib`, а не готовый TS API. +В исходниках нет публичного `@JsExport`-фасада. Поэтому сравнивали не размер klib, +а готовые минимизированные подключаемые пакеты: + +- TS: все экспорты новой библиотеки, полный DH, подписи и шифрование, RPC, + локальные интерфейсы и сессии, кодеки, ключи, WebSocket-клиент; +- Kotlin: отдельная рабочая экспортируемая обёртка над опубликованным Kiloparsec, + блочный адаптер без намеренного включения Ktor WebSocket-клиента, DH, pinning, + идентификация и echo. Production DCE Kotlin, затем та же минимизация esbuild. + +Оба пакета включают одну версию libsodium-wrappers-sumo 0.7.15 и её WASM payload. +Сравнение благоприятно для Kotlin: его фасад существенно уже по API, чем TS. +Это не сравнение полного равенства возможностей или двух готовых релизов. + +| Измерение | TS/WASM | Kotlin/JS baseline | +|---|---:|---:| +| Минимизированный пакет | 1 124 612 байт | 1 982 755 байт | +| gzip, уровень 9 | 330 101 байт | 559 003 байта | +| Инициализация + создание Ed25519, медиана 5 процессов | 88,59 мс | 166,50 мс | +| RSS процесса после инициализации, медиана | 62,9 МБ | 70,6 МБ | +| Прирост RSS, медиана | 19,3 МБ | 27,0 МБ | + +Выигрыш размера около **43%**, по gzip — **41%**. Время/RSS — локальное наблюдение +на одном хосте, не SLA; время не включает запуск Node. Числа воспроизводятся через +`scripts/measure.mjs`, исходные измерения сохранены в +[measurement record](../evaluation/measurements.json). Готовые измеряемые пакеты +проверены загрузкой; Kotlin bundle выполнил реальный authenticated echo. +Большая часть TS-пакета — готовая криптобиблиотека; специальная урезанная сборка +sodium здесь не создавалась и в выигрыш не заложена. + +## Реально выполнено + +TS-клиент работает с опубликованным JVM Kiloparsec 0.7.2-SNAPSHOT без его правок: +DH, проверка закреплённого серверного ключа, идентификация клиента, строки/числа/ +списки, callback с вложенным обратным RPC, push, удалённые ошибки, 64 параллельных +вызова, большой строковый аргумент, ping и новое подключение после закрытия. +Для воспроизведения `npm run test:interop` запускает собственный локальный сервер. + +Проверены unit-тесты кодирования, криптографические векторы, таймаут/abort/close, +полный дуплекс, pinning, подмена/истечение подписи, повреждение/повтор шифротекста. +Один тест намеренно воспроизводит обнаруженный изъян корреляции ответа. +Ни тест производительности реального сервиса, ни аудит всей библиотеки, ни +браузерные runtime-тесты этим не подменяются. + +## Что выяснилось при переносе + +1. Kotlin bridge требует корректного порядка загрузки sodium: импорт Kotlin до + sodium.ready ломает опубликованный binding. Обёртка должна это скрывать. +2. Неаннотированный UInt кодируется по-разному в опубликованном BiPack 0.3.2 и + более новом исходнике 0.4.0. Перенос проверен именно с опубликованными артефактами; + версии/контрольные суммы нужно закреплять, а не ориентироваться только на master. +3. Исходный signed Smartint не сохраняет Long.MIN_VALUE. TS явно отвергает это + значение, чтобы не выдавать тихо повреждённые данные. +4. **Существующий протокол позволяет переставлять ответы между RPC**, изменив + только внешние forId. Изъян воспроизведён. Есть также неподписанное подтверждение + клиентской идентификации. Подробности и предложения — [SECURITY.md](SECURITY.md). + +## Граница результата и следующее решение + +Оценка закончена, путь выбран, рабочий совместимый клиент реализован отдельно в +`~/dev/kiloparsec/typescript`. Это кандидат библиотеки, не готовая поставка в биржу. +Первоначальный совместимый перенос не требовал изменения сервера, но унаследовал +KP-01/KP-02. По последующему решению пользователя обе стороны переведены на +протокол 2: ID/тип/приоритет аутентифицированы, завершение идентификации зашифровано. +Старая версия автоматически не выбирается. Технический разбор и доказательства +исправления находятся в SECURITY.md. Изменение сервера здесь относится к исходникам +библиотеки и локальному тестовому стенду; развёртывания сервисов не выполнялись. + +Нет автоматического reconnect/повтора бизнес-команд, TCP/UDP/carrier cycling, +генератора всех Kotlin DTO или квалификации браузеров. Приоритет пакета передаётся +совместимо, но первый TS-клиент обслуживает выход FIFO. Эти ограничения явно +перечислены в README; они не скрыты обещанием полного совпадения API Kotlin. + +Ни сервис GrandMobile, ни SDK Gold, ни сайт биржи этой работой не развёрнуты и +не переключены на новый транспорт. Модуль сопровождается вместе с Kotlin в одном репозитории; публикация не выполнялась. + +## Повторный замер исправленного протокола 2 + +Текущий модуль 0.8.0: TS — 1 126 081 байт, gzip 330 527 байт; Kotlin/JS-обёртка +над текущими исходниками — 1 993 070 байт, gzip 561 965 байт. Выигрыш сохраняется: +примерно 43,5% по размеру и 41,2% по gzip. Медиана инициализации с генерацией +Ed25519: 87,0 мс против 169,6 мс на этом хосте. Полные исходные данные: +[measurements-v2.json](../evaluation/measurements-v2.json). Старые данные выше +сохранены как основание исходного выбора, а не выданы за текущий релиз. + +## Сравнение с Kotlin/Wasm, протокол 2 + +Проверили также **настоящую оптимизированную Kotlin/Wasm-сборку**, а не `.klib`. +Модуль `evaluation/wasm-baseline` использует тот же исходник Kiloparsec через +Gradle composite build. Минимальная обёртка сохраняет DH, проверку серверного +ключа, идентификацию клиента и echo RPC — как Kotlin/JS baseline выше. + +| Поставка | Размер, байт | gzip level 9, байт | Инициализация + Ed25519, медиана | +|---|---:|---:|---:| +| TS + sodium WASM | 1 126 081 | 330 527 | 87,0 мс | +| Kotlin/JS + sodium WASM | 1 993 070 | 561 965 | 169,6 мс | +| Kotlin/Wasm + JS loader + sodium WASM | 2 083 682 | 616 011 | 116,1 мс | + +По сравнению с Kotlin/Wasm TS меньше на **46,0% по исходному размеру** и на +**46,3% после gzip**. По сравнению с Kotlin/JS выигрыш составляет 43,5% и 41,2%. + +В Kotlin/Wasm посчитаны два реально необходимых файла: оптимизированный Binaryen +исполняемый `.wasm` — 764 584 байта (gzip 247 073), минимизированный ESM loader +с зависимостями sodium/joda — 1 319 098 байт (gzip 368 938). Gzip применяется +**к каждому файлу отдельно**, после чего размеры складываются. Sodium WASM уже +встроен в loader и учитывается один раз; обе crypto-привязки используют один +CommonJS-модуль sodium. Карты исходников, неоптимизированный `.wasm`, `.klib`, +тестовый runtime и компилятор в поставку не включены. + +Получившаяся поставка успешно загрузилась на Node 24.19.0, создала Ed25519-ключ, +выполнила DH, проверку закреплённого серверного ключа и authenticated echo с +локальным JVM-сервером протокола 2. Временный сервер после проверки остановлен. +Браузерные runtime-проверки этим не подменяются. Время/RSS остаются наблюдением +на одном Linux x64 хосте, а не обещанием производительности на других платформах. + +[Переносимый протокол измерения](../evaluation/wasm-measurements.json) содержит +состав поставки, относительные пути, SHA-256 каждого файла и пять исходных замеров. +Для воспроизведения из `typescript/`: + +```sh +npm run wasm:build +npm ci --prefix evaluation --ignore-scripts +npm run measure:wasm +npm run fixture:build +npm run test:wasm-baseline +``` + +Инструкции и ограничения упаковки приведены в +[evaluation/README.md](../evaluation/README.md#optimized-kotlinwasm-comparison). diff --git a/typescript/docs/SECURITY.md b/typescript/docs/SECURITY.md new file mode 100644 index 0000000..d0a554e --- /dev/null +++ b/typescript/docs/SECURITY.md @@ -0,0 +1,93 @@ +# Authenticated RPC and handshake: protocol 2 + +Protocol 2, implemented with library version 0.8.0, fixes two weaknesses found +while building the TypeScript client. Both Kotlin and TypeScript use this profile +by default and reject protocol 1. Deploy compatible peers together; there is no +automatic fallback to the vulnerable envelope. + +## KP-01 — outer RPC response IDs were unauthenticated + +The old Kotlin `KiloL0Interface` encrypted a result whose inner `forId` was always +zero. The actual pending RPC ID was in an unencrypted outer Transport.Response. +An active intermediary could exchange two outer IDs without modifying either +ciphertext. With compatible result codecs, request A received B's valid answer. +This was reproduced before the fix; passive decryption was not required. + +Protocol 2 seals this record inside the existing session encryption: + +``` +ProtectedMessage(kind, correlation, priority, payload) +kind = call | push | response | error | server_finished +``` + +A call's correlation is its actual nonzero outer Transport ID; a push has zero. +Before application lookup/deserialization, the receiver validates the protected +kind, ID and priority against the outer dispatch metadata. Responses/errors echo +the authenticated request ID and priority. The caller checks these before decoding +or delivering a result. The inner response/error Block remains forId=0, but no +longer supplies the request correlation. Rewriting a response's outer forId now +closes the connection instead of resolving the wrong call. Rewriting call/push or +priority likewise fails before application dispatch. An unencrypted outer error +is not accepted as an authenticated application result. + +Session nonces are replay-checked on both peers, including pushes. A bounded +4096-position window supports responses completed out of order and rejects replay +or excessively stale traffic. Send nonces and transport IDs do not wrap. Nonce +state used during identity exchange is preserved when the identity is completed. + +The routing envelope still allows denial of service by an active carrier: packets +can be dropped, delayed or redirected to unknown IDs. Channel encryption cannot +force an adversarial carrier to deliver data. Public outer priority may influence +transport scheduling before the receiver authenticates it; it cannot authorize +an altered application call. + +## KP-02 — identity acknowledgement was plaintext + +The old `L0ClientId` returned a plaintext Unit. A relay could forge successful +completion without the server accepting the client's identity. + +The v2 reply is an encrypted `server_finished` frame with correlation/priority zero +and payload equal to the exact ClientIdentity plaintext the server accepted. +The client validates all of these before returning a connected client or invoking +its connected handlers. A plaintext Unit or an altered identity is rejected. +Server-side per-operation authorization remains mandatory. + +Server and client proofs use different signing domains: + +``` +UTF8("kiloparsec/v2/server\0") || sessionToken +UTF8("kiloparsec/v2/client\0") || sessionToken +``` + +Here `\0` denotes one NUL byte, not a backslash and zero on the wire. This binds +the proof to the role and version. A legacy signature of the token alone is invalid. +The handshake version is exactly 2 at both peers; version changes are rejected. +The server also rejects a repeated handshake/identity exchange within a connection +and creates a fresh exchange keypair per connection. + +## Identity trust + +The TypeScript client requires a preconfigured Ed25519 server key and validates its +Seal before signing the client proof. Kotlin retains its existing application +policy for optional remote identity: a valid Seal establishes possession of its +key, not external trust in that key. Kotlin applications must check the expected +remoteIdentity as part of their connection policy; anonymous sessions should be +used only where explicitly authorized. Version 2 fixes RPC binding; it does not +silently invent a key provisioning or trust-on-first-use policy. + +## Evidence + +- `test/security.test.mjs`: swapped responses reject; wrong pin, version/role/token + signature substitution, forged/mismatched finished, expired/tampered server Seal, + damaged/replayed packets, altered call IDs/kinds/priorities fail closed. +- Kotlin `AuthenticatedEnvelopeTest`: normal signed duplex and session identities, + response-ID swap, call-to-push rewrite, priority rewrite, replay, forged Unit + acknowledgement and protocol downgrade. +- The interoperability fixture consumes this checkout, rather than an old Maven + snapshot, and runs TS ↔ JVM full-duplex calls, errors, pushes, concurrency and + reconnect to a fresh session. + +These regressions exercise the identified flaws. They are not a general independent +cryptographic audit. Browser runtime and sustained load remain separate release +qualification work. Secret export is explicit; wiping library-owned byte buffers +does not promise erasure of copies made by a host VM or device. diff --git a/typescript/evaluation/.gitignore b/typescript/evaluation/.gitignore new file mode 100644 index 0000000..afeafe2 --- /dev/null +++ b/typescript/evaluation/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +.kotlin/ +**/build/ +node_modules/ +baseline-browser.min.js diff --git a/typescript/evaluation/README.md b/typescript/evaluation/README.md new file mode 100644 index 0000000..4ec3776 --- /dev/null +++ b/typescript/evaluation/README.md @@ -0,0 +1,147 @@ +# Kotlin compatibility fixture and size baseline + +This fixture uses a Gradle composite build to consume Kiloparsec source from +the repository root (`../..`), with explicit dependency substitution. The JVM fixture uses +`setupWebsocketServer` and `KiloInterface` unchanged. + +## Build and start the reference server + +From the TypeScript module directory (requires JDK 21 and the parent build's Android SDK configuration): + +```sh +npm run fixture:build +npm run baseline:build +JAVA_HOME=/path/to/jdk21 evaluation/server/build/install/server/bin/server +``` + +Default endpoint: `ws://127.0.0.1:18732/kp`; optional first argument overrides port. +The server binds localhost only. Its deterministic Ed25519 **TEST-ONLY** seed is +bytes 0 through 31. Public key: + +```text +03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8 +``` + +Commands use ordinary Kotlin serialization/BiPack: + +| Command | Arguments | Result / behavior | +|---|---|---| +| `echo` | String | same String | +| `sum` | List | Int sum | +| `identity` | Unit | authenticated remote public key raw lowercase hex, or `anonymous` | +| `callback` | String | server calls `clientEcho(String): String`, returns its result | +| `push` | String | server calls `notice(String): Unit`, waits for acknowledgment | +| `fail` | String | throws registered `IllegalArgumentException` with that message | + +The fixture supports anonymous clients to allow explicit coverage of identity +presence/absence; a production application's authorization policy must decide +which identities may invoke its methods. + +## Optimized Kotlin/JS baseline + +`baseline` exports a usable `BaselineClient` accepting binary block callbacks. +It retains full DH, signed identity, expected server identity checking and RPC, +without retaining the Ktor WebSocket client intentionally. This is the smaller +bridge option rather than a deliberately inflated full library bundle. + +```sh +npm ci --prefix evaluation --ignore-scripts +node evaluation/baseline-smoke.cjs +node_modules/.bin/esbuild evaluation/baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js --bundle --minify --platform=browser --external:fs --external:path --external:crypto --outfile=evaluation/baseline-browser.min.js +``` + +`fs`, `path`, `crypto` are libsodium's conditional Node imports, unused in browser +execution. Bundle includes libsodium-sumo WASM payload. Source maps are excluded. +Kotlin production compiler performs dead-code elimination, followed by esbuild +minification. npm explicitly pins sodium wrapper 0.7.15 for comparison with TS. + +For the common comparison, use the same **Node CJS** platform for both libraries: + +```sh +npm run build +node scripts/measure.mjs +node evaluation/baseline-smoke.cjs --bundled +``` + +Results are saved in `evaluation/build/measurement/results.json`. A recorded run +is preserved in [measurements.json](measurements.json). The script performs five +fresh subprocess measurements, from immediately before module loading through +WASM initialization and generation of one Ed25519 identity. It does not include +process startup. RSS includes Node; RSS delta is resident growth during workload. + +Historical measurements of the pre-hardening published 0.7.2 baseline, +2026-10-18 on Node 24.19.0, Linux x64 (not regenerated for current source): + +| Measure | TS full exports | Kotlin minimal bridge | +|---|---:|---:| +| Minified self-contained Node bundle, bytes | 1,124,612 | 1,982,755 | +| Gzip level 9, bytes | 330,101 | 559,003 | +| Cold init + identity median, ms | 88.59 | 166.50 | +| Resident memory median, bytes | 62,898,176 | 70,574,080 | +| Resident growth median, bytes | 19,300,352 | 26,976,256 | + +The Kotlin baseline exposes only echo and the bridge plus the measurement identity +helper; TS includes all exported API. This is not a claim of full feature parity. +Both include sodium's WASM payload. Both actual standalone bundles initialize +successfully; the measured Kotlin bundle additionally passed authenticated, +pinned-server echo to the JVM fixture. Measurements vary by host and revision. + +The Kotlin sodium bindings eagerly capture imported functions: await +`require('libsodium-wrappers-sumo').ready` **before loading the compiled bridge**, +as shown in the smoke script. Loading the bridge first produces `_sodium_init is +not a function` with these published dependencies. That observation concerns the historical published baseline. +The browser bundle's size was measured; browser execution is not claimed by the +Node smoke check. The bridge is an evaluation fixture, not a polished production +wrapper (bounded buffering, lifecycle error propagation, and key ownership would +need finishing if this alternative were selected). + +## Protocol 2 validation and measurements + +Current-source protocol 2 results are in [measurements-v2.json](measurements-v2.json). +TS: 1,126,081 bytes / gzip330,527; Kotlin baseline: 1,993,070 / gzip561,965. +Both measured bundles completed pinned-server echo against the local protocol2 +fixture. Historical measurements above remain unchanged for traceability. +The full root verification command passed 43 JVM tests, 28 Kotlin/JS Node tests, +35 TS unit/security tests, and the real TS/JVM interoperability scenario. + +## Optimized Kotlin/Wasm comparison + +`wasm-baseline` retains the same full handshake, server key pin, fresh client +identity and echo RPC as the JS baseline. It exports a small string/callback +bridge, avoiding non-exportable Kotlin objects at the JS/Wasm boundary. + +From the TypeScript module directory: + +```sh +npm run wasm:build +npm ci --prefix evaluation --ignore-scripts +npm run measure:wasm +npm run fixture:build +npm run test:wasm-baseline +``` + +The measurement uses the **optimized production executable** `.wasm` emitted by +Binaryen, together with an esbuild-minified ESM loader containing sodium and joda. +The raw `.klib`, unoptimized compiler output, maps and test runtime are excluded. +Both the raw and wrapper sodium imports share the same CommonJS module instance, +so the crypto payload is counted once. The wrapper is pinned to 0.7.15; its locked +transitive raw sodium dependency is 0.7.16, as in the JS/TS comparison. + +The Kotlin loader must receive the sodium modules after `sodium.ready`. The small +`wasm-entry.mjs` adapter does this, supplies the expected default/namespace shapes, +and includes Node CommonJS helpers for the bundled sodium dependency. It does not +change the protocol or cryptography. The optimized asset stays beside `client.mjs`. + +Recorded Node/Linux results are in [wasm-measurements.json](wasm-measurements.json). +The runnable distribution consists of two assets: + +| Asset | Bytes | Gzip level 9 bytes | +|---|---:|---:| +| Minified loader, sodium and joda | 1,319,098 | 368,938 | +| Optimized Kotlin executable | 764,584 | 247,073 | +| Total | 2,083,682 | 616,011 | + +Gzip is applied separately to each transferred asset and the sizes are summed. +The distribution passed initialization, Ed25519 identity generation, full DH, +server pinning and authenticated echo against the local protocol-2 JVM fixture. +This qualifies the comparison on Node; no browser runtime result is claimed. diff --git a/typescript/evaluation/baseline-entry.cjs b/typescript/evaluation/baseline-entry.cjs new file mode 100644 index 0000000..c9fe2e2 --- /dev/null +++ b/typescript/evaluation/baseline-entry.cjs @@ -0,0 +1,5 @@ +// Kotlin binding imports must execute after sodium resolves its WASM functions. +exports.load = async function load() { + await require('libsodium-wrappers-sumo').ready; + return require('./baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js'); +}; diff --git a/typescript/evaluation/baseline-smoke.cjs b/typescript/evaluation/baseline-smoke.cjs new file mode 100644 index 0000000..1d4cee5 --- /dev/null +++ b/typescript/evaluation/baseline-smoke.cjs @@ -0,0 +1,24 @@ +// Check that the measured baseline is a usable full client, not dead-code-only output. +const { WebSocket } = require('ws'); +async function main() { +if (!process.argv.includes('--bundled')) await require('libsodium-wrappers-sumo').ready; +const { BaselineClient } = process.argv.includes('--bundled') + ? await require('./build/measurement/kotlin.cjs').load() + : require('./baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js'); +const socket = new WebSocket(process.env.KILOPARSEC_TEST_URL ?? 'ws://127.0.0.1:18732/kp'); +const client = new BaselineClient(bytes => socket.send(Buffer.from(bytes))); +socket.on('message', bytes => client.receiveBlock(new Int8Array(bytes))); +socket.on('open', async () => { + try { + await client.connect('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8'); + const result = await client.echo('Kotlin baseline ✓'); + if (result !== 'Kotlin baseline ✓') throw Error('Wrong echo'); + console.log('BASELINE_AUTHENTICATED_ECHO_OK'); + } catch (error) { console.error(error); process.exitCode = 1; } + finally { client.close(); socket.close(); } +}); +socket.on('error', error => { console.error(error); process.exitCode = 1; }); +setTimeout(() => { console.error('Baseline timeout'); process.exit(1); }, 10000).unref(); + +} +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/typescript/evaluation/baseline/build.gradle.kts b/typescript/evaluation/baseline/build.gradle.kts new file mode 100644 index 0000000..c7e35c3 --- /dev/null +++ b/typescript/evaluation/baseline/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { kotlin("multiplatform"); kotlin("plugin.serialization") } +kotlin { + js { nodejs(); binaries.executable(); generateTypeScriptDefinitions() } + sourceSets { jsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") } } +} diff --git a/typescript/evaluation/baseline/src/jsMain/kotlin/Baseline.kt b/typescript/evaluation/baseline/src/jsMain/kotlin/Baseline.kt new file mode 100644 index 0000000..fe19f0a --- /dev/null +++ b/typescript/evaluation/baseline/src/jsMain/kotlin/Baseline.kt @@ -0,0 +1,41 @@ +@file:OptIn(ExperimentalJsExport::class, kotlin.ExperimentalUnsignedTypes::class) +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import net.sergeych.crypto2.* +import net.sergeych.kiloparsec.* +import net.sergeych.kiloparsec.adapter.ProxyDevice +import kotlin.js.Promise + +/** Native JS WebSocket bridge: retains the full authenticated protocol without Ktor client. */ +@JsExport +class BaselineClient(private val sendBlock: (ByteArray) -> Unit) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val input = Channel(Channel.UNLIMITED) + private val output = Channel(Channel.UNLIMITED) + private var client: KiloClientConnection? = null + + fun receiveBlock(bytes: ByteArray) { check(input.trySend(bytes.toUByteArray()).isSuccess) } + fun connect(expectedPublicKeyHex: String): Promise = scope.promise { + initCrypto() + val local = KiloInterface() + val connection = KiloClientConnection(local, ProxyDevice(input, output), Unit, SigningSecretKey.new()) + client = connection + scope.launch { for (block in output) sendBlock(block.asByteArray()) } + scope.launch { connection.run() } + val actual = connection.remoteId()?.keyBytes?.joinToString("") { it.toString(16).padStart(2, '0') } + if (actual != expectedPublicKeyHex) { close(); error("Unexpected server identity") } + } + fun echo(text: String): Promise = scope.promise { + val echo by command() + requireNotNull(client).call(echo, text) + } + fun close() { scope.cancel() } +} +fun main() {} + +/** Evaluation-only cold startup workload: initialize sodium and generate one identity. */ +@JsExport +fun initializeIdentity(): Promise = CoroutineScope(Dispatchers.Default).promise { + initCrypto() + SigningSecretKey.new().verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') } +} diff --git a/typescript/evaluation/build.gradle.kts b/typescript/evaluation/build.gradle.kts new file mode 100644 index 0000000..647a2ce --- /dev/null +++ b/typescript/evaluation/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + kotlin("jvm") version "2.2.21" apply false + kotlin("multiplatform") version "2.2.21" apply false + kotlin("plugin.serialization") version "2.2.21" apply false +} +allprojects { + repositories { + mavenLocal(); mavenCentral() + maven("https://maven.universablockchain.com/") + maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven") + maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven") + } +} diff --git a/typescript/evaluation/package-lock.json b/typescript/evaluation/package-lock.json new file mode 100644 index 0000000..6d61b6b --- /dev/null +++ b/typescript/evaluation/package-lock.json @@ -0,0 +1,63 @@ +{ + "name": "evaluation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@js-joda/core": "3.2.0", + "format-util": "1.0.5", + "libsodium-wrappers-sumo": "0.7.15", + "ws": "8.18.0" + } + }, + "node_modules/@js-joda/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-3.2.0.tgz", + "integrity": "sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==", + "license": "BSD-3-Clause" + }, + "node_modules/format-util": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz", + "integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==", + "license": "MIT" + }, + "node_modules/libsodium-sumo": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz", + "integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz", + "integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.7.15" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/typescript/evaluation/package.json b/typescript/evaluation/package.json new file mode 100644 index 0000000..3ec6fc0 --- /dev/null +++ b/typescript/evaluation/package.json @@ -0,0 +1 @@ +{"private":true,"dependencies":{"@js-joda/core":"3.2.0","libsodium-wrappers-sumo":"0.7.15","format-util":"1.0.5","ws":"8.18.0"}} diff --git a/typescript/evaluation/server/build.gradle.kts b/typescript/evaluation/server/build.gradle.kts new file mode 100644 index 0000000..306b8f7 --- /dev/null +++ b/typescript/evaluation/server/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { kotlin("jvm"); kotlin("plugin.serialization"); application } +kotlin { jvmToolchain(21); compilerOptions { optIn.add("kotlin.ExperimentalUnsignedTypes") } } +dependencies { + implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") + implementation("io.ktor:ktor-server-netty:3.1.1") + implementation("org.slf4j:slf4j-simple:2.0.17") +} +application { mainClass.set("InteropServerKt") } diff --git a/typescript/evaluation/server/src/main/kotlin/InteropServer.kt b/typescript/evaluation/server/src/main/kotlin/InteropServer.kt new file mode 100644 index 0000000..24bf849 --- /dev/null +++ b/typescript/evaluation/server/src/main/kotlin/InteropServer.kt @@ -0,0 +1,36 @@ +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import kotlinx.coroutines.runBlocking +import net.sergeych.crypto2.* +import net.sergeych.kiloparsec.* +import net.sergeych.kiloparsec.adapter.setupWebsocketServer + +// Public reproducible TEST KEY ONLY. Never use this identity outside tests. +fun main(args: Array) = runBlocking { + initCrypto() + val key = SigningSecretKey(("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8").chunked(2).map { it.toUByte(16) }.toUByteArray()) + val echo by command() + val sum by command, Int>() + val identity by command() + val callback by command() + val clientEcho by command() + val push by command() + val notice by command() + val fail by command() + val api = KiloInterface().apply { + on(echo) { it } + on(sum) { it.sum() } + on(identity) { remoteIdentity?.keyBytes?.joinToString("") { b -> b.toString(16).padStart(2, '0') } ?: "anonymous" } + on(callback) { remote.call(clientEcho, it) } + on(push) { remote.call(notice, it) } + on(fail) { throw IllegalArgumentException(it) } + } + val port = args.firstOrNull()?.toInt() ?: 18732 + println("TEST_SERVER_PUBLIC_KEY_HEX=" + key.verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') }) + println("TEST_SERVER_URL=ws://127.0.0.1:$port/kp") + embeddedServer(Netty, port = port, host = "127.0.0.1") { + setupWebsocketServer(api, serverKey = key) { Unit } + }.start(wait = true) + Unit +} diff --git a/typescript/evaluation/settings.gradle.kts b/typescript/evaluation/settings.gradle.kts new file mode 100644 index 0000000..5deb8b5 --- /dev/null +++ b/typescript/evaluation/settings.gradle.kts @@ -0,0 +1,10 @@ +pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } } +rootProject.name = "kiloparsec-evaluation" +include("server", "baseline", "wasm-baseline") + +// Both fixtures resolve this checkout, never a stale published snapshot. +includeBuild("../..") { + dependencySubstitution { + substitute(module("net.sergeych:kiloparsec")).using(project(":")) + } +} diff --git a/typescript/evaluation/wasm-baseline/build.gradle.kts b/typescript/evaluation/wasm-baseline/build.gradle.kts new file mode 100644 index 0000000..0a743cb --- /dev/null +++ b/typescript/evaluation/wasm-baseline/build.gradle.kts @@ -0,0 +1,8 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { kotlin("multiplatform"); kotlin("plugin.serialization") } +kotlin { + @OptIn(ExperimentalWasmDsl::class) + wasmJs { nodejs(); binaries.executable() } + sourceSets { wasmJsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") } } +} diff --git a/typescript/evaluation/wasm-baseline/src/wasmJsMain/kotlin/Baseline.kt b/typescript/evaluation/wasm-baseline/src/wasmJsMain/kotlin/Baseline.kt new file mode 100644 index 0000000..2e324f7 --- /dev/null +++ b/typescript/evaluation/wasm-baseline/src/wasmJsMain/kotlin/Baseline.kt @@ -0,0 +1,57 @@ +@file:OptIn(kotlin.js.ExperimentalJsExport::class, kotlin.ExperimentalUnsignedTypes::class) + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import net.sergeych.crypto2.* +import net.sergeych.kiloparsec.* +import net.sergeych.kiloparsec.adapter.ProxyDevice +import kotlin.js.JsExport + +private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +private var input = Channel(Channel.UNLIMITED) +private var client: KiloClientConnection? = null +private fun UByteArray.hex() = joinToString("") { it.toString(16).padStart(2, '0') } +private fun String.bytes() = chunked(2).map { it.toUByte(16) }.toUByteArray() + +/** Full-protocol evaluation bridge. Hex avoids exposing Kotlin objects to JS. */ +@JsExport +fun start(expectedPublicKeyHex: String, sendBlock: (String) -> Unit, onReady: () -> Unit, onError: (String) -> Unit) { + scope.launch { + try { + initCrypto() + val output = Channel(Channel.UNLIMITED) + val connection = KiloClientConnection(KiloInterface(), ProxyDevice(input, output), Unit, SigningSecretKey.new()) + client = connection + scope.launch { for (block in output) sendBlock(block.hex()) } + scope.launch { connection.run() } + if (connection.remoteId()?.keyBytes?.hex() != expectedPublicKeyHex) error("Unexpected server identity") + onReady() + } catch (e: Throwable) { onError(e.toString()) } + } +} + +@JsExport +fun receiveBlock(hex: String) { check(input.trySend(hex.bytes()).isSuccess) } + +@JsExport +fun echo(text: String, onResult: (String) -> Unit, onError: (String) -> Unit) { + scope.launch { + try { + val echo by command() + onResult(requireNotNull(client).call(echo, text)) + } catch (e: Throwable) { onError(e.toString()) } + } +} + +@JsExport +fun close() { scope.cancel() } + +/** Same startup workload as the JS and TS comparison: sodium plus one identity. */ +@JsExport +fun initializeIdentity(onResult: (String) -> Unit, onError: (String) -> Unit) { + scope.launch { + try { initCrypto(); onResult(SigningSecretKey.new().verifyingKey.keyBytes.hex()) } + catch (e: Throwable) { onError(e.toString()) } + } +} +fun main() {} diff --git a/typescript/evaluation/wasm-entry.mjs b/typescript/evaluation/wasm-entry.mjs new file mode 100644 index 0000000..7a93a2e --- /dev/null +++ b/typescript/evaluation/wasm-entry.mjs @@ -0,0 +1,12 @@ +// Sodium must be initialized before instantiating Kotlin/Wasm bindings. +import sodiumModules from './wasm-sodium.cjs'; +const { sodium, rawSodium } = sodiumModules; +import * as joda from '@js-joda/core'; +import { instantiate } from './wasm-baseline/build/compileSync/wasmJs/main/productionExecutable/kotlin/kiloparsec-evaluation-wasm-baseline.uninstantiated.mjs'; +await sodium.ready; +const { exports: api } = await instantiate({ + '@js-joda/core': joda, + 'libsodium-sumo': rawSodium, + 'libsodium-wrappers-sumo': { default: sodium }, +}); +export { api }; diff --git a/typescript/evaluation/wasm-smoke.mjs b/typescript/evaluation/wasm-smoke.mjs new file mode 100644 index 0000000..021f2e6 --- /dev/null +++ b/typescript/evaluation/wasm-smoke.mjs @@ -0,0 +1,43 @@ +// Disposable localhost interop of the measured optimized Wasm distribution. +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { setTimeout as pause } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { WebSocket } from 'ws'; +const listener = net.createServer(); +await new Promise((resolve,reject)=>listener.once('error',reject).listen(0,'127.0.0.1',resolve)); +const port=listener.address().port; +await new Promise(resolve=>listener.close(resolve)); +const server=spawn(fileURLToPath(new URL('./server/build/install/server/bin/server',import.meta.url)),[String(port)],{stdio:['ignore','pipe','pipe']}); +let diagnostics=''; let exited=false; let socket; let api; +server.stdout.on('data',b=>{ diagnostics=(diagnostics+b).slice(-8000); }); +server.stderr.on('data',b=>{ diagnostics=(diagnostics+b).slice(-8000); }); +server.on('exit',()=>{exited=true;}); +try { + let ready=false; + for(let n=0;n<100&&!exited;n++) { + ready=await new Promise(resolve=>{const s=net.connect(port,'127.0.0.1');s.once('connect',()=>{s.destroy();resolve(true);});s.once('error',()=>resolve(false));}); + if(ready) break; + await pause(100); + } + if(!ready) throw Error(`Fixture did not start: ${diagnostics}`); + ({api}=await import('./build/measurement/wasm/client.mjs')); + socket=new WebSocket(`ws://127.0.0.1:${port}/kp`); + socket.on('message',bytes=>api.receiveBlock(Buffer.from(bytes).toString('hex'))); + await new Promise((resolve,reject)=>{ + const timeout=setTimeout(()=>reject(Error('Wasm handshake timeout')),10000); + socket.once('error',reject); + socket.once('open',()=>api.start('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8', + hex=>socket.send(Buffer.from(hex,'hex')),()=>{clearTimeout(timeout);resolve();},message=>{clearTimeout(timeout);reject(Error(message));})); + }); + const result=await new Promise((resolve,reject)=>{ + const timeout=setTimeout(()=>reject(Error('Wasm echo timeout')),10000); + api.echo('Kotlin Wasm baseline ✓',value=>{clearTimeout(timeout);resolve(value);},message=>{clearTimeout(timeout);reject(Error(message));}); + }); + if(result!=='Kotlin Wasm baseline ✓') throw Error('Wrong echo'); + console.log('WASM_BASELINE_AUTHENTICATED_PINNED_ECHO_OK'); +} catch(e) { console.error(String(e));process.exitCode=1; } +finally { + api?.close(); socket?.terminate(); server.kill('SIGTERM'); + if(!exited){const timer=setTimeout(()=>server.kill('SIGKILL'),3000);await new Promise(resolve=>server.once('exit',resolve));clearTimeout(timer);} +} diff --git a/typescript/evaluation/wasm-sodium.cjs b/typescript/evaluation/wasm-sodium.cjs new file mode 100644 index 0000000..539c23f --- /dev/null +++ b/typescript/evaluation/wasm-sodium.cjs @@ -0,0 +1,3 @@ +// Match the wrapper's CommonJS import path to retain a single sodium instance. +exports.sodium = require('libsodium-wrappers-sumo'); +exports.rawSodium = require('libsodium-sumo'); diff --git a/typescript/package-lock.json b/typescript/package-lock.json new file mode 100644 index 0000000..ed024fd --- /dev/null +++ b/typescript/package-lock.json @@ -0,0 +1,577 @@ +{ + "name": "@sergeych/kiloparsec", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sergeych/kiloparsec", + "version": "0.8.0", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "libsodium-wrappers-sumo": "0.7.15" + }, + "devDependencies": { + "@types/libsodium-wrappers-sumo": "0.7.8", + "esbuild": "0.25.12", + "typescript": "5.9.3", + "ws": "8.18.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/libsodium-wrappers": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz", + "integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/libsodium-wrappers-sumo": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.8.tgz", + "integrity": "sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/libsodium-wrappers": "*" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/libsodium-sumo": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz", + "integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz", + "integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.7.15" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 0000000..f0a4553 --- /dev/null +++ b/typescript/package.json @@ -0,0 +1,49 @@ +{ + "name": "@sergeych/kiloparsec", + "version": "0.8.0", + "private": true, + "type": "module", + "description": "TypeScript client for the full Kiloparsec protocol with libsodium WASM", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "docs", + "evaluation/measurements.json", + "evaluation/artifacts.json", + "evaluation/measurements-v2.json" + ], + "scripts": { + "build": "tsc", + "test": "npm run build && node --test test/*.test.mjs", + "test:interop": "npm run build && npm run fixture:build && node scripts/interop.mjs", + "measure": "npm run build && node scripts/measure.mjs", + "fixture:build": "../gradlew -p evaluation :server:installDist", + "test:all": "npm test && npm run test:interop", + "baseline:build": "../gradlew -p evaluation :baseline:jsProductionExecutableCompileSync", + "wasm:build": "../gradlew -p evaluation :wasm-baseline:wasmJsProductionExecutableCompileSync", + "measure:wasm": "node scripts/measure-wasm.mjs", + "test:wasm-baseline": "node evaluation/wasm-smoke.mjs" + }, + "engines": { + "node": ">=22" + }, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "libsodium-wrappers-sumo": "0.7.15" + }, + "devDependencies": { + "typescript": "5.9.3", + "esbuild": "0.25.12", + "@types/libsodium-wrappers-sumo": "0.7.8", + "ws": "8.18.3" + } +} diff --git a/typescript/scripts/interop.mjs b/typescript/scripts/interop.mjs new file mode 100644 index 0000000..dc2637c --- /dev/null +++ b/typescript/scripts/interop.mjs @@ -0,0 +1,39 @@ +// Starts only the disposable localhost fixture; never connects to deployment hosts. +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import { setTimeout as pause } from 'node:timers/promises'; +import { existsSync } from 'node:fs'; +const executable = new URL('../evaluation/server/build/install/server/bin/server', import.meta.url).pathname; +if (!existsSync(executable)) throw Error('Build the fixture first: npm run fixture:build'); +const listener = net.createServer(); +await new Promise((resolve, reject) => listener.once('error', reject).listen(0, '127.0.0.1', resolve)); +const port = listener.address().port; +await new Promise(resolve => listener.close(resolve)); +const server = spawn(executable, [String(port)], { stdio: ['ignore', 'pipe', 'pipe'] }); +let diagnostics = ''; let exited = false; +server.stdout.on('data', b => { diagnostics = (diagnostics + b).slice(-8000); }); +server.stderr.on('data', b => { diagnostics = (diagnostics + b).slice(-8000); }); +server.on('exit', () => { exited = true; }); +server.on('error', e => { diagnostics += e.message; exited = true; }); +try { + let ready = false; + for (let n = 0; n < 100 && !exited; n++) { + ready = await new Promise(resolve => { + const socket = net.connect(port, '127.0.0.1'); + socket.once('connect', () => { socket.destroy(); resolve(true); }); + socket.once('error', () => resolve(false)); + }); + if (ready) break; + await pause(100); + } + if (!ready) throw Error(`Local JVM fixture did not start:\n${diagnostics}`); + const run = spawn(process.execPath, ['--test', 'test/interop.test.mjs'], { stdio: 'inherit', env: { ...process.env, KILOPARSEC_TEST_URL: `ws://127.0.0.1:${port}/kp` } }); + process.exitCode = await new Promise(resolve => run.once('exit', code => resolve(code ?? 1))); +} finally { + server.kill('SIGTERM'); + if (!exited) { + const hardStop = setTimeout(() => server.kill('SIGKILL'), 3000); + await new Promise(resolve => { if (exited) resolve(); else server.once('exit', resolve); }); + clearTimeout(hardStop); + } +} diff --git a/typescript/scripts/measure-wasm.mjs b/typescript/scripts/measure-wasm.mjs new file mode 100644 index 0000000..13d6ec5 --- /dev/null +++ b/typescript/scripts/measure-wasm.mjs @@ -0,0 +1,51 @@ +/** Optimized Kotlin/Wasm baseline; includes all required JS and sodium WASM. */ +import { build } from 'esbuild'; +import { createHash } from 'node:crypto'; +import { mkdir, copyFile, readFile, writeFile } from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const output = path.join(root, 'evaluation/build/measurement/wasm'); +await mkdir(output, { recursive: true }); +await build({ + absWorkingDir: root, entryPoints: ['evaluation/wasm-entry.mjs'], + outfile: path.join(output, 'client.mjs'), bundle: true, minify: true, + platform: 'node', format: 'esm', sourcemap: false, + banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const __filename = import.meta.filename; const __dirname = import.meta.dirname;" }, +}); +const wasmName = 'kiloparsec-evaluation-wasm-baseline.wasm'; +await copyFile(path.join(root, 'evaluation/wasm-baseline/build/compileSync/wasmJs/main/productionExecutable/optimized', wasmName), path.join(output, wasmName)); +const assets = []; +for (const name of ['client.mjs', wasmName]) { + const bytes = await readFile(path.join(output, name)); + assets.push({ path: `evaluation/build/measurement/wasm/${name}`, sha256: createHash('sha256').update(bytes).digest('hex'), rawBytes: bytes.length, gzipBytes: gzipSync(bytes, { level: 9 }).length }); +} +const samples = []; +for (let i = 0; i < 5; i++) { + const program = `const before=process.memoryUsage().rss; const start=performance.now(); +try { const {api}=await import(${JSON.stringify(pathToFileURL(path.join(output, 'client.mjs')).href)}); +await new Promise((resolve,reject)=>api.initializeIdentity(resolve,reject)); api.close(); +console.log(JSON.stringify({milliseconds:performance.now()-start,rss:process.memoryUsage().rss,rssDelta:process.memoryUsage().rss-before})); +} catch(e) { console.error(String(e)); process.exitCode=1; }`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', program], { encoding: 'utf8', timeout: 15000 }); + if (child.status !== 0) throw Error(child.stderr || String(child.error)); + samples.push(JSON.parse(child.stdout.trim().split('\n').at(-1))); +} +const median = key => samples.map(s => s[key]).sort((a,b)=>a-b)[2]; +const report = { + measuredAt: new Date().toISOString(), node: process.version, platform: process.platform, + architecture: process.arch, kotlin: '2.2.21', sodiumWrapper: '0.7.15', rawSodium: '0.7.16', sourceVersion: '0.8.0-SNAPSHOT', protocol: 2, + notes: [ + 'Actual optimized production Kotlin/Wasm executable, not klib or test runtime.', + 'Minified Node ESM loader includes all sodium and joda dependencies; Kotlin wasm is a separate asset.', + 'Sodium WASM is embedded in client.mjs. Gzip level9 is applied independently to each transferred asset and summed.', + 'Full DH, expected server identity pin, client identity and echo RPC retained. Export bridge uses strings/callbacks.', + 'Cold fresh Node process per sample, module loading plus sodium init plus one new Ed25519 identity; process startup excluded.', + ], assets, + rawBytes: assets.reduce((n,a)=>n+a.rawBytes,0), gzipBytes: assets.reduce((n,a)=>n+a.gzipBytes,0), + medianMilliseconds: median('milliseconds'), medianRssBytes: median('rss'), medianRssDeltaBytes: median('rssDelta'), samples, +}; +await writeFile(path.join(output, 'results.json'), JSON.stringify(report,null,2)+'\n'); +console.log(JSON.stringify(report,null,2)); diff --git a/typescript/scripts/measure.mjs b/typescript/scripts/measure.mjs new file mode 100644 index 0000000..6c37b8b --- /dev/null +++ b/typescript/scripts/measure.mjs @@ -0,0 +1,36 @@ +/** Same-platform bundled measurements; run `npm run build` and Kotlin production build first. */ +import { build } from 'esbuild'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { gzipSync } from 'node:zlib'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +const root = fileURLToPath(new URL('../', import.meta.url)); +const output = path.join(root, 'evaluation/build/measurement'); +await mkdir(output, { recursive: true }); +const entries = { ts: 'dist/index.js', kotlin: 'evaluation/baseline-entry.cjs' }; +const report = { protocol: 2, sourceVersion: '0.8.0-SNAPSHOT', measuredAt: new Date().toISOString(), node: process.version, platform: process.platform, architecture: process.arch, sodiumWrapper: '0.7.15', notes: [ + 'Node CJS bundles, same esbuild settings: bundle/minify/platform=node; gzip level9, no source maps.', + 'Both include sodium WASM. TS includes all exported API; Kotlin retains only baseline raw bridge, identity init and echo API.', + 'Cold fresh Node subprocess per sample; elapsed begins immediately before module require, includes WASM init and one new Ed25519 identity.', + 'RSS includes Node VM; delta measures process resident growth after workload. Five samples, use medians; no process startup time included.', + 'These figures compare retained subsets, not full feature equivalence or proof of browser runtime compatibility.' +], artifacts: {} }; +for (const [name, entry] of Object.entries(entries)) { + const outfile = path.join(output, `${name}.cjs`); + await build({ absWorkingDir: root, entryPoints: [entry], outfile, bundle: true, minify: true, platform: 'node', format: 'cjs', sourcemap: false, logLevel: 'warning' }); + const data = await readFile(outfile); + const samples = []; + for (let i = 0; i < 5; i++) { + const program = `const before=process.memoryUsage().rss; const start=performance.now(); +(async()=>{const m=require(${JSON.stringify(outfile)}); ${name === 'ts' ? 'await m.generateSigningKey();' : 'const api=await m.load(); await api.initializeIdentity();'} +console.log(JSON.stringify({milliseconds:performance.now()-start,rss:process.memoryUsage().rss,rssDelta:process.memoryUsage().rss-before}));})().catch(e=>{console.error(e);process.exitCode=1});`; + const child = spawnSync(process.execPath, ['-e', program], { encoding: 'utf8', timeout: 15000 }); + if (child.status !== 0) throw Error(`${name}: ${child.stderr || child.error}`); + samples.push(JSON.parse(child.stdout.trim().split('\n').at(-1))); + } + const median = key => samples.map(s => s[key]).sort((a,b)=>a-b)[2]; + report.artifacts[name] = { rawBytes: data.length, gzipBytes: gzipSync(data, { level: 9 }).length, medianMilliseconds: median('milliseconds'), medianRssBytes: median('rss'), medianRssDeltaBytes: median('rssDelta'), samples }; +} +await writeFile(path.join(output, 'results.json'), JSON.stringify(report, null, 2) + '\n'); +console.log(JSON.stringify(report, null, 2)); diff --git a/typescript/src/bipack.ts b/typescript/src/bipack.ts new file mode 100644 index 0000000..beab2e0 --- /dev/null +++ b/typescript/src/bipack.ts @@ -0,0 +1,144 @@ +/** Minimal bounded BiPack primitives, compatible with mp_bintools Smartint. + * Signed numbers use sign-magnitude, not protobuf zigzag. No Kotlin runtime is needed. + */ +export class BipackError extends Error {} +export const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; +const U64_MAX = (1n << 64n) - 1n; +const I64_MAX = (1n << 63n) - 1n; +const utf8 = new TextEncoder(); +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); + +/** Writer with a total output limit. Byte arrays and strings have unsigned Smartint lengths. */ +export class BipackWriter { + private data: number[] = []; + constructor(readonly maxBytes = DEFAULT_MAX_BYTES) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new BipackError('Invalid size limit'); + } + byte(value: number): this { + if (!Number.isInteger(value) || value < 0 || value > 255) throw new BipackError('Invalid byte'); + if (this.data.length >= this.maxBytes) throw new BipackError('Output limit exceeded'); + this.data.push(value); return this; + } + u64(value: bigint): this { + if (value < 0n || value > U64_MAX) throw new BipackError('Unsigned 64-bit overflow'); + const type = value < 64n ? 0 : value < 16384n ? 1 : value < 4194304n ? 2 : 3; + this.byte(Number((value & 63n) << 2n) | type); + if (type >= 1) this.byte(Number((value >> 6n) & 255n)); + if (type >= 2) this.byte(Number((value >> 14n) & 255n)); + if (type === 3) { + let tail = value >> 22n; + do { const b = Number(tail & 127n); tail >>= 7n; this.byte(b | (tail > 0n ? 128 : 0)); } while (tail > 0n); + } + return this; + } + i64(value: bigint): this { + // Long.MIN_VALUE is not representable in Kotlin's sign-magnitude codec: reject instead of losing data. + if (value < -I64_MAX || value > I64_MAX) throw new BipackError('Signed BiPack 64-bit overflow'); + return this.u64(value < 0n ? ((-value) << 1n) | 1n : value << 1n); + } + u32(value: number): this { + if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new BipackError('Unsigned 32-bit overflow'); + return this.u64(BigInt(value)); + } + i32(value: number): this { + if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) throw new BipackError('Signed 32-bit overflow'); + return this.i64(BigInt(value)); + } + bool(value: boolean): this { return this.byte(value ? 1 : 0); } + bytes(value: Uint8Array): this { + this.u32(value.length); + if (value.length > this.maxBytes - this.data.length) throw new BipackError('Output limit exceeded'); + for (const b of value) this.data.push(b); + return this; + } + string(value: string): this { + if (value.length > this.maxBytes) throw new BipackError('String limit exceeded'); + return this.bytes(utf8.encode(value)); + } + nullable(value: T | null, write: (writer: BipackWriter, value: T) => void): this { + this.bool(value !== null); if (value !== null) write(this, value); return this; + } + finish(): Uint8Array { return Uint8Array.from(this.data); } +} + +/** Strict reader: rejects truncation, overflow, invalid UTF-8, noncanonical integers and trailing bytes. */ +export class BipackReader { + private offset = 0; + constructor(private readonly data: Uint8Array, readonly maxBytes = DEFAULT_MAX_BYTES) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || data.length > maxBytes) throw new BipackError('Input limit exceeded'); + } + get remaining(): number { return this.data.length - this.offset; } + byte(): number { + if (this.offset >= this.data.length) throw new BipackError('Truncated BiPack'); + return this.data[this.offset++]!; + } + u64(): bigint { + const first = this.byte(), type = first & 3; + let result = BigInt(first >> 2); + if (type >= 1) result |= BigInt(this.byte()) << 6n; + if (type >= 2) result |= BigInt(this.byte()) << 14n; + if (type === 3) { + let shift = 22n; + for (;;) { + const b = this.byte(); + if (shift >= 64n || (shift === 57n && (b & 128) !== 0)) throw new BipackError('Unsigned 64-bit overflow'); + result |= BigInt(b & 127) << shift; + if ((b & 128) === 0) { + if (shift > 22n && (b & 127) === 0) throw new BipackError('Noncanonical integer'); + break; + } + shift += 7n; + } + } + if ((type === 1 && result < 64n) || (type === 2 && result < 16384n) || (type === 3 && result < 4194304n)) throw new BipackError('Noncanonical integer'); + return result; + } + i64(): bigint { + const value = this.u64(); + if (value === 1n) throw new BipackError('Negative zero is not canonical'); + return (value & 1n) !== 0n ? -(value >> 1n) : value >> 1n; + } + u32(): number { const v = this.u64(); if (v > 0xffffffffn) throw new BipackError('Unsigned 32-bit overflow'); return Number(v); } + i32(): number { const v = this.i64(); if (v < -2147483648n || v > 2147483647n) throw new BipackError('Signed 32-bit overflow'); return Number(v); } + bool(): boolean { const b = this.byte(); if (b > 1) throw new BipackError('Invalid boolean'); return b === 1; } + bytes(): Uint8Array { + const size = this.u32(); + if (size > this.remaining) throw new BipackError('Truncated byte array'); + const value = this.data.slice(this.offset, this.offset + size); this.offset += size; return value; + } + string(): string { try { return utf8Decoder.decode(this.bytes()); } catch (e) { if (e instanceof BipackError) throw e; throw new BipackError('Invalid UTF-8'); } } + nullable(read: (reader: BipackReader) => T): T | null { return this.bool() ? read(this) : null; } + end(): void { if (this.remaining !== 0) throw new BipackError('Trailing BiPack bytes'); } +} + +/** Explicit schema boundary for a command argument or result. */ +export interface Codec { encode(value: T): Uint8Array; decode(data: Uint8Array): T; } +/** Build a codec from field-order readers/writers. Decoding requires consumption of the entire value. */ +export interface ValueCodec extends Codec { write(w: BipackWriter, value: T): void; read(r: BipackReader): T; } +export function codec(write: (w: BipackWriter, v: T) => void, read: (r: BipackReader) => T): ValueCodec { + return { write, read, encode(v) { const w = new BipackWriter(); write(w, v); return w.finish(); }, decode(data) { const r = new BipackReader(data); const v = read(r); r.end(); return v; } }; +} +export const uint32 = codec((w,v) => { w.u32(v); }, r => r.u32()); +export const uint64 = codec((w,v) => { w.u64(v); }, r => r.u64()); +export const int32 = codec((w,v) => { w.i32(v); }, r => r.i32()); +export const int64 = codec((w,v) => { w.i64(v); }, r => r.i64()); +export const bytes = codec((w,v) => { w.bytes(v); }, r => r.bytes()); +export const string = codec((w,v) => { w.string(v); }, r => r.string()); +export const boolean = codec((w,v) => { w.bool(v); }, r => r.bool()); +export const unit = codec(() => {}, () => undefined); + +/** Kotlin List/Array field layout. Bounds also limit arrays of zero-byte Unit values. */ +export function array(element: ValueCodec, maxItems = 100_000): ValueCodec { + if (!Number.isSafeInteger(maxItems) || maxItems < 0) throw new BipackError('Invalid array limit'); + return codec((w, values) => { + if (values.length > maxItems) throw new BipackError('Array limit exceeded'); + w.u32(values.length); for (const value of values) element.write(w, value); + }, r => { + const count = r.u32(); if (count > maxItems) throw new BipackError('Array limit exceeded'); + const values: T[] = []; for (let i = 0; i < count; i++) values.push(element.read(r)); return values; + }); +} +/** Kotlin nullable field layout: one presence byte followed by the value without another frame. */ +export function nullable(element: ValueCodec): ValueCodec { + return codec((w,v) => { w.nullable(v, element.write); }, r => r.nullable(element.read)); +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts new file mode 100644 index 0000000..4524fb3 --- /dev/null +++ b/typescript/src/client.ts @@ -0,0 +1,328 @@ +import { bytes, int64, type Codec } from './bipack.js'; +import { blockCodec, handshakeCodec, clientIdentityCodec, encryptedPackageCodec, commandCallCodec, sealedDataCodec, protectedMessageCodec, authenticationMessage, type Block } from './wire.js'; +import { createKeyExchange, verifySignature, type SessionCrypto } from './crypto.js'; + +/** A named, typed command. Codecs must match the Kotlin serializers exactly. */ +export interface Command { readonly name: string; readonly args: Codec; readonly result: Codec } +export function command(name: string, args: Codec, result: Codec): Command { + if (!name || name.startsWith('L0')) throw new TypeError('Invalid application command name'); + return Object.freeze({ name, args, result }); +} +/** Local signing device; only public material is sent to the peer. */ +export interface Identity { readonly publicKey: Uint8Array; sign(message: Uint8Array): Uint8Array | Promise } +export class ConnectionError extends Error { + constructor(readonly code: 'closed' | 'timeout' | 'aborted' | 'protocol' | 'authentication' | 'overloaded', message = code as string) { super(message); this.name = 'ConnectionError'; } +} +/** An authenticated remote error. The code and optional extra bytes come from the peer. */ +export class RemoteError extends Error { + constructor(readonly code: string, message = code, readonly extra: Uint8Array | null = null) { super(message); this.name = 'RemoteError'; } +} +export interface CallOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly priority?: number } +export interface Scope { + readonly session: S; + readonly remote: Client; + readonly remoteIdentity: Uint8Array; + readonly sessionToken: Uint8Array; +} +type Handler = (scope: Scope, payload: Uint8Array) => Promise; +/** Declare callbacks before connecting. Each connection snapshots this registry. */ +export class LocalInterface { + #handlers = new Map>(); + on(cmd: Command, handler: (scope: Scope, args: A) => R | Promise): this { + if (this.#handlers.has(cmd.name)) throw new TypeError(`Duplicate command: ${cmd.name}`); + this.#handlers.set(cmd.name, async (scope, payload) => cmd.result.encode(await handler(scope, cmd.args.decode(payload)))); + return this; + } + /** @internal */ snapshot(): ReadonlyMap> { return new Map(this.#handlers); } +} +export interface ConnectOptions { + /** Trusted Ed25519 server key obtained independently of this connection. Required; no trust-on-first-use fallback. */ + readonly expectedServerKey: Uint8Array; + readonly identity?: Identity; + readonly local?: LocalInterface; + readonly session: S; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + readonly maxPacketBytes?: number; + readonly maxPendingCalls?: number; + readonly maxIncomingCalls?: number; + /** Interval for encrypted heartbeat; 0 disables. Default 30 seconds. */ + readonly heartbeatMs?: number; + /** For a host-provided WebSocket implementation. Must implement the standard binary WebSocket API. */ + readonly socketFactory?: (url: string) => WebSocket; +} +type Pending = { resolve(value: Uint8Array): void; reject(error: unknown): void; cleanup(): void }; +const pingCommand = command('@kiloparsec/ping', int64, int64); +const empty = new Uint8Array(); +function bound(value: number, label: string, min = 1): number { + if (!Number.isSafeInteger(value) || value < min) throw new TypeError(`Invalid ${label}`); + return value; +} +function same(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let result = 0; for (let i = 0; i < a.length; i++) result |= a[i]! ^ b[i]!; + return result === 0; +} + +/** + * One full Kiloparsec connection. Reconnection creates a fresh client and DH session. + * Calls are never automatically replayed: transport IDs are not business idempotency IDs. + */ +export class Client { + #socket: WebSocket; + #crypto?: SessionCrypto; + #token: Uint8Array = empty; + #remoteIdentity: Uint8Array = empty; + #nonce = 0n; + #highestReceived = -1n; + #received = new Set(); + #nextId = 1; + #pending = new Map(); + #incoming = new Set(); + #incomingCount = 0; + #handlers: ReadonlyMap>; + #closed = false; + #closeReason: unknown = new ConnectionError('closed'); + #ready = false; + #heartbeat?: ReturnType; + #heartbeatBusy = false; + #finishClosed!: () => void; + /** Resolves after local connection disposal. No rejected Promise to forget to observe. */ + readonly closed: Promise; + readonly session: S; + readonly maxPacketBytes: number; + readonly maxPendingCalls: number; + readonly maxIncomingCalls: number; + readonly timeoutMs: number; + #onMessage = (event: MessageEvent) => { + try { + if (!(event.data instanceof ArrayBuffer)) throw new ConnectionError('protocol', 'Expected binary WebSocket message'); + if (event.data.byteLength > this.maxPacketBytes) throw new ConnectionError('protocol', 'Packet too large'); + this.#receive(blockCodec.decode(new Uint8Array(event.data))); + } catch { this.#fail(new ConnectionError('protocol', 'Invalid peer packet')); } + }; + #onClose = () => this.#fail(new ConnectionError('closed')); + #onError = () => this.#fail(new ConnectionError('closed', 'WebSocket failed')); + + private constructor(socket: WebSocket, options: ConnectOptions) { + this.#socket = socket; + this.session = options.session; + this.#handlers = options.local?.snapshot() ?? new Map(); + this.maxPacketBytes = bound(options.maxPacketBytes ?? 8 * 1024 * 1024, 'maxPacketBytes'); + this.maxPendingCalls = bound(options.maxPendingCalls ?? 1024, 'maxPendingCalls'); + this.maxIncomingCalls = bound(options.maxIncomingCalls ?? 128, 'maxIncomingCalls'); + this.timeoutMs = bound(options.timeoutMs ?? 15_000, 'timeoutMs'); + this.closed = new Promise(resolve => { this.#finishClosed = resolve; }); + socket.binaryType = 'arraybuffer'; + socket.addEventListener('message', this.#onMessage); + socket.addEventListener('close', this.#onClose); + socket.addEventListener('error', this.#onError); + } + + /** Connect, authenticate the pinned server, then identify the client inside the encrypted session. */ + static async connect(url: string, options: ConnectOptions): Promise> { + const parsed = new URL(url); + if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new TypeError('Expected ws/wss URL without credentials'); + if (!(options.expectedServerKey instanceof Uint8Array) || options.expectedServerKey.length !== 32) throw new TypeError('A 32-byte trusted server key is required'); + const expected = options.expectedServerKey.slice(); + const timeout = bound(options.timeoutMs ?? 15_000, 'timeoutMs'); + const heartbeat = bound(options.heartbeatMs ?? 30_000, 'heartbeatMs', 0); + // Validate all limits before allocating the connection. + bound(options.maxPacketBytes ?? 8 * 1024 * 1024, 'maxPacketBytes'); + bound(options.maxPendingCalls ?? 1024, 'maxPendingCalls'); + bound(options.maxIncomingCalls ?? 128, 'maxIncomingCalls'); + if (options.signal?.aborted) throw new ConnectionError('aborted'); + const factory = options.socketFactory ?? (u => new WebSocket(u)); + const socket = factory(url); + const client = new Client(socket, options); + const abort = () => client.#fail(new ConnectionError('aborted')); + options.signal?.addEventListener('abort', abort, { once: true }); + const timer = setTimeout(() => client.#fail(new ConnectionError('timeout')), timeout); + try { + const exchange = await client.#whileOpen(createKeyExchange().then(exchange => { + if (client.#closed) { exchange.destroy(); throw client.#closeReason; } + return exchange; + })); + try { + await client.#waitOpen(); + const reply = handshakeCodec.decode(await client.#rpc('L0Request', handshakeCodec.encode({ version: 2, publicKey: exchange.publicKey, signature: null }), { signal: options.signal })); + if (reply.version !== 2 || !reply.signature || !same(reply.signature.publicKey, expected)) throw new ConnectionError('authentication', 'Server identity does not match'); + client.#crypto = exchange.clientSession(reply.publicKey); + client.#token = client.#crypto.token; + const seal = reply.signature; + const now = BigInt(Date.now()); + if (seal.createdAt > now + 45_000n || (seal.expiresAt !== null && now >= seal.expiresAt) || + !verifySignature(seal.publicKey, seal.signature, sealedDataCodec.encode({ message: authenticationMessage('server', client.#token), nonce: seal.nonce, createdAt: seal.createdAt, validUntil: seal.expiresAt }))) { + throw new ConnectionError('authentication', 'Invalid server signature'); + } + client.#remoteIdentity = expected; + const publicKey = options.identity?.publicKey.slice() ?? null; + if (publicKey && publicKey.length !== 32) throw new TypeError('Expected a 32-byte client public key'); + const signature = options.identity ? await client.#whileOpen(Promise.resolve(options.identity.sign(authenticationMessage('client', client.#token)))) : null; + if (signature && (!publicKey || !verifySignature(publicKey, signature, authenticationMessage('client', client.#token)))) throw new ConnectionError('authentication', 'Invalid local signature'); + // The trusted server may call us from its onConnected handler before replying. + client.#ready = true; + const identityBytes = clientIdentityCodec.encode({ clientIdKey: publicKey, signature }); + const result = await client.#rpc('L0ClientId', bytes.encode(client.#encrypt(identityBytes)), { signal: options.signal }); + const finished = protectedMessageCodec.decode(client.#decrypt(bytes.decode(result))); + if (finished.kind !== 'finished' || finished.correlation !== 0n || finished.priority !== 0 || !same(finished.payload, identityBytes)) { + throw new ConnectionError('authentication', 'Invalid server identity confirmation'); + } + if (client.#closed) throw new ConnectionError('closed'); + client.#ready = true; + if (heartbeat) client.#heartbeat = setInterval(() => { + if (client.#heartbeatBusy) return; + client.#heartbeatBusy = true; + void client.ping().catch(() => client.#fail(new ConnectionError('timeout', 'Heartbeat failed'))).finally(() => { client.#heartbeatBusy = false; }); + }, heartbeat); + return client; + } finally { exchange.destroy(); } + } catch (error) { client.#fail(error); throw error; } + finally { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); } + } + + async #whileOpen(promise: Promise): Promise { + if (this.#closed) throw this.#closeReason; + return Promise.race([promise, this.closed.then(() => { throw this.#closeReason; })]); + } + async #waitOpen(): Promise { + if (this.#socket.readyState === 1) return; + if (this.#socket.readyState !== 0 || this.#closed) throw new ConnectionError('closed'); + let open!: () => void; + try { await this.#whileOpen(new Promise(resolve => { open = resolve; this.#socket.addEventListener('open', open, { once: true }); })); } + finally { this.#socket.removeEventListener('open', open); } + } + #send(block: Block): void { + if (this.#closed || this.#socket.readyState !== 1) throw new ConnectionError('closed'); + const packet = blockCodec.encode(block); + if (packet.length > this.maxPacketBytes || this.#socket.bufferedAmount + packet.length > this.maxPacketBytes * 2) throw new ConnectionError('overloaded', 'Send buffer limit'); + this.#socket.send(packet); + } + #rpc(name: string, packedArgs: Uint8Array | ((id: number) => Uint8Array), options: CallOptions = {}): Promise { + if (this.#closed) return Promise.reject(new ConnectionError('closed')); + if (options.signal?.aborted) return Promise.reject(new ConnectionError('aborted')); + if (this.#pending.size >= this.maxPendingCalls) return Promise.reject(new ConnectionError('overloaded')); + const timeout = bound(options.timeoutMs ?? this.timeoutMs, 'timeoutMs'); + if (this.#nextId > 0xffffffff) { this.#fail(new ConnectionError('closed', 'RPC ID space exhausted')); return Promise.reject(new ConnectionError('closed')); } + const id = this.#nextId++; + return new Promise((resolve, reject) => { + const finish = (error: unknown) => { const pending = this.#pending.get(id); if (pending) { this.#pending.delete(id); pending.cleanup(); pending.reject(error); } }; + const abort = () => finish(new ConnectionError('aborted')); + const timer = setTimeout(() => finish(new ConnectionError('timeout')), timeout); + const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); }; + this.#pending.set(id, { resolve, reject, cleanup }); + options.signal?.addEventListener('abort', abort, { once: true }); + try { this.#send({ kind: 'call', id, name, packedArgs: typeof packedArgs === 'function' ? packedArgs(id) : packedArgs, priority: options.priority ?? 0 }); } + catch (error) { finish(error); } + }); + } + #receive(block: Block): void { + if (this.#closed) return; + if (block.kind !== 'call') { + const pending = this.#pending.get(block.forId); + if (!pending) return; // Late response after timeout/abort; no implicit retry. + this.#pending.delete(block.forId); pending.cleanup(); + if (block.kind === 'response') pending.resolve(block.packedResult); + else { + const error = new ConnectionError('protocol', 'Unencrypted outer RPC error'); + pending.reject(error); this.#fail(error); + } + return; + } + if (block.name !== 'L0Call' || !this.#crypto || !this.#remoteIdentity.length) throw new ConnectionError('protocol'); + if (this.#incomingCount >= this.maxIncomingCalls || (block.id !== 0 && this.#incoming.has(block.id))) throw new ConnectionError('overloaded'); + const message = protectedMessageCodec.decode(this.#decrypt(bytes.decode(block.packedArgs))); + if (message.kind !== (block.id === 0 ? 'push' : 'call') || message.correlation !== BigInt(block.id) || message.priority !== (block.priority ?? 0)) { + throw new ConnectionError('protocol', 'Unauthenticated call metadata'); + } + const call = commandCallCodec.decode(message.payload); + this.#incomingCount++; + if (block.id) this.#incoming.add(block.id); + const scope: Scope = Object.freeze({ session: this.session, remote: this, remoteIdentity: this.#remoteIdentity.slice(), sessionToken: this.#token.slice() }); + // Dispatch independently: a callback may itself await a call to the peer. + void (async () => { + let reply: Block; + try { + const handler = this.#handlers.get(call.name); + const packedResult = call.name === pingCommand.name ? int64.encode(int64.decode(call.serializedArgs)) : + handler ? await handler(scope, call.serializedArgs) : (() => { throw new RemoteError('UnknownCommand', call.name); })(); + reply = { kind: 'response', forId: 0, packedResult }; + } catch (error) { + reply = error instanceof RemoteError ? { kind: 'error', forId: 0, code: error.code, text: error.message, extra: error.extra } : + { kind: 'error', forId: 0, code: 'InternalError', text: 'Local handler failed', extra: null }; + } + if (block.id && !this.#closed) this.#send({ kind: 'response', forId: block.id, packedResult: bytes.encode(this.#encrypt(protectedMessageCodec.encode({ kind: reply.kind === 'error' ? 'error' : 'response', correlation: BigInt(block.id), priority: message.priority, payload: blockCodec.encode(reply) }))) }); + })().catch(() => this.#fail(new ConnectionError('protocol'))).finally(() => { + this.#incomingCount--; if (block.id) this.#incoming.delete(block.id); + }); + } + #encrypt(payload: Uint8Array): Uint8Array { + if (!this.#crypto || this.#closed) throw new ConnectionError('closed'); + if (this.#nonce > 0xffffffffffffffffn) { this.#fail(new ConnectionError('closed')); throw new ConnectionError('closed', 'Nonce exhausted'); } + const nonce = this.#nonce++; + return encryptedPackageCodec.encode({ nonce, encryptedMessage: this.#crypto.encrypt(nonce, payload) }); + } + #decrypt(payload: Uint8Array): Uint8Array { + if (!this.#crypto || this.#closed) throw new ConnectionError('closed'); + const pkg = encryptedPackageCodec.decode(payload); + // Responses may finish out of order. Bounded replay window fails closed when too old. + if (this.#received.has(pkg.nonce) || pkg.nonce <= this.#highestReceived - 4096n) throw new ConnectionError('protocol', 'Replayed or stale encrypted packet'); + const clear = this.#crypto.decrypt(pkg.nonce, pkg.encryptedMessage); + this.#received.add(pkg.nonce); + if (pkg.nonce > this.#highestReceived) { + this.#highestReceived = pkg.nonce; + for (const n of this.#received) if (n <= pkg.nonce - 4096n) this.#received.delete(n); + } + return clear; + } + /** Execute a typed command. Timeout/abort stops waiting, never cancels remote work. */ + async call(cmd: Command, args: A, options: CallOptions = {}): Promise { + if (!this.#ready || this.#closed) throw new ConnectionError('closed'); + if (options.signal?.aborted) throw new ConnectionError('aborted'); + const priority = options.priority ?? 0; + let requestId = 0; + const raw = await this.#rpc('L0Call', id => { + requestId = id; + return bytes.encode(this.#encrypt(protectedMessageCodec.encode({kind: 'call', correlation: BigInt(id), priority, + payload: commandCallCodec.encode({ name: cmd.name, serializedArgs: cmd.args.encode(args) }) }))); + }, options); + let reply: Block; + try { + const message = protectedMessageCodec.decode(this.#decrypt(bytes.decode(raw))); + if ((message.kind !== 'response' && message.kind !== 'error') || message.correlation !== BigInt(requestId) || message.priority !== priority) { + throw new ConnectionError('protocol', 'Response metadata does not match request'); + } + reply = blockCodec.decode(message.payload); + if (reply.kind !== message.kind || reply.forId !== 0) throw new ConnectionError('protocol', 'Invalid response envelope'); + } catch { this.#fail(new ConnectionError('protocol')); throw new ConnectionError('protocol', 'Invalid encrypted response'); } + if (reply.kind === 'error') throw new RemoteError(reply.code, reply.text ?? reply.code, reply.extra); + if (reply.kind !== 'response') throw new ConnectionError('protocol'); + return cmd.result.decode(reply.packedResult); + } + /** Send an id=0 notification. Resolution proves local enqueue only, not remote execution. */ + push(cmd: Command, args: A, priority = 0): void { + if (!this.#ready || this.#closed) throw new ConnectionError('closed'); + this.#send({ kind: 'call', id: 0, name: 'L0Call', packedArgs: bytes.encode(this.#encrypt(protectedMessageCodec.encode({ kind: 'push', correlation: 0n, priority, payload: commandCallCodec.encode({ name: cmd.name, serializedArgs: cmd.args.encode(args) }) }))), priority }); + } + /** Encrypted round trip. */ + async ping(): Promise { + const value = BigInt(Date.now()); + try { if (await this.call(pingCommand, value) !== value) throw new ConnectionError('protocol'); } + catch (error) { if (!(error instanceof RemoteError && error.code === 'UnknownCommand')) throw error; } + } + /** Dispose this connection, reject pending calls, wipe session keys. Running callbacks cannot be cancelled. */ + close(): void { this.#fail(new ConnectionError('closed')); } + #fail(error: unknown): void { + if (this.#closed) return; + this.#closed = true; this.#ready = false; this.#closeReason = error; + clearInterval(this.#heartbeat); + for (const p of this.#pending.values()) { p.cleanup(); p.reject(error); } + this.#pending.clear(); + this.#crypto?.destroy(); this.#token.fill(0); this.#received.clear(); + this.#socket.removeEventListener('message', this.#onMessage); + this.#socket.removeEventListener('close', this.#onClose); + // Keep the no-op error listener through close: some socket implementations emit asynchronously. + try { this.#socket.close(); } catch { /* already disposed */ } + this.#finishClosed(); + } +} diff --git a/typescript/src/crypto.ts b/typescript/src/crypto.ts new file mode 100644 index 0000000..3415671 --- /dev/null +++ b/typescript/src/crypto.ts @@ -0,0 +1,133 @@ +/** Crypto2 0.9.0 / Kiloparsec crypto compatibility. No alternate handshake or downgraded channel. */ +import sodium from 'libsodium-wrappers-sumo'; +import { BipackReader, BipackWriter } from './bipack.js'; + +/** Wait for libsodium's WASM initialization before using synchronous primitives. */ +export async function cryptoReady(): Promise { await sodium.ready; } +function size(bytes: Uint8Array, length: number, label: string): void { + if (bytes.length !== length) throw new RangeError(`${label} must contain ${length} bytes`); +} +function concat(...parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; for (const p of parts) { result.set(p, offset); offset += p.length; } return result; +} +const utf8 = (s: string) => new TextEncoder().encode(s); +/** Crypto2 uses BLAKE2b with a 32-byte output, not truncation of BLAKE2b-512. */ +export function blake2b(data: Uint8Array): Uint8Array { return sodium.crypto_generichash(32, data); } +export function blake2b3l(data: Uint8Array): Uint8Array { + const linked = blake2b(concat(blake2b(data), utf8('All lay loads on a willing horse'), data)); + return blake2b(concat(linked, utf8('A stitch in time saves nine'), data)); +} +/** Crypto2 BinaryId: body, magic byte, CRC8 (polynomial 0xA7). */ +export function binaryId(body: Uint8Array, magic: number): Uint8Array { + if (!Number.isInteger(magic) || magic < 0 || magic > 255) throw new RangeError('Invalid key magic'); + const result = concat(body, Uint8Array.of(magic, 0)); let crc = 0; + for (const byte of result.subarray(0, -1)) { + crc ^= byte; + for (let i = 0; i < 8; i++) crc = ((crc << 1) ^ ((crc & 128) ? 0xa7 : 0)) & 255; + } + result[result.length - 1] = crc; return result; +} +export function verifyingKeyId(publicKey: Uint8Array): Uint8Array { size(publicKey, 32, 'Public key'); return binaryId(publicKey, 4); } +/** Preserve the double triple-linked hash used by SymmetricKey.id + KeyId in crypto2. */ +export function symmetricKeyId(key: Uint8Array): Uint8Array { size(key, 32, 'Symmetric key'); return binaryId(blake2b3l(blake2b3l(key)), 2); } +/** XOR the unsigned little-endian counter into a copy of the session token. */ +export function numericNonce(base: Uint8Array, counter: bigint): Uint8Array { + if (counter < 0n || counter > 0xffffffffffffffffn) throw new RangeError('Nonce must fit ULong'); + const nonce = base.slice(); let n = counter; let i = 0; + while (n !== 0n) { + if (i >= nonce.length) throw new RangeError('Nonce base is too short'); + nonce[i] = nonce[i]! ^ Number(n & 255n); n >>= 8n; i++; + } + return nonce; +} +export function equalBytes(a: Uint8Array, b: Uint8Array): boolean { return a.length === b.length && sodium.memcmp(a, b); } +export function verifySignature(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean { + size(publicKey, 32, 'Public key'); size(signature, 64, 'Signature'); + return sodium.crypto_sign_verify_detached(signature, message, publicKey); +} +/** Local Ed25519 identity. Secret export is explicit; destroy makes further signing impossible. */ +/** Use generateSigningKey / restoreSigningKey unless cryptoReady has already completed. */ +export class SigningKey { + #secret: Uint8Array; #public: Uint8Array; #destroyed = false; + constructor(secret: Uint8Array) { + size(secret, 64, 'Secret key'); + const pair = sodium.crypto_sign_seed_keypair(secret.subarray(0, 32)); + if (!equalBytes(pair.privateKey, secret)) { sodium.memzero(pair.privateKey); throw new Error('Inconsistent signing key'); } + this.#secret = pair.privateKey; this.#public = pair.publicKey; + } + get publicKey(): Uint8Array { return this.#public.slice(); } + get keyId(): Uint8Array { return verifyingKeyId(this.#public); } + sign(message: Uint8Array): Uint8Array { this.#check(); return sodium.crypto_sign_detached(message, this.#secret); } + exportSecret(): Uint8Array { this.#check(); return this.#secret.slice(); } + destroy(): void { sodium.memzero(this.#secret); this.#destroyed = true; } + #check(): void { if (this.#destroyed) throw new Error('Signing key was destroyed'); } +} +export async function generateSigningKey(seed?: Uint8Array): Promise { + await cryptoReady(); if (seed) size(seed, 32, 'Signing seed'); + const pair = seed ? sodium.crypto_sign_seed_keypair(seed) : sodium.crypto_sign_keypair(); + try { return new SigningKey(pair.privateKey); } finally { sodium.memzero(pair.privateKey); } +} +export async function restoreSigningKey(secret: Uint8Array): Promise { await cryptoReady(); return new SigningKey(secret); } + +/** Directional session keys. Keys are private; the token is shared by the two peers. */ +export class SessionCrypto { + #sending: Uint8Array; #receiving: Uint8Array; #token: Uint8Array; #destroyed = false; + constructor(sending: Uint8Array, receiving: Uint8Array, isClient: boolean) { + size(sending, 32, 'Sending key'); size(receiving, 32, 'Receiving key'); + this.#sending = sending.slice(); this.#receiving = receiving.slice(); + const id = symmetricKeyId(isClient ? sending : receiving); + const tag = blake2b(concat(id, id)); + this.#token = blake2b(concat(utf8('token_'), tag)).slice(0, 24); + } + get token(): Uint8Array { this.#check(); return this.#token.slice(); } + encrypt(counter: bigint, message: Uint8Array): Uint8Array { + this.#check(); + const filled = encodeWithFill(message); + try { return sodium.crypto_secretbox_easy(filled, numericNonce(this.#token, counter), this.#sending); } + finally { sodium.memzero(filled); } + } + decrypt(counter: bigint, ciphertext: Uint8Array): Uint8Array { + this.#check(); + const plain = sodium.crypto_secretbox_open_easy(ciphertext, numericNonce(this.#token, counter), this.#receiving); + if (!plain) throw new Error('Message authentication failed'); + try { return decodeWithFill(plain); } finally { sodium.memzero(plain); } + } + destroy(): void { sodium.memzero(this.#sending); sodium.memzero(this.#receiving); sodium.memzero(this.#token); this.#destroyed = true; } + #check(): void { if (this.#destroyed) throw new Error('Session was destroyed'); } +} +/** Ephemeral X25519 exchange, consumed by one derivation to prevent accidental key reuse. */ +/** Use createKeyExchange unless cryptoReady has already completed. */ +export class KeyExchange { + #secret: Uint8Array; #public: Uint8Array; #destroyed = false; + constructor(seed?: Uint8Array) { + if (seed) size(seed, 32, 'Exchange seed'); + const pair = seed ? sodium.crypto_kx_seed_keypair(seed) : sodium.crypto_kx_keypair(); + this.#secret = pair.privateKey; this.#public = pair.publicKey; + } + get publicKey(): Uint8Array { return this.#public.slice(); } + clientSession(remote: Uint8Array): SessionCrypto { return this.#derive(remote, true); } + serverSession(remote: Uint8Array): SessionCrypto { return this.#derive(remote, false); } + #derive(remote: Uint8Array, client: boolean): SessionCrypto { + if (this.#destroyed) throw new Error('Key exchange was consumed or destroyed'); + size(remote, 32, 'Remote exchange key'); + try { + const pair = client ? sodium.crypto_kx_client_session_keys(this.#public, this.#secret, remote) + : sodium.crypto_kx_server_session_keys(this.#public, this.#secret, remote); + try { return new SessionCrypto(pair.sharedTx, pair.sharedRx, client); } + finally { sodium.memzero(pair.sharedTx); sodium.memzero(pair.sharedRx); } + } finally { this.destroy(); } + } + destroy(): void { sodium.memzero(this.#secret); this.#destroyed = true; } +} +export async function createKeyExchange(seed?: Uint8Array): Promise { await cryptoReady(); return new KeyExchange(seed); } +export function encryptSession(session: SessionCrypto, nonce: bigint, message: Uint8Array): Uint8Array { return session.encrypt(nonce, message); } +export function decryptSession(session: SessionCrypto, nonce: bigint, cipher: Uint8Array): Uint8Array { return session.decrypt(nonce, cipher); } + +// WithFill always has two length-prefixed arrays, even when random fill is empty. +export function encodeWithFill(data: Uint8Array, fill = new Uint8Array()): Uint8Array { + return new BipackWriter().bytes(fill).bytes(data).finish(); +} +export function decodeWithFill(data: Uint8Array): Uint8Array { + const reader = new BipackReader(data); reader.bytes(); const result = reader.bytes(); reader.end(); return result; +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts new file mode 100644 index 0000000..7b1f1b0 --- /dev/null +++ b/typescript/src/index.ts @@ -0,0 +1,6 @@ +/** Full Kiloparsec client. No light protocol, no TLS-only authentication fallback. */ +export { Client, LocalInterface, command, ConnectionError, RemoteError } from './client.js'; +export type { Command, Scope, Identity, ConnectOptions, CallOptions } from './client.js'; +export { cryptoReady, generateSigningKey, restoreSigningKey, verifySignature, SigningKey } from './crypto.js'; +export { BipackReader, BipackWriter, BipackError, codec, array, nullable, bytes, string, boolean, unit, int32, uint32, int64, uint64 } from './bipack.js'; +export type { Codec, ValueCodec } from './bipack.js'; diff --git a/typescript/src/wire.ts b/typescript/src/wire.ts new file mode 100644 index 0000000..d99663f --- /dev/null +++ b/typescript/src/wire.ts @@ -0,0 +1,100 @@ +/** Exact field-order codecs for Kiloparsec's existing Kotlin wire structures. */ +import { BipackError, BipackReader, BipackWriter, codec } from './bipack.js'; + +export type Block = ( + | { kind: 'call'; id: number; name: string; packedArgs: Uint8Array } + | { kind: 'response'; forId: number; packedResult: Uint8Array } + | { kind: 'error'; forId: number; code: string; text: string | null; extra: Uint8Array | null } +) & { priority?: number }; +export const blockCodec = codec((w, b) => { + switch (b.kind) { + case 'call': w.byte(0).u32(b.id).string(b.name).bytes(b.packedArgs); break; + case 'error': w.byte(1).u32(b.forId).string(b.code).nullable(b.text, (w,v) => { w.string(v); }).nullable(b.extra, (w,v) => { w.bytes(v); }); break; + case 'response': w.byte(2).u32(b.forId).bytes(b.packedResult); break; + } + const priority = b.priority ?? 0; + if (!Number.isInteger(priority) || priority < -2147483648 || priority > 2147483647) throw new BipackError('Invalid priority'); + if (priority !== 0) for (let shift = 24; shift >= 0; shift -= 8) w.byte((priority >>> shift) & 255); +}, r => { + let b: Block; + switch (r.byte()) { + case 0: b = { kind: 'call', id: r.u32(), name: r.string(), packedArgs: r.bytes() }; break; + case 1: b = { kind: 'error', forId: r.u32(), code: r.string(), text: r.nullable(r => r.string()), extra: r.nullable(r => r.bytes()) }; break; + case 2: b = { kind: 'response', forId: r.u32(), packedResult: r.bytes() }; break; + default: throw new BipackError('Unknown transport block'); + } + if (r.remaining !== 0) { + if (r.remaining !== 4) throw new BipackError('Invalid priority trailer'); + b.priority = (r.byte() << 24) | (r.byte() << 16) | (r.byte() << 8) | r.byte(); + } + return b; +}); + +/** Crypto2 Seal; timestamps are epoch milliseconds, keys are raw Ed25519 verifying bytes. */ +export interface Seal { + publicKey: Uint8Array; + signature: Uint8Array; + nonce: Uint8Array | null; + createdAt: bigint; + expiresAt: bigint | null; +} +function keyBytes(r: BipackReader): Uint8Array { + const key = r.bytes(); if (key.length !== 32) throw new BipackError('Public key must have 32 bytes'); return key; +} +function writeKey(w: BipackWriter, key: Uint8Array): void { + if (key.length !== 32) throw new BipackError('Public key must have 32 bytes'); w.bytes(key); +} +function writeSeal(w: BipackWriter, v: Seal): void { + writeKey(w,v.publicKey); + if (v.signature.length !== 64) throw new BipackError('Signature must have 64 bytes'); + w.bytes(v.signature).nullable(v.nonce, (w,v) => { w.bytes(v); }).i64(v.createdAt).nullable(v.expiresAt, (w,v) => { w.i64(v); }); +} +function readSeal(r: BipackReader): Seal { + const publicKey = keyBytes(r), signature = r.bytes(); + if (signature.length !== 64) throw new BipackError('Signature must have 64 bytes'); + return { publicKey, signature, nonce: r.nullable(r => r.bytes()), createdAt: r.i64(), expiresAt: r.nullable(r => r.i64()) }; +} +export const sealCodec = codec(writeSeal, readSeal); +export interface SealedData { message: Uint8Array; nonce: Uint8Array | null; createdAt: bigint | null; validUntil: bigint | null; } +export const sealedDataCodec = codec((w,v) => { + w.bytes(v.message).nullable(v.nonce, (w,v) => { w.bytes(v); }).nullable(v.createdAt, (w,v) => { w.i64(v); }).nullable(v.validUntil, (w,v) => { w.i64(v); }); +}, r => ({ message: r.bytes(), nonce: r.nullable(r => r.bytes()), createdAt: r.nullable(r => r.i64()), validUntil: r.nullable(r => r.i64()) })); +export interface Handshake { version: number; publicKey: Uint8Array; signature: Seal | null; } +// Published Kotlin BiPack encodes unannotated UInt through its signed Int backing value. +// Transport IDs and package nonces carry @Unsigned and therefore use unsigned Smartint. +export const handshakeCodec = codec((w,v) => { + if (!Number.isInteger(v.version) || v.version < 0 || v.version > 0xffffffff) throw new BipackError('Invalid handshake version'); + w.i32(v.version | 0); writeKey(w,v.publicKey); w.nullable(v.signature,writeSeal); +}, r => ({ version: r.i32() >>> 0, publicKey: keyBytes(r), signature: r.nullable(readSeal) })); +export interface ClientIdentity { clientIdKey: Uint8Array | null; signature: Uint8Array | null; } +export const clientIdentityCodec = codec((w,v) => { + w.nullable(v.clientIdKey,writeKey).nullable(v.signature, (w,v) => { w.bytes(v); }); +}, r => ({ clientIdKey: r.nullable(keyBytes), signature: r.nullable(r => r.bytes()) })); +export interface EncryptedPackage { nonce: bigint; encryptedMessage: Uint8Array; } +export const encryptedPackageCodec = codec((w,v) => { w.u64(v.nonce).bytes(v.encryptedMessage); }, r => ({ nonce: r.u64(), encryptedMessage: r.bytes() })); +export interface CommandCall { name: string; serializedArgs: Uint8Array; } +export const commandCallCodec = codec((w,v) => { w.string(v.name).bytes(v.serializedArgs); }, r => ({ name: r.string(), serializedArgs: r.bytes() })); + +/** V2 authenticated RPC metadata. No dispatch decision may rely on the outer block alone. */ +export interface ProtectedMessage { + kind: 'call' | 'push' | 'response' | 'error' | 'finished'; + correlation: bigint; + priority: number; + payload: Uint8Array; +} +const protectedKinds = ['call', 'push', 'response', 'error', 'finished'] as const; +export const protectedMessageCodec = codec((w,v) => { + const kind = protectedKinds.indexOf(v.kind); + if (kind < 0) throw new BipackError('Invalid protected message kind'); + w.u32(kind).u64(v.correlation).i32(v.priority).bytes(v.payload); +}, r => { + const kind = protectedKinds[r.u32()]; + if (kind === undefined) throw new BipackError('Unknown protected message kind'); + return { kind, correlation: r.u64(), priority: r.i32(), payload: r.bytes() }; +}); +/** Distinct version-bound signing domains prevent reuse between roles and protocol versions. */ +export function authenticationMessage(role: 'server' | 'client', token: Uint8Array): Uint8Array { + const domain = new TextEncoder().encode(`kiloparsec/v2/${role}\0`); + const message = new Uint8Array(domain.length + token.length); + message.set(domain); message.set(token, domain.length); return message; +} diff --git a/typescript/test/client.test.mjs b/typescript/test/client.test.mjs new file mode 100644 index 0000000..9e0fdd0 --- /dev/null +++ b/typescript/test/client.test.mjs @@ -0,0 +1,120 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Client, LocalInterface, command } from '../dist/client.js'; +import { generateSigningKey, createKeyExchange, verifySignature } from '../dist/crypto.js'; +import { bytes, string, unit } from '../dist/bipack.js'; +import { blockCodec, handshakeCodec, sealedDataCodec, clientIdentityCodec, encryptedPackageCodec, commandCallCodec, protectedMessageCodec, authenticationMessage } from '../dist/wire.js'; + +const echo = command('echo',string,string); +class PeerSocket extends EventTarget { + readyState = 1; bufferedAmount = 0; binaryType = 'arraybuffer'; + constructor(peer) { super(); this.peer = peer; } + send(data) { queueMicrotask(() => { void this.peer.receive(blockCodec.decode(data)).catch(error => { this.peer.error = error; this.close(); }); }); } + deliver(block) { const data = blockCodec.encode(block); this.dispatchEvent(new MessageEvent('message',{data:data.buffer})); } + close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); } +} +async function makePeer(options = {}) { + const signing = await generateSigningKey(); + const exchange = await createKeyExchange(); + const peer = { signing, exchange, crypto:null, nonce:0n, error:null, calls:0, ...options }; + peer.socket = new PeerSocket(peer); + peer.encrypt = clear => encryptedPackageCodec.encode({nonce:peer.nonce,encryptedMessage:peer.crypto.encrypt(peer.nonce++,clear)}); + peer.decrypt = packed => { const p=encryptedPackageCodec.decode(packed); return peer.crypto.decrypt(p.nonce,p.encryptedMessage); }; + peer.respond = (id,result) => peer.socket.deliver({kind:'response',forId:id,packedResult:result}); + peer.receive = async b => { + if (b.kind !== 'call') { peer.callbackResult?.(b); return; } + if (b.name === 'L0Request') { + const h=handshakeCodec.decode(b.packedArgs), publicKey=exchange.publicKey; + peer.crypto=exchange.serverSession(h.publicKey); + const createdAt=BigInt(Date.now()), nonce=null, expiresAt=null; + const signature=signing.sign(sealedDataCodec.encode({message:authenticationMessage('server',peer.crypto.token),nonce,createdAt,validUntil:expiresAt})); + peer.respond(b.id,handshakeCodec.encode({version:2,publicKey,signature:{publicKey:signing.publicKey,signature,nonce,createdAt,expiresAt}})); + } else if (b.name === 'L0ClientId') { + const identityBytes=peer.decrypt(bytes.decode(b.packedArgs)); + peer.identity=clientIdentityCodec.decode(identityBytes); + peer.respond(b.id,bytes.encode(peer.encrypt(protectedMessageCodec.encode({kind:'finished',correlation:0n,priority:0,payload:identityBytes})))); + } else if (b.name === 'L0Call') { + peer.calls++; peer.lastPriority=b.priority ?? 0; + const message=protectedMessageCodec.decode(peer.decrypt(bytes.decode(b.packedArgs))); + assert.equal(message.correlation,BigInt(b.id)); assert.equal(message.priority,b.priority ?? 0); + const call=commandCallCodec.decode(message.payload); + if (peer.hang) return; + const payload=blockCodec.encode({kind:'response',forId:0,packedResult:string.encode(string.decode(call.serializedArgs))}); + const encrypted=peer.encrypt(protectedMessageCodec.encode({kind:'response',correlation:BigInt(b.id),priority:message.priority,payload})); + if(peer.tamper) encrypted[encrypted.length-1]^=1; + peer.respond(b.id,bytes.encode(encrypted)); + } + }; + peer.connect = extra => Client.connect('ws://test.invalid',{expectedServerKey:signing.publicKey,session:{},heartbeatMs:0,timeoutMs:500,socketFactory:()=>peer.socket,...extra}); + peer.dispose = () => { peer.socket.close(); peer.crypto?.destroy(); exchange.destroy(); signing.destroy(); }; + return peer; +} + +test('Client authenticates peer and performs encrypted typed calls', async () => { + const peer=await makePeer(); + try { const client=await peer.connect(); assert.equal(await client.call(echo,'hello'),'hello'); client.close(); await client.closed; assert.equal(peer.error,null); } + finally { peer.dispose(); } +}); +test('Wrong pinned key rejects connection before sending identity', async () => { + const peer=await makePeer(), wrong=await generateSigningKey(); + try { await assert.rejects(peer.connect({expectedServerKey:wrong.publicKey}), e=>e.code==='authentication'); assert.equal(peer.identity,undefined); } + finally { peer.dispose(); wrong.destroy(); } +}); +test('Call timeout never automatically replays work', async () => { + const peer=await makePeer({hang:true}); + try { const client=await peer.connect(); await assert.rejects(client.call(echo,'once',{timeoutMs:20}),e=>e.code==='timeout'); assert.equal(peer.calls,1); client.close(); } + finally { peer.dispose(); } +}); +test('Corrupt encrypted response closes connection', async () => { + const peer=await makePeer({tamper:true}); + try { const client=await peer.connect(); await assert.rejects(client.call(echo,'x'),e=>e.code==='protocol'); await client.closed; await assert.rejects(client.call(echo,'x'),e=>e.code==='closed'); } + finally { peer.dispose(); } +}); +test('Incoming handler can await outbound call: full duplex has no dispatch deadlock', async () => { + const peer=await makePeer(); + const callback=command('callback',string,string); + const local=new LocalInterface().on(callback,async (scope,arg)=>await scope.remote.call(echo,arg+'!')); + try { + const client=await peer.connect({local}); + const reply=new Promise(resolve=>{peer.callbackResult=resolve;}); + peer.socket.deliver({kind:'call',id:80,name:'L0Call',packedArgs:bytes.encode(peer.encrypt(protectedMessageCodec.encode({kind:'call',correlation:80n,priority:0,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('nested')})})))}); + const response=await reply; + const inner=blockCodec.decode(protectedMessageCodec.decode(peer.decrypt(bytes.decode(response.packedResult))).payload); + assert.equal(inner.kind,'response'); assert.equal(string.decode(inner.packedResult),'nested!'); client.close(); + } finally { peer.dispose(); } +}); + +test('Abort cleans pending slot and preserves remote execution uncertainty', async () => { + const peer=await makePeer({hang:true}); + try { + const client=await peer.connect({maxPendingCalls:1}); + const abort=new AbortController(); + const first=client.call(echo,'first',{signal:abort.signal}); abort.abort(); + await assert.rejects(first,e=>e.code==='aborted'); + peer.hang=false; + assert.equal(await client.call(echo,'second',{priority:7}),'second'); + assert.equal(peer.lastPriority,7); assert.equal(peer.calls,2); client.close(); + } finally { peer.dispose(); } +}); +test('Explicit close rejects all pending calls and repeated close is harmless', async () => { + const peer=await makePeer({hang:true}); + try { + const client=await peer.connect(); + const first=client.call(echo,'a'), second=client.call(echo,'b'); + client.close(); client.close(); + await Promise.all([assert.rejects(first,e=>e.code==='closed'),assert.rejects(second,e=>e.code==='closed')]); + await client.closed; + } finally { peer.dispose(); } +}); + +test('client identity signs the v2 client domain, distinct from server and legacy proofs',async()=>{ + const peer=await makePeer(), identity=await generateSigningKey(); + try { + const client=await peer.connect({identity}); + assert.deepEqual(peer.identity.clientIdKey,identity.publicKey); + assert.equal(verifySignature(identity.publicKey,peer.identity.signature,authenticationMessage('client',peer.crypto.token)),true); + assert.equal(verifySignature(identity.publicKey,peer.identity.signature,authenticationMessage('server',peer.crypto.token)),false); + assert.equal(verifySignature(identity.publicKey,peer.identity.signature,peer.crypto.token),false); + client.close(); + } finally {peer.dispose();identity.destroy();} +}); diff --git a/typescript/test/codec.test.mjs b/typescript/test/codec.test.mjs new file mode 100644 index 0000000..fac3ca6 --- /dev/null +++ b/typescript/test/codec.test.mjs @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { array, nullable, BipackReader, BipackWriter, uint64, int64, int32, uint32, string, bytes, boolean, unit } from '../dist/bipack.js'; +import { blockCodec, handshakeCodec, clientIdentityCodec, encryptedPackageCodec, sealedDataCodec, sealCodec, commandCallCodec } from '../dist/wire.js'; +const hex = bytes => Buffer.from(bytes).toString('hex'); + +test('Smartint boundaries and sign-magnitude vectors match Kotlin source', () => { + const vectors = [[0n,'00'],[1n,'04'],[63n,'fc'],[64n,'0101'],[16383n,'fdff'],[16384n,'020001'],[4194303n,'feffff'],[4194304n,'03000001'],[(1n<<64n)-1n,'ffffffffffffffff7f']]; + for (const [value, expected] of vectors) { assert.equal(hex(uint64.encode(value)), expected); assert.equal(uint64.decode(Buffer.from(expected,'hex')),value); } + assert.equal(hex(int64.encode(-1n)), '0c'); + assert.equal(hex(int64.encode(1n)), '08'); + for (const value of [-(1n<<63n)+1n,-16384n,-32n,0n,31n,32n, (1n<<63n)-1n]) assert.equal(int64.decode(int64.encode(value)),value); + for (const value of [-2147483648,2147483647]) assert.equal(int32.decode(int32.encode(value)),value); + assert.equal(uint32.decode(uint32.encode(0xffffffff)),0xffffffff); + assert.throws(() => int64.encode(-(1n<<63n)), /overflow/); + assert.throws(() => uint64.encode(1n<<64n), /overflow/); +}); +test('Bounded decoding rejects malformed values without allocation from untrusted lengths', () => { + for (const value of ['01','0201','03000080','03000080808080808000','0100','020000','03000000']) assert.throws(() => uint64.decode(Buffer.from(value,'hex'))); + assert.throws(() => int64.decode(Uint8Array.of(4)), /Negative zero/); + assert.throws(() => bytes.decode(uint32.encode(0xffffffff)), /Truncated/); + assert.throws(() => string.decode(Uint8Array.of(4,255)), /UTF-8/); + assert.throws(() => boolean.decode(Uint8Array.of(2)), /boolean/); + assert.throws(() => unit.decode(Uint8Array.of(0)), /Trailing/); + assert.throws(() => new BipackReader(new Uint8Array(4),3), /limit/); + assert.throws(() => new BipackWriter(2).bytes(new Uint8Array(2)), /limit/); + assert.equal(string.decode(string.encode('Привет 🌌')), 'Привет 🌌'); +}); +test('Transport legacy vectors and optional signed priority trailer', () => { + const call = {kind:'call',id:1,name:'x',packedArgs:Uint8Array.of(42)}; + assert.equal(hex(blockCodec.encode(call)), '00040478042a'); + assert.deepEqual(blockCodec.decode(blockCodec.encode(call)),call); + const response = {kind:'response',forId:1,packedResult:new Uint8Array()}; + assert.equal(hex(blockCodec.encode(response)), '020400'); + const error = {kind:'error',forId:1,code:'E',text:null,extra:null}; + assert.equal(hex(blockCodec.encode(error)), '010404450000'); + for (const p of [-2147483648,-1,1,2147483647]) assert.deepEqual(blockCodec.decode(blockCodec.encode({...call,priority:p})), {...call,priority:p}); + assert.equal(hex(blockCodec.encode({...call,priority:0})), hex(blockCodec.encode(call))); + const encoded = blockCodec.encode({...call,priority:1}); + for (let trim=1;trim<=3;trim++) assert.throws(() => blockCodec.decode(encoded.slice(0,-trim)), /trailer/); + assert.throws(() => blockCodec.decode(Uint8Array.of(...encoded,0)), /trailer/); + assert.throws(() => blockCodec.decode(Uint8Array.of(4)), /Unknown/); +}); +test('Handshake, Seal and nullable Instant exact field order', () => { + const publicKey = new Uint8Array(32); + const seal = {publicKey,signature:new Uint8Array(64),nonce:null,createdAt:1n,expiresAt:null}; + assert.equal(hex(sealCodec.encode(seal)), '80'+'00'.repeat(32)+'0101'+'00'.repeat(64)+'000800'); + assert.deepEqual(sealCodec.decode(sealCodec.encode(seal)),seal); + const hs = {version:1,publicKey,signature:null}; + assert.equal(hex(handshakeCodec.encode(hs)),'0880'+'00'.repeat(32)+'00'); + assert.deepEqual(handshakeCodec.decode(handshakeCodec.encode({...hs,signature:seal})),{...hs,signature:seal}); + assert.equal(hex(clientIdentityCodec.encode({clientIdKey:null,signature:null})),'0000'); + assert.equal(hex(sealedDataCodec.encode({message:Uint8Array.of(7),nonce:null,createdAt:1n,validUntil:null})),'040700010800'); + assert.throws(() => handshakeCodec.encode({...hs,publicKey:new Uint8Array(31)}), /32 bytes/); +}); +test('Encrypted envelope and command call roundtrip, complete consumption enforced', () => { + const p = {nonce:(1n<<64n)-1n,encryptedMessage:Uint8Array.of(0,128,255)}; + assert.deepEqual(encryptedPackageCodec.decode(encryptedPackageCodec.encode(p)),p); + const c = {name:'echo',serializedArgs:string.encode('hello')}; + assert.deepEqual(commandCallCodec.decode(commandCallCodec.encode(c)),c); + assert.throws(() => commandCallCodec.decode(Uint8Array.of(...commandCallCodec.encode(c),0)),/Trailing/); +}); + +test('Typed lists and nullable values match unframed Kotlin collection encoding', () => { + const ints = array(int32); + assert.equal(hex(ints.encode([1,-1,0])), '0c080c00'); + assert.deepEqual(ints.decode(ints.encode([1,-1,0])),[1,-1,0]); + const nested = array(nullable(array(string))); + assert.deepEqual(nested.decode(nested.encode([null,[],['x','ы']])),[null,[],['x','ы']]); + assert.throws(() => array(unit,3).decode(uint32.encode(4)), /Array limit/); + assert.throws(() => array(unit,3).encode([undefined,undefined,undefined,undefined]), /Array limit/); + assert.throws(() => ints.decode(Uint8Array.of(8,8)), /Truncated/); +}); diff --git a/typescript/test/crypto.test.mjs b/typescript/test/crypto.test.mjs new file mode 100644 index 0000000..5cf7a02 --- /dev/null +++ b/typescript/test/crypto.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {cryptoReady, generateSigningKey, restoreSigningKey, createKeyExchange, + verifySignature, numericNonce, encodeWithFill, decodeWithFill} from '../dist/crypto.js'; +await cryptoReady(); +const fromHex = s => new Uint8Array(Buffer.from(s, 'hex')); +const hex = b => Buffer.from(b).toString('hex'); + +test('Ed25519 RFC8032 vector; identity restore and secret lifecycle', async () => { + const key = await generateSigningKey(fromHex('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60')); + assert.equal(hex(key.publicKey), 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'); + const signature = key.sign(new Uint8Array()); + assert.equal(hex(signature), 'e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b'); + assert.ok(verifySignature(key.publicKey, signature, new Uint8Array())); + assert.equal(verifySignature(key.publicKey, signature, Uint8Array.of(1)), false); + assert.equal(key.keyId.length, 34); assert.equal(key.keyId[32], 4); + const saved = key.exportSecret(); const restored = await restoreSigningKey(saved); + assert.deepEqual(restored.keyId, key.keyId); + saved[63] ^= 1; await assert.rejects(restoreSigningKey(saved), /Inconsistent/); + key.destroy(); assert.throws(() => key.sign(new Uint8Array()), /destroyed/); + assert.throws(() => key.exportSecret(), /destroyed/); restored.destroy(); +}); + +test('directional KX agrees on token and authenticates both directions', async () => { + const client = await createKeyExchange(new Uint8Array(32).fill(1)); + const server = await createKeyExchange(new Uint8Array(32).fill(2)); + const c = client.clientSession(server.publicKey), s = server.serverSession(client.publicKey); + assert.deepEqual(c.token, s.token); + const message = new TextEncoder().encode('full Kiloparsec'); + const cipher = c.encrypt(0n, message); + assert.deepEqual(s.decrypt(0n, cipher), message); + assert.deepEqual(c.decrypt(7n, s.encrypt(7n, message)), message); + assert.throws(() => c.decrypt(0n, cipher)); // wrong direction + assert.throws(() => s.decrypt(1n, cipher)); // wrong nonce + cipher[0] ^= 1; assert.throws(() => s.decrypt(0n, cipher)); + assert.throws(() => client.clientSession(server.publicKey), /consumed/); + c.destroy(); s.destroy(); assert.throws(() => c.encrypt(0n, message), /destroyed/); +}); + +test('numeric nonce uses unsigned little-endian XOR, validates overflow', () => { + assert.deepEqual(numericNonce(Uint8Array.of(0xaa, 0xbb, 0xcc), 0x1234n), Uint8Array.of(0x9e, 0xa9, 0xcc)); + assert.throws(() => numericNonce(new Uint8Array(24), -1n), RangeError); + assert.throws(() => numericNonce(new Uint8Array(24), 1n << 64n), RangeError); + assert.throws(() => numericNonce(new Uint8Array(1), 256n), RangeError); +}); + +test('WithFill preserves arbitrary bytes and rejects trailing input', () => { + const payload = Uint8Array.of(0, 128, 255); + assert.deepEqual(decodeWithFill(encodeWithFill(payload)), payload); + assert.deepEqual(decodeWithFill(encodeWithFill(payload, Uint8Array.of(4, 5))), payload); + assert.throws(() => decodeWithFill(new Uint8Array([...encodeWithFill(payload), 0]))); +}); diff --git a/typescript/test/interop.test.mjs b/typescript/test/interop.test.mjs new file mode 100644 index 0000000..ab88ef0 --- /dev/null +++ b/typescript/test/interop.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Client, LocalInterface, command, string, int32, array, unit, generateSigningKey, RemoteError } from '../dist/index.js'; +const url = process.env.KILOPARSEC_TEST_URL; +const pin = Uint8Array.from(Buffer.from('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8', 'hex')); +const echo = command('echo', string, string); +const callback = command('callback', string, string); +const clientEcho = command('clientEcho', string, string); +const notice = command('notice', string, unit); + +test('source Kotlin protocol 2 server: pinned DH, identity, codecs, duplex, push, errors, parallel calls, reconnect', { skip: !url, timeout: 20_000 }, async () => { + const identity = await generateSigningKey(); + const session = { calls: 0 }; + let notify; + const noticed = new Promise(resolve => { notify = resolve; }); + const local = new LocalInterface() + .on(clientEcho, async scope => { + assert.equal(scope.session, session); + assert.deepEqual(scope.remoteIdentity, pin); + scope.session.calls++; + // Server is waiting for this callback; client can call back into the server. + return await scope.remote.call(echo, 'nested ✓'); + }) + .on(notice, (scope, text) => { notify(text); }); + const options = { session, local, identity, expectedServerKey: pin, heartbeatMs: 0 }; + let client; + try { + client = await Client.connect(url, options); + assert.equal(await client.call(echo, 'Привет, Kotlin 👋'), 'Привет, Kotlin 👋'); + assert.equal(await client.call(command('identity', unit, string), undefined), Buffer.from(identity.publicKey).toString('hex')); + assert.equal(await client.call(command('sum', array(int32), int32), [-1000, 500, 507]), 7); + assert.equal(await client.call(callback, 'request callback'), 'nested ✓'); + assert.equal(session.calls, 1); + client.push(command('push', string, unit), 'notification from TS'); + assert.equal(await noticed, 'notification from TS'); + await assert.rejects(client.call(command('fail', string, string), 'expected rejection'), e => e instanceof RemoteError && e.code === 'IllegalArgumentException'); + await assert.rejects(client.call(command('missing-command', unit, unit), undefined), e => e instanceof RemoteError && e.code === 'UnknownCommand'); + const calls = Array.from({ length: 64 }, (_, i) => `parallel:${i}`); + assert.deepEqual(await Promise.all(calls.map(x => client.call(echo, x))), calls); + const large = 'я'.repeat(65536); + assert.equal(await client.call(echo, large), large); + await client.ping(); + client.close(); await client.closed; + client = await Client.connect(url, options); + assert.equal(await client.call(echo, 'new session'), 'new session'); + } finally { client?.close(); identity.destroy(); } +}); diff --git a/typescript/test/security.test.mjs b/typescript/test/security.test.mjs new file mode 100644 index 0000000..9c20be2 --- /dev/null +++ b/typescript/test/security.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {Client, LocalInterface, command} from '../dist/client.js'; +import {generateSigningKey, createKeyExchange} from '../dist/crypto.js'; +import {bytes, string} from '../dist/bipack.js'; +import {blockCodec, handshakeCodec, sealedDataCodec, encryptedPackageCodec, commandCallCodec, protectedMessageCodec, authenticationMessage} from '../dist/wire.js'; + +/** In-memory hostile peer: no TLS or socket timing assumptions in these tests. */ +async function peer(mode = 'normal') { + const key = await generateSigningKey(); + const exchange = await createKeyExchange(); + let session, nonce = 0n, previous, calls = [], peerError; + class Socket extends EventTarget { + readyState = 1; bufferedAmount = 0; binaryType = 'arraybuffer'; + send(data) { queueMicrotask(() => { void receive(blockCodec.decode(data)).catch(e => { peerError = e; this.close(); }); }); } + close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); } + } + const socket = new Socket(); + const emit = block => { + const data = blockCodec.encode(block); + socket.dispatchEvent(new MessageEvent('message', {data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)})); + }; + const encrypted = value => { + const n = nonce++; + return bytes.encode(encryptedPackageCodec.encode({nonce:n, encryptedMessage:session.encrypt(n,value)})); + }; + async function receive(block) { + if (block.name === 'L0Request') { + const request = handshakeCodec.decode(block.packedArgs); + session = exchange.serverSession(request.publicKey); + const now = BigInt(Date.now()); + const createdAt = mode === 'future' ? now + 100000n : now; + const expiresAt = mode === 'expired' ? now - 1000n : null; + const signature = key.sign(sealedDataCodec.encode({message:mode === 'legacy-signature' ? session.token : authenticationMessage('server',session.token), nonce:null, createdAt, validUntil:expiresAt})); + if (mode === 'tamper') signature[0] ^= 1; + emit({kind:'response', forId:block.id, packedResult:handshakeCodec.encode({version:mode === 'downgrade' ? 1 : 2,publicKey:exchange.publicKey, + signature:mode === 'unsigned' ? null : {publicKey:key.publicKey,signature,nonce:null,createdAt,expiresAt}})}); + } else if (block.name === 'L0ClientId') { + const p=encryptedPackageCodec.decode(bytes.decode(block.packedArgs)); + const identity=session.decrypt(p.nonce,p.encryptedMessage); + const packedResult=mode === 'forged-finished' ? new Uint8Array() : encrypted(protectedMessageCodec.encode({kind:'finished',correlation:0n,priority:0, + payload:mode === 'wrong-finished' ? new Uint8Array() : identity})); + emit({kind:'response', forId:block.id, packedResult}); + } else { + const pkg = encryptedPackageCodec.decode(bytes.decode(block.packedArgs)); + const message=protectedMessageCodec.decode(session.decrypt(pkg.nonce,pkg.encryptedMessage)); + const call = commandCallCodec.decode(message.payload); + let packedResult = encrypted(protectedMessageCodec.encode({kind:'response',correlation:message.correlation,priority:message.priority,payload:blockCodec.encode({kind:'response',forId:0,packedResult:call.serializedArgs})})); + if (mode === 'damage') packedResult[packedResult.length - 1] ^= 1; + if (mode === 'replay') { const saved = packedResult; packedResult = previous ?? saved; previous = saved; } + if (mode === 'swap') { + calls.push({id:block.id, packedResult}); + if (calls.length === 2) { + emit({kind:'response',forId:calls[1].id,packedResult:calls[0].packedResult}); + emit({kind:'response',forId:calls[0].id,packedResult:calls[1].packedResult}); + } + } else emit({kind:'response',forId:block.id,packedResult}); + } + } + return {key, socket, injectCall({id=90, signedId=90n, priority=0, signedPriority=0, kind='call'}={}) { + emit({kind:'call', id, priority, name:'L0Call', packedArgs:encrypted(protectedMessageCodec.encode({kind,correlation:signedId,priority:signedPriority,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('payload')})}))}); + }, connect: (pin, extra={}) => Client.connect('ws://test.invalid/', {expectedServerKey:pin ?? key.publicKey,session:null, + heartbeatMs:0,timeoutMs:1000,socketFactory:() => socket,...extra}), cleanup() {socket.close();session?.destroy();key.destroy();exchange.destroy(); if(peerError) throw peerError;} }; +} +const echo = command('echo',string,string); +test('server pin is mandatory before creating a socket', async () => { + let created = false; + await assert.rejects(Client.connect('ws://test.invalid/',{session:null,socketFactory:() => {created=true;}}), /trusted server key/); + assert.equal(created,false); +}); +for (const mode of ['wrong-pin','unsigned','tamper','expired','future','downgrade','legacy-signature']) { + test(`handshake fails closed: ${mode}`,async () => { + const p = await peer(mode); + try { + await assert.rejects(p.connect(mode === 'wrong-pin' ? new Uint8Array(32) : undefined), e => e.code === 'authentication'); + assert.equal(p.socket.readyState,3); + } finally {p.cleanup();} + }); +} +test('damaged encrypted response closes connection', async () => { + const p=await peer('damage'); + try { + const client=await p.connect(); + await assert.rejects(client.call(echo,'hello'),e=>e.code==='protocol'); + await client.closed; + await assert.rejects(client.call(echo,'again'),e=>e.code==='closed'); + } finally {p.cleanup();} +}); +test('replayed encrypted response closes connection', async () => { + const p=await peer('replay'); + try { + const client=await p.connect(); assert.equal(await client.call(echo,'first'),'first'); + await assert.rejects(client.call(echo,'second'),e=>e.code==='protocol'); await client.closed; + } finally {p.cleanup();} +}); +test('outer forId substitution fails closed without delivering swapped responses', async () => { + const p=await peer('swap'); + try { + const client=await p.connect(); + const results=await Promise.allSettled([client.call(echo,'first'),client.call(echo,'second')]); + assert.ok(results.every(r=>r.status==='rejected')); + assert.ok(results.some(r=>r.reason.code==='protocol')); + await client.closed; + client.close(); + } finally {p.cleanup();} +}); + +for (const mode of ['forged-finished','wrong-finished']) { + test(`connection requires authenticated identity confirmation: ${mode}`,async()=>{ + const p=await peer(mode); + try { await assert.rejects(p.connect()); assert.equal(p.socket.readyState,3); } + finally {p.cleanup();} + }); +} + +for (const [name, metadata] of [ + ['outer call ID substitution',{id:91}], + ['call rewritten as push',{id:0}], + ['outer priority substitution',{priority:8}], + ['protected response rewritten as call',{kind:'response'}], +]) { + test(`incoming metadata is authenticated before dispatch: ${name}`,async()=>{ + const p=await peer(); let invoked=0; + const local=new LocalInterface().on(command('callback',string,string),()=>{invoked++;return 'response';}); + try { + const client=await p.connect(undefined,{local}); + p.injectCall(metadata); + await client.closed; + assert.equal(invoked,0); + } finally {p.cleanup();} + }); +} diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 0000000..37b22d2 --- /dev/null +++ b/typescript/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"NodeNext","moduleResolution":"NodeNext","strict":true,"declaration":true,"outDir":"dist","rootDir":"src","skipLibCheck":true,"lib":["ES2022","DOM"],"noUncheckedIndexedAccess":true},"include":["src/**/*.ts"]}