Add authenticated protocol 2 and TypeScript WASM client

This commit is contained in:
sergeych committed 2026-10-19 04:17:34 +04:00
1 parent 88ceeb2ae9
commit 237d404bcd
52 files changed
+3247 -148

No files matched your search

+27 -17
View File
@@ -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).
+6
View File
@@ -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
+10 -1
View File
@@ -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<Exec>("typescriptTest") {
group = "verification"
description = "Build and test the TypeScript/WASM client (run npm ci first)."
workingDir(layout.projectDirectory.dir("typescript"))
commandLine("npm", "test")
}
@@ -19,6 +19,7 @@ import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug
import net.sergeych.mp_logger.info
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
private var clientIds = 0
@@ -60,7 +61,7 @@ class KiloClientConnection<S>(
// client transport has no dedicated commands (unlike the server's),
// it is a calling party:
val l0Interface = KiloL0Interface(clientInterface, deferredParams)
val l0Interface = KiloL0Interface(clientInterface, deferredParams, allowHandshakeCallbacks = true)
val transport = Transport(device, l0Interface, Unit)
job = launch { transport.run() }
@@ -69,29 +70,27 @@ class KiloClientConnection<S>(
val pair = deferredKeyPair.await()
debug { "keypair ready (1)" }
val serverHe = transport.call(L0Request, Handshake(1u, pair.publicKey))
val serverHe = transport.call(L0Request, Handshake(KILO_PROTOCOL_VERSION, pair.publicKey))
debug { "got server HE (2)" }
if (serverHe.version != KILO_PROTOCOL_VERSION)
throw RemoteInterface.SecurityException("unsupported Kiloparsec protocol")
val sk = pair.clientSessionKey(serverHe.publicKey)
var params = KiloParams(false, transport, sk, session, null, this@KiloClientConnection)
// Check ID if any
serverHe.signature?.let { s ->
if (!s.isValid(params.token))
if (!s.isValid(serverAuthentication(params.token)))
throw RemoteInterface.SecurityException("wrong signature")
params = params.copy(remoteIdentity = s.publicKey)
params = params.authenticated(s.publicKey)
}
transport.call(
L0ClientId, params.encrypt(
pack(
ClientIdentity(
secretIdKey?.verifyingKey,
secretIdKey?.sign(params.token)
)
)
)
)
val identity = pack(ClientIdentity(secretIdKey?.verifyingKey,
secretIdKey?.sign(clientAuthentication(params.token))))
val finished = params.decryptMessage(transport.call(L0ClientId, params.encrypt(identity)))
if (finished.kind != ProtectedKind.SERVER_FINISHED || finished.correlation != 0uL ||
finished.priority != 0 || !finished.payload.contentEquals(identity))
throw RemoteInterface.SecurityException("invalid server finished")
deferredParams.complete(params)
kiloRemoteInterface.complete(
KiloRemoteInterface(deferredParams, clientInterface)
@@ -1,56 +1,47 @@
/*
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
*
* You may use, distribute and modify this code under the
* terms of the private license, which you must obtain from the author
*
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
* real dot sergeych at gmail.
*/
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
package net.sergeych.kiloparsec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import net.sergeych.tools.AtomicCounter
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
private val idCounter = AtomicCounter(0)
/**
* This class is not normally used directly. This is a local interface that supports
* secure transport command layer (encrypted calls/results) to work with [KiloRemoteInterface].
*
* It is recommended to use [KiloClientConnection] and [KiloServerConnection] instead.
*/
/** Authenticates dispatch metadata before looking up or deserializing an application command. */
internal class KiloL0Interface<T>(
private val clientInterface: LocalInterface<KiloScope<T>>,
private val deferredParams: CompletableDeferred<KiloParams<T>>,
private val allowHandshakeCallbacks: Boolean = false,
) : LocalInterface<Unit>() {
override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
init { addErrorProvider(clientInterface) }
// Plaintext transport errors must never use application exception constructors.
override fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? = null
init {
// local interface uses the same session as a client:
addErrorProvider(clientInterface)
on(L0Call) {
val params = deferredParams.await()
val call = Command.unpackCall(params.decrypt(it))
// tricky part: we need to encrypt a result or error: we can't allow default
// error transport (as it is not encrypted), so we re-use transport blocks here:
val result: Transport.Block = try {
Transport.Block.Response(
0u,
if (call.name == connectionPing.name) connectionPing.exec(call.serializedArgs) { it }
else clientInterface.execute(params.scope, call.name, call.serializedArgs)
)
} catch(t: BreakConnectionException) {
throw t
}
catch (t: Throwable) {
clientInterface.encodeError(0u, t)
}
params.encrypt(pack(result))
}
override suspend fun executeTransportBlock(scope: Unit, block: Transport.Block.Call): UByteArray {
if (block.name != L0Call.name) return super.executeTransportBlock(scope, block)
if (!allowHandshakeCallbacks && !deferredParams.isCompleted)
throw RemoteInterface.SecurityException("application call before authentication")
val params = deferredParams.await()
val frame: ProtectedMessage = try { params.decryptMessage(unpack(block.packedArgs)) }
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid authenticated call") }
val expected = if (block.id == 0u) ProtectedKind.PUSH else ProtectedKind.CALL
if (frame.kind != expected || frame.correlation != block.id.toULong() || frame.priority != block.priority)
throw RemoteInterface.SecurityException("authenticated routing mismatch")
val call = try { Command.unpackCall(frame.payload) }
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid command encoding") }
val result: Transport.Block = try {
Transport.Block.Response(0u,
if (call.name == connectionPing.name) connectionPing.exec(call.serializedArgs) { it }
else clientInterface.execute(params.scope, call.name, call.serializedArgs))
} catch (e: BreakConnectionException) { throw e }
catch (e: CancellationException) { throw e }
catch (e: Throwable) { clientInterface.encodeError(0u, e) }
// Push has no reply; its authenticated kind cannot be changed by rewriting id=0.
if (frame.kind == ProtectedKind.PUSH) return pack(Unit)
val kind = if (result is Transport.Block.Error) ProtectedKind.ERROR else ProtectedKind.RESPONSE
return pack(params.encryptMessage(ProtectedMessage(kind, frame.correlation, frame.priority, pack(result))))
}
}
@@ -45,6 +45,8 @@ data class KiloParams<S>(
)
private var nonce = 0UL
private var highestReceived: ULong? = null
private val received = mutableSetOf<ULong>()
val scope: KiloScope<S> by lazy {
object : KiloScope<S> {
@@ -72,18 +74,43 @@ data class KiloParams<S>(
else
null
val n = protectedOp.invoke { nonce++ }
val n = protectedOp.invoke {
if (nonce == ULong.MAX_VALUE) throw RemoteInterface.SecurityException("session nonce exhausted")
nonce++
}
return pack(
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
)
}
// fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray()
fun decrypt(encryptedMessage: UByteArray): UByteArray =
protectDecryption {
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
/** Authenticate and consume one incoming nonce, allowing bounded out-of-order delivery. */
fun decrypt(encryptedMessage: UByteArray): UByteArray = protectDecryption {
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
protectedOp.invoke {
val high = highestReceived
if (p.nonce in received || (high != null && high >= 4096uL && p.nonce <= high - 4096uL))
throw RemoteInterface.SecurityException("replayed or stale encrypted packet")
val clear = sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
received.add(p.nonce)
if (high == null || p.nonce > high) {
highestReceived = p.nonce
if (p.nonce >= 4096uL) received.removeAll { it <= p.nonce - 4096uL }
}
clear
}
}
/** Identity completion must not reset counters/replay history already used by the handshake. */
internal fun authenticated(identity: VerifyingPublicKey): KiloParams<S> = protectedOp.invoke {
copy(remoteIdentity = identity).also {
it.nonce = nonce
it.highestReceived = highestReceived
it.received.addAll(received)
}
}
internal fun encryptMessage(message: ProtectedMessage): UByteArray = encrypt(pack(message))
internal fun decryptMessage(message: UByteArray): ProtectedMessage =
net.sergeych.utools.unpack(decrypt(message))
}
@@ -0,0 +1,25 @@
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
package net.sergeych.kiloparsec
import kotlinx.serialization.Serializable
import net.sergeych.bipack.Unsigned
/** Protocol 2 never falls back to the unauthenticated v1 RPC envelope. */
internal const val KILO_PROTOCOL_VERSION: UInt = 2u
@Serializable
internal enum class ProtectedKind { CALL, PUSH, RESPONSE, ERROR, SERVER_FINISHED }
/** All dispatch/correlation metadata is authenticated by the session cipher. */
@Serializable
internal data class ProtectedMessage(
val kind: ProtectedKind,
@Unsigned val correlation: ULong,
val priority: Int,
val payload: UByteArray,
)
internal fun serverAuthentication(token: UByteArray): UByteArray =
"kiloparsec/v2/server\u0000".encodeToByteArray().toUByteArray() + token
internal fun clientAuthentication(token: UByteArray): UByteArray =
"kiloparsec/v2/client\u0000".encodeToByteArray().toUByteArray() + token
@@ -1,58 +1,66 @@
/*
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
*
* You may use, distribute and modify this code under the
* terms of the private license, which you must obtain from the author
*
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
* real dot sergeych at gmail.
*/
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
package net.sergeych.kiloparsec
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.serialization.builtins.serializer
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
private var L1IdCounter = 0
// Same encrypted L0Call wire command, sent as an existing id=0 notification.
// The result serializer is unused: neither endpoint sends/decodes a reply to a push.
private val l0Notification = Command(L0Call.name, L0Call.argsSerializer, Unit.serializer())
/**
* Represents a remote interface for interacting with a Kiloparsec service.
*
* @param S the scope type of the Kiloparsec service which will be used to call local functions remotely
* @property deferredParams a [CompletableDeferred] that resolves to the parameters required for making remote calls
* @property clientInterface a [LocalInterface] used to communicate with the local client
*/
/** Full-duplex remote interface with authenticated request/result correlation. */
class KiloRemoteInterface<S>(
private val deferredParams: CompletableDeferred<KiloParams<S>>,
private val clientInterface: LocalInterface<KiloScope<S>>,
) : RemoteInterface, Loggable by LogTag("L1TR:${++L1IdCounter}") {
) : RemoteInterface {
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
val params = deferredParams.await()
val block: Transport.Block = unpack(
params.decrypt(
params.transport.call(L0Call, params.encrypt(cmd.packCall(args)))
)
)
return when (block) {
is Transport.Block.Response -> {
cmd.unpackResult(block.packedResult)
val transport = params.transport as Transport<*>
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
var requestId = 0u
val call = cmd.packCall(args)
val encoded = try {
transport.callWithId(L0Call) { id ->
requestId = id
params.encryptMessage(ProtectedMessage(ProtectedKind.CALL, id.toULong(), priority, call))
}
} catch (e: CancellationException) { throw e }
catch (e: RemoteInterface.ClosedException) { throw e }
catch (e: Throwable) {
// An outer plaintext error is not an authenticated business result.
transport.abortSecurity()
throw RemoteInterface.SecurityException("unauthenticated outer error")
}
val frame: ProtectedMessage
val block: Transport.Block
try {
frame = params.decryptMessage(encoded)
if (frame.correlation != requestId.toULong() || frame.priority != priority)
throw RemoteInterface.SecurityException("authenticated response routing mismatch")
block = unpack(frame.payload)
when (block) {
is Transport.Block.Response -> if (frame.kind != ProtectedKind.RESPONSE || block.forId != 0u)
throw RemoteInterface.SecurityException("invalid response kind")
is Transport.Block.Error -> if (frame.kind != ProtectedKind.ERROR || block.forId != 0u)
throw RemoteInterface.SecurityException("invalid error kind")
else -> throw RemoteInterface.SecurityException("unexpected response frame")
}
} catch (e: Throwable) {
transport.abortSecurity()
throw RemoteInterface.SecurityException("invalid authenticated response")
}
return when (block) {
is Transport.Block.Response -> cmd.unpackResult(block.packedResult)
is Transport.Block.Error -> clientInterface.decodeAndThrow(block)
else -> throw RemoteInterface.Exception("unexpected block type: $block")
else -> error("unreachable")
}
}
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
val params = deferredParams.await()
params.transport.push(l0Notification, params.encrypt(cmd.packCall(args)))
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
params.transport.push(l0Notification, params.encryptMessage(
ProtectedMessage(ProtectedKind.PUSH, 0uL, priority, cmd.packCall(args))))
}
}
@@ -11,6 +11,8 @@
package net.sergeych.kiloparsec
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.crypto2.SafeKeyExchange
import net.sergeych.crypto2.SigningKey
import net.sergeych.mp_logger.LogTag
@@ -57,38 +59,39 @@ class KiloServerConnection<S>(
val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply {
var params: KiloParams<S>? = null
on(L0Request) {
val sk = pair.serverSessionKey(it.publicKey)
val handshakeLock = Mutex()
var established = false
val pair = SafeKeyExchange()
on(L0Request) { request -> handshakeLock.withLock {
if (request.version != KILO_PROTOCOL_VERSION || params != null || established)
throw RemoteInterface.SecurityException("unsupported or repeated handshake")
val sk = pair.serverSessionKey(request.publicKey)
params = KiloParams(true, deferredTransport.await(), sk, session, null, this@KiloServerConnection)
Handshake(KILO_PROTOCOL_VERSION, pair.publicKey,
serverSigningKey?.seal(serverAuthentication(params!!.token)))
} }
params = KiloParams(
true,
deferredTransport.await(),
sk,
session,
null,
this@KiloServerConnection
)
Handshake(1u, pair.publicKey, serverSigningKey?.seal(params!!.token))
}
on(L0ClientId) {
var p = params ?: throw RemoteInterface.ClosedException("wrong handshake sequence")
val ci = unpack<ClientIdentity>(p.decrypt(it))
if( ci.clientIdKey != null ) {
if( ci.signature == null )
throw RemoteInterface.SecurityException("missing signature")
if( !ci.clientIdKey.verify(ci.signature, params!!.token))
on(L0ClientId) { encoded -> handshakeLock.withLock {
if (established) throw RemoteInterface.SecurityException("repeated client identity")
var p = params ?: throw RemoteInterface.SecurityException("wrong handshake sequence")
val identity = p.decrypt(encoded)
val ci = unpack<ClientIdentity>(identity)
if ((ci.clientIdKey == null) != (ci.signature == null))
throw RemoteInterface.SecurityException("incomplete client identity")
if (ci.clientIdKey != null) {
if (!ci.clientIdKey.verify(ci.signature!!, clientAuthentication(p.token)))
throw RemoteInterface.SecurityException("wrong signature")
p = p.copy(remoteIdentity = ci.clientIdKey)
p = p.authenticated(ci.clientIdKey)
params = p
}
established = true
deferredParams.complete(p)
kiloRemoteInterface.complete(
KiloRemoteInterface(deferredParams, clientInterface)
)
kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams, clientInterface))
connectedScope = p.scope
clientInterface.onConnectHandlers.invokeAll(connectedScope)
}
p.encryptMessage(ProtectedMessage(ProtectedKind.SERVER_FINISHED, 0uL, 0, identity))
} }
}
val transport = Transport(device, l0Interface, Unit)
@@ -103,9 +106,6 @@ class KiloServerConnection<S>(
}
}
companion object {
val pair = SafeKeyExchange()
}
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
return kiloRemoteInterface.await().call(cmd, args)
@@ -82,6 +82,10 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
.invoke(scope, packedArgs)
/** Transport hook keeps outer routing metadata available to the authenticated layer. */
internal open suspend fun executeTransportBlock(scope: S, block: Transport.Block.Call): UByteArray =
execute(scope, block.name, block.packedArgs)
private val errorByClass = mutableMapOf<KClass<*>, String>()
private val errorBuilder = mutableMapOf<String, (String, UByteArray?) -> Throwable>()
@@ -126,7 +126,11 @@ class Transport<S>(
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
* @throws RemoteInterface.ClosedException
*/
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray {
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray =
sendBoundCallBlock(name) { packedArgs }
/** Allocate the outer ID before sealing its authenticated inner counterpart. */
private suspend fun sendBoundCallBlock(name: String, packedArgs: (UInt) -> UByteArray): UByteArray {
if (isClosed) throw RemoteInterface.ClosedException()
val b: Block.Call
@@ -136,7 +140,9 @@ class Transport<S>(
access.withLock {
if (isClosed) throw RemoteInterface.ClosedException()
// the order is important: first id in use MUST BE >= 1, not zero:
b = Block.Call(++lastId, name, packedArgs).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
if (lastId == UInt.MAX_VALUE) throw RemoteInterface.ClosedException("RPC ID space exhausted")
val id = ++lastId
b = Block.Call(id, name, packedArgs(id)).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
calls[b.id] = deferred
}
@@ -172,6 +178,17 @@ class Transport<S>(
}
}
internal suspend fun callWithId(
cmd: Command<UByteArray, UByteArray>,
build: (UInt) -> UByteArray,
): UByteArray = unpack(cmd.resultSerializer,
sendBoundCallBlock(cmd.name) { id -> pack(cmd.argsSerializer, build(id)) })
internal suspend fun abortSecurity() {
isClosed = true
device.close()
}
/**
* Call the remote procedure with specified args and return its result
*/
@@ -217,12 +234,12 @@ class Transport<S>(
try {
if (b.id == 0u)
// Command does not waits return
localInterface.execute(commandContext, b.name, b.packedArgs)
localInterface.executeTransportBlock(commandContext, b)
else
send(
Block.Response(
b.id,
localInterface.execute(commandContext, b.name, b.packedArgs)
localInterface.executeTransportBlock(commandContext, b)
).also { it.priority = b.priority }
)
} catch (x: LocalInterface.BreakConnectionException) {
@@ -231,6 +248,8 @@ class Transport<S>(
isClosed = true
if (x.flushSendQueue) device.flush()
device.close()
} catch (x: CancellationException) {
throw x
} catch (x: RemoteInterface.RemoteException) {
send(Block.Error(b.id, x.code, x.text, x.extra).also { it.priority = b.priority })
} catch (t: Throwable) {
@@ -272,6 +291,8 @@ class Transport<S>(
c.completeExceptionally(RemoteInterface.ClosedException())
calls.clear()
debug { "calls clear has been called" }
// Closing a device also releases dispatch children waiting on unfinished handshakes.
currentCoroutineContext().cancelChildren()
}
debug { "no more active: $isActive / ${calls.size}" }
}
@@ -25,5 +25,5 @@ data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: U
// Level 0 command: request key exchange
internal val L0Request by command<Handshake, Handshake>()
internal val L0ClientId by command<UByteArray, Unit>()
internal val L0ClientId by command<UByteArray, UByteArray>()
internal val L0Call by command<UByteArray,UByteArray>()
@@ -0,0 +1,186 @@
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import net.sergeych.crypto2.SigningSecretKey
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.*
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
import kotlin.test.*
import kotlin.time.Duration.Companion.seconds
/** Wire adversary regressions: mutations occur after serialization and outside the encryption layer. */
class AuthenticatedEnvelopeTest {
private fun intercept(device: Transport.Device,
mutate: suspend (Transport.Block, suspend (Transport.Block) -> Unit) -> Unit) =
object : Transport.Device by device {
override suspend fun send(block: UByteArray) {
mutate(unpack(block)) { replacement -> device.send(pack<Transport.Block>(replacement)) }
}
}
@Test fun authenticatedDuplexAndSessionArePreserved() = runTest(timeout = 10.seconds) {
initCrypto()
val echo by command<String, String>()
val callback by command<String, String>()
val (a, b) = createTestDevice()
val ready = CompletableDeferred<Unit>()
val serverKey = SigningSecretKey.new()
val clientKey = SigningSecretKey.new()
val server = KiloServerConnection(KiloInterface<String>().apply {
on(echo) {
assertEquals("server-session", session)
assertEquals(clientKey.verifyingKey, remoteIdentity)
remote.call(callback, it)
}
}, b, "server-session", serverKey)
val client = KiloClientConnection(KiloInterface<String>().apply {
on(callback) {
assertEquals("client-session", session)
assertEquals(serverKey.verifyingKey, remoteIdentity)
"callback:$it"
}
}, a, "client-session", clientKey)
val jobs = listOf(launch { server.run() }, launch { client.run { if (it) ready.complete(Unit) } })
try {
ready.await()
assertEquals("callback:duplex", client.call(echo, "duplex"))
} finally { jobs.forEach { it.cancelAndJoin() } }
}
@Test fun swappingOuterResponseIdsCannotSwapConcurrentResults() = runTest(timeout = 10.seconds) {
initCrypto()
val echo by command<String, String>()
val (a, b) = createTestDevice()
val ready = CompletableDeferred<Unit>()
var armed = false
val held = mutableListOf<Transport.Block.Response>()
val malicious = intercept(b) { block, send ->
if (armed && block is Transport.Block.Response) {
held += block
if (held.size == 2) {
send(held[0].copy(forId = held[1].forId).also { it.priority = held[0].priority })
send(held[1].copy(forId = held[0].forId).also { it.priority = held[1].priority })
}
} else send(block)
}
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new())
val client = KiloClientConnection(KiloInterface<Unit>(), a, Unit)
val serverJob = launch { runCatching { server.run() } }
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
try {
ready.await(); armed = true
val first = async { runCatching { client.call(echo, "first") } }
val second = async { runCatching { client.call(echo, "second") } }
assertTrue(withTimeout(2000) { first.await() }.isFailure)
assertTrue(withTimeout(2000) { second.await() }.isFailure)
assertEquals(2, held.size, "Both authenticated responses must reach the mutation point")
withTimeout(2000) { clientJob.join() }
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
}
@Test fun changingOuterCallIdToPushCannotDispatch() = rejectTamperedCall(changeId = true)
@Test fun changingOuterPriorityCannotDispatch() = rejectTamperedCall(changeId = false)
private fun rejectTamperedCall(changeId: Boolean) = runTest(timeout = 10.seconds) {
initCrypto()
val mutate by command<Int, Int>()
val (a, b) = createTestDevice()
val ready = CompletableDeferred<Unit>()
var executions = 0
var attacked = false
val malicious = intercept(a) { block, send ->
if (block is Transport.Block.Call && block.name == "L0Call") {
attacked = true
send(block.copy(id = if (changeId) 0u else block.id).also {
it.priority = if (changeId) block.priority else block.priority + 1
})
} else send(block)
}
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(mutate) { executions++; it } }, b, Unit, SigningSecretKey.new())
val client = KiloClientConnection(KiloInterface<Unit>(), malicious, Unit)
val jobs = listOf(launch { runCatching { server.run() } }, launch { runCatching { client.run { if (it) ready.complete(Unit) } } })
try {
ready.await()
assertTrue(withTimeout(2000) { runCatching { client.call(mutate, 1) } }.isFailure)
assertTrue(attacked)
withTimeout(2000) { jobs[0].join() }
assertEquals(0, executions, "Unauthenticated routing metadata must be rejected before dispatch")
} finally { jobs.forEach { it.cancelAndJoin() } }
}
@Test fun replayCannotExecuteTwice() = runTest(timeout = 10.seconds) {
initCrypto()
val mutate by command<Int, Int>()
val (a, b) = createTestDevice()
val ready = CompletableDeferred<Unit>()
var executions = 0
var attacked = false
val malicious = intercept(a) { block, send ->
send(block)
if (block is Transport.Block.Call && block.name == "L0Call") { attacked = true; send(block) }
}
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(mutate) { executions++; it } }, b, Unit, SigningSecretKey.new())
val client = KiloClientConnection(KiloInterface<Unit>(), malicious, Unit)
val serverJob = launch { runCatching { server.run() } }
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
try {
ready.await()
withTimeout(2000) { runCatching { client.call(mutate, 1) } }
withTimeout(2000) { serverJob.join() }
assertTrue(attacked)
assertTrue(executions <= 1, "The replay must never execute the application operation again")
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
}
@Test fun forgedPlaintextIdentityAcknowledgementCannotConnect() = runTest(timeout = 10.seconds) {
initCrypto()
val (a, b) = createTestDevice()
var identityId: UInt? = null
var connected = false
var attacked = false
val observedClient = intercept(a) { block, send ->
if (block is Transport.Block.Call && block.name == "L0ClientId") identityId = block.id
send(block)
}
val maliciousServer = intercept(b) { block, send ->
if (block is Transport.Block.Response && block.forId == identityId) {
attacked = true
send(block.copy(packedResult = pack(Unit)))
} else send(block)
}
val server = KiloServerConnection(KiloInterface<Unit>(), maliciousServer, Unit, SigningSecretKey.new())
val client = KiloClientConnection(KiloInterface<Unit>(), observedClient, Unit)
val serverJob = launch { runCatching { server.run() } }
val clientJob = launch { runCatching { client.run { if (it) connected = true } } }
try {
withTimeout(2000) { clientJob.join() }
assertTrue(attacked)
assertFalse(connected, "Connected must await the authenticated server Finished")
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
}
@Test fun legacyHandshakeVersionIsRejectedBeforeConnection() = runTest(timeout = 10.seconds) {
initCrypto()
val (a, b) = createTestDevice()
var connected = false
var attacked = false
val downgraded = intercept(a) { block, send ->
if (block is Transport.Block.Call && block.name == "L0Request") {
attacked = true
val request = unpack<Handshake>(block.packedArgs)
send(block.copy(packedArgs = pack(request.copy(version = 1u))))
} else send(block)
}
val server = KiloServerConnection(KiloInterface<Unit>(), b, Unit, SigningSecretKey.new())
val client = KiloClientConnection(KiloInterface<Unit>(), downgraded, Unit)
val serverJob = launch { runCatching { server.run() } }
val clientJob = launch { runCatching { client.run { if (it) connected = true } } }
try {
withTimeout(2000) { clientJob.join() }
assertTrue(attacked)
assertFalse(connected)
} finally { listOf(serverJob, clientJob).forEach { it.cancelAndJoin() } }
}
}
@@ -0,0 +1,64 @@
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import net.sergeych.crypto2.SigningSecretKey
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.*
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
import kotlin.test.*
import kotlin.time.Duration.Companion.seconds
/** Regression checks at the boundary between untrusted outer transport and authenticated RPC. */
class SecurityBoundaryTest {
@Test fun plaintextRegisteredBusinessErrorCannotReachCallerAsBusinessResult() =
rejectPlaintextResponse(registeredError = true)
@Test fun malformedPlaintextResultClosesConnection() = rejectPlaintextResponse(registeredError = false)
private fun rejectPlaintextResponse(registeredError: Boolean) = runTest(timeout = 10.seconds) {
initCrypto()
val echo by command<String, String>()
val (clientDevice, serverDevice) = createTestDevice()
val ready = CompletableDeferred<Unit>()
var armed = false
var attacked = false
val malicious = object : Transport.Device by serverDevice {
override suspend fun send(block: UByteArray) {
val decoded = unpack<Transport.Block>(block)
if (armed && decoded is Transport.Block.Response) {
attacked = true
val forged: Transport.Block = if (registeredError)
Transport.Block.Error(decoded.forId, "IllegalArgumentException", "forged quota rejection")
else decoded.copy(packedResult = ubyteArrayOf())
serverDevice.send(pack(forged))
} else serverDevice.send(block)
}
}
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new())
val client = KiloClientConnection(KiloInterface<Unit>(), clientDevice, Unit)
val serverJob = launch { runCatching { server.run() } }
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
try {
ready.await(); armed = true
val result = withTimeout(2000) { runCatching { client.call(echo, "payload") } }
assertTrue(attacked)
assertIs<RemoteInterface.SecurityException>(result.exceptionOrNull(), "Plaintext outer errors/data must never impersonate authenticated business results")
withTimeout(2000) { clientJob.join() }
} finally { listOf(clientJob, serverJob).forEach { it.cancelAndJoin() } }
}
@Test fun unestablishedProtectedCallDoesNotKeepDisconnectedServerAlive() = runTest(timeout = 10.seconds) {
initCrypto()
val (clientDevice, serverDevice) = createTestDevice()
val server = KiloServerConnection(KiloInterface<Unit>(), serverDevice, Unit, SigningSecretKey.new())
val serverJob = launch { runCatching { server.run() } }
try {
clientDevice.send(pack<Transport.Block>(Transport.Block.Call(1u, "L0Call", pack(ubyteArrayOf()))))
runCurrent()
clientDevice.close()
withTimeout(2000) { serverJob.join() }
} finally { serverJob.cancelAndJoin() }
}
}
+13
View File
@@ -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
+4
View File
@@ -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.
+126
View File
@@ -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<A,R>`.
`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.
+144
View File
@@ -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).
+93
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
.gradle/
.kotlin/
**/build/
node_modules/
baseline-browser.min.js
+147
View File
@@ -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> | 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.
+5
View File
@@ -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');
};
+24
View File
@@ -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; });
@@ -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") } }
}
@@ -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<UByteArray>(Channel.UNLIMITED)
private val output = Channel<UByteArray>(Channel.UNLIMITED)
private var client: KiloClientConnection<Unit>? = null
fun receiveBlock(bytes: ByteArray) { check(input.trySend(bytes.toUByteArray()).isSuccess) }
fun connect(expectedPublicKeyHex: String): Promise<Unit> = scope.promise {
initCrypto()
val local = KiloInterface<Unit>()
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<String> = scope.promise {
val echo by command<String, String>()
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<String> = CoroutineScope(Dispatchers.Default).promise {
initCrypto()
SigningSecretKey.new().verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') }
}
+13
View File
@@ -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")
}
}
+63
View File
@@ -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
}
}
}
}
}
+1
View File
@@ -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"}}
@@ -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") }
@@ -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<String>) = runBlocking {
initCrypto()
val key = SigningSecretKey(("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8").chunked(2).map { it.toUByte(16) }.toUByteArray())
val echo by command<String, String>()
val sum by command<List<Int>, Int>()
val identity by command<Unit, String>()
val callback by command<String, String>()
val clientEcho by command<String, String>()
val push by command<String, Unit>()
val notice by command<String, Unit>()
val fail by command<String, String>()
val api = KiloInterface<Unit>().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
}
+10
View File
@@ -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(":"))
}
}
@@ -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") } }
}
@@ -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<UByteArray>(Channel.UNLIMITED)
private var client: KiloClientConnection<Unit>? = 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<UByteArray>(Channel.UNLIMITED)
val connection = KiloClientConnection(KiloInterface<Unit>(), 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<String, String>()
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() {}
+12
View File
@@ -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 };
+43
View File
@@ -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);}
}
+3
View File
@@ -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');
+577
View File
@@ -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
}
}
}
}
}
+49
View File
@@ -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"
}
}
+39
View File
@@ -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);
}
}
+51
View File
@@ -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));
+36
View File
@@ -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));
+144
View File
@@ -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<T>(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<T>(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<T> { 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<T> extends Codec<T> { write(w: BipackWriter, value: T): void; read(r: BipackReader): T; }
export function codec<T>(write: (w: BipackWriter, v: T) => void, read: (r: BipackReader) => T): ValueCodec<T> {
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<number>((w,v) => { w.u32(v); }, r => r.u32());
export const uint64 = codec<bigint>((w,v) => { w.u64(v); }, r => r.u64());
export const int32 = codec<number>((w,v) => { w.i32(v); }, r => r.i32());
export const int64 = codec<bigint>((w,v) => { w.i64(v); }, r => r.i64());
export const bytes = codec<Uint8Array>((w,v) => { w.bytes(v); }, r => r.bytes());
export const string = codec<string>((w,v) => { w.string(v); }, r => r.string());
export const boolean = codec<boolean>((w,v) => { w.bool(v); }, r => r.bool());
export const unit = codec<void>(() => {}, () => undefined);
/** Kotlin List<T>/Array<T> field layout. Bounds also limit arrays of zero-byte Unit values. */
export function array<T>(element: ValueCodec<T>, maxItems = 100_000): ValueCodec<T[]> {
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<T>(element: ValueCodec<T>): ValueCodec<T | null> {
return codec((w,v) => { w.nullable(v, element.write); }, r => r.nullable(element.read));
}
+328
View File
@@ -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<A, R> { readonly name: string; readonly args: Codec<A>; readonly result: Codec<R> }
export function command<A, R>(name: string, args: Codec<A>, result: Codec<R>): Command<A, R> {
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<Uint8Array> }
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<S> {
readonly session: S;
readonly remote: Client<S>;
readonly remoteIdentity: Uint8Array;
readonly sessionToken: Uint8Array;
}
type Handler<S> = (scope: Scope<S>, payload: Uint8Array) => Promise<Uint8Array>;
/** Declare callbacks before connecting. Each connection snapshots this registry. */
export class LocalInterface<S> {
#handlers = new Map<string, Handler<S>>();
on<A, R>(cmd: Command<A, R>, handler: (scope: Scope<S>, args: A) => R | Promise<R>): 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<string, Handler<S>> { return new Map(this.#handlers); }
}
export interface ConnectOptions<S> {
/** 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<S>;
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<S> {
#socket: WebSocket;
#crypto?: SessionCrypto;
#token: Uint8Array = empty;
#remoteIdentity: Uint8Array = empty;
#nonce = 0n;
#highestReceived = -1n;
#received = new Set<bigint>();
#nextId = 1;
#pending = new Map<number, Pending>();
#incoming = new Set<number>();
#incomingCount = 0;
#handlers: ReadonlyMap<string, Handler<S>>;
#closed = false;
#closeReason: unknown = new ConnectionError('closed');
#ready = false;
#heartbeat?: ReturnType<typeof setInterval>;
#heartbeatBusy = false;
#finishClosed!: () => void;
/** Resolves after local connection disposal. No rejected Promise to forget to observe. */
readonly closed: Promise<void>;
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<S>) {
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<S>(url: string, options: ConnectOptions<S>): Promise<Client<S>> {
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<T>(promise: Promise<T>): Promise<T> {
if (this.#closed) throw this.#closeReason;
return Promise.race([promise, this.closed.then(() => { throw this.#closeReason; })]);
}
async #waitOpen(): Promise<void> {
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<void>(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<Uint8Array> {
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<S> = 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<A, R>(cmd: Command<A, R>, args: A, options: CallOptions = {}): Promise<R> {
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<A>(cmd: Command<A, void>, 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<void> {
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();
}
}
+133
View File
@@ -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<void> { 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<SigningKey> {
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<SigningKey> { 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<KeyExchange> { 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;
}
+6
View File
@@ -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';
+100
View File
@@ -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<Block>((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<Seal>(writeSeal, readSeal);
export interface SealedData { message: Uint8Array; nonce: Uint8Array | null; createdAt: bigint | null; validUntil: bigint | null; }
export const sealedDataCodec = codec<SealedData>((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<Handshake>((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<ClientIdentity>((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<EncryptedPackage>((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<CommandCall>((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<ProtectedMessage>((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;
}
+120
View File
@@ -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();}
});
+73
View File
@@ -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/);
});
+52
View File
@@ -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])));
});
+47
View File
@@ -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(); }
});
+132
View File
@@ -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();}
});
}
+1
View File
@@ -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"]}