Add authenticated protocol 2 and TypeScript WASM client
This commit is contained in:
1 parent
88ceeb2ae9
commit
237d404bcd
52 files changed
+3247
-148
No files matched your search
@@ -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).
|
||||
Executable
+6
@@ -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
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
**/build/
|
||||
node_modules/
|
||||
baseline-browser.min.js
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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') }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Generated
+63
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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 };
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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');
|
||||
Generated
+577
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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])));
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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();}
|
||||
});
|
||||
}
|
||||
@@ -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"]}
|
||||
Reference in new issue
Block a user