Add authenticated protocol 2 and TypeScript WASM client

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

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.gradle/
.kotlin/
**/build/
node_modules/
baseline-browser.min.js
+147
View File
@@ -0,0 +1,147 @@
# Kotlin compatibility fixture and size baseline
This fixture uses a Gradle composite build to consume Kiloparsec source from
the repository root (`../..`), with explicit dependency substitution. The JVM fixture uses
`setupWebsocketServer` and `KiloInterface` unchanged.
## Build and start the reference server
From the TypeScript module directory (requires JDK 21 and the parent build's Android SDK configuration):
```sh
npm run fixture:build
npm run baseline:build
JAVA_HOME=/path/to/jdk21 evaluation/server/build/install/server/bin/server
```
Default endpoint: `ws://127.0.0.1:18732/kp`; optional first argument overrides port.
The server binds localhost only. Its deterministic Ed25519 **TEST-ONLY** seed is
bytes 0 through 31. Public key:
```text
03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8
```
Commands use ordinary Kotlin serialization/BiPack:
| Command | Arguments | Result / behavior |
|---|---|---|
| `echo` | String | same String |
| `sum` | List<Int> | Int sum |
| `identity` | Unit | authenticated remote public key raw lowercase hex, or `anonymous` |
| `callback` | String | server calls `clientEcho(String): String`, returns its result |
| `push` | String | server calls `notice(String): Unit`, waits for acknowledgment |
| `fail` | String | throws registered `IllegalArgumentException` with that message |
The fixture supports anonymous clients to allow explicit coverage of identity
presence/absence; a production application's authorization policy must decide
which identities may invoke its methods.
## Optimized Kotlin/JS baseline
`baseline` exports a usable `BaselineClient` accepting binary block callbacks.
It retains full DH, signed identity, expected server identity checking and RPC,
without retaining the Ktor WebSocket client intentionally. This is the smaller
bridge option rather than a deliberately inflated full library bundle.
```sh
npm ci --prefix evaluation --ignore-scripts
node evaluation/baseline-smoke.cjs
node_modules/.bin/esbuild evaluation/baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js --bundle --minify --platform=browser --external:fs --external:path --external:crypto --outfile=evaluation/baseline-browser.min.js
```
`fs`, `path`, `crypto` are libsodium's conditional Node imports, unused in browser
execution. Bundle includes libsodium-sumo WASM payload. Source maps are excluded.
Kotlin production compiler performs dead-code elimination, followed by esbuild
minification. npm explicitly pins sodium wrapper 0.7.15 for comparison with TS.
For the common comparison, use the same **Node CJS** platform for both libraries:
```sh
npm run build
node scripts/measure.mjs
node evaluation/baseline-smoke.cjs --bundled
```
Results are saved in `evaluation/build/measurement/results.json`. A recorded run
is preserved in [measurements.json](measurements.json). The script performs five
fresh subprocess measurements, from immediately before module loading through
WASM initialization and generation of one Ed25519 identity. It does not include
process startup. RSS includes Node; RSS delta is resident growth during workload.
Historical measurements of the pre-hardening published 0.7.2 baseline,
2026-10-18 on Node 24.19.0, Linux x64 (not regenerated for current source):
| Measure | TS full exports | Kotlin minimal bridge |
|---|---:|---:|
| Minified self-contained Node bundle, bytes | 1,124,612 | 1,982,755 |
| Gzip level 9, bytes | 330,101 | 559,003 |
| Cold init + identity median, ms | 88.59 | 166.50 |
| Resident memory median, bytes | 62,898,176 | 70,574,080 |
| Resident growth median, bytes | 19,300,352 | 26,976,256 |
The Kotlin baseline exposes only echo and the bridge plus the measurement identity
helper; TS includes all exported API. This is not a claim of full feature parity.
Both include sodium's WASM payload. Both actual standalone bundles initialize
successfully; the measured Kotlin bundle additionally passed authenticated,
pinned-server echo to the JVM fixture. Measurements vary by host and revision.
The Kotlin sodium bindings eagerly capture imported functions: await
`require('libsodium-wrappers-sumo').ready` **before loading the compiled bridge**,
as shown in the smoke script. Loading the bridge first produces `_sodium_init is
not a function` with these published dependencies. That observation concerns the historical published baseline.
The browser bundle's size was measured; browser execution is not claimed by the
Node smoke check. The bridge is an evaluation fixture, not a polished production
wrapper (bounded buffering, lifecycle error propagation, and key ownership would
need finishing if this alternative were selected).
## Protocol 2 validation and measurements
Current-source protocol 2 results are in [measurements-v2.json](measurements-v2.json).
TS: 1,126,081 bytes / gzip330,527; Kotlin baseline: 1,993,070 / gzip561,965.
Both measured bundles completed pinned-server echo against the local protocol2
fixture. Historical measurements above remain unchanged for traceability.
The full root verification command passed 43 JVM tests, 28 Kotlin/JS Node tests,
35 TS unit/security tests, and the real TS/JVM interoperability scenario.
## Optimized Kotlin/Wasm comparison
`wasm-baseline` retains the same full handshake, server key pin, fresh client
identity and echo RPC as the JS baseline. It exports a small string/callback
bridge, avoiding non-exportable Kotlin objects at the JS/Wasm boundary.
From the TypeScript module directory:
```sh
npm run wasm:build
npm ci --prefix evaluation --ignore-scripts
npm run measure:wasm
npm run fixture:build
npm run test:wasm-baseline
```
The measurement uses the **optimized production executable** `.wasm` emitted by
Binaryen, together with an esbuild-minified ESM loader containing sodium and joda.
The raw `.klib`, unoptimized compiler output, maps and test runtime are excluded.
Both the raw and wrapper sodium imports share the same CommonJS module instance,
so the crypto payload is counted once. The wrapper is pinned to 0.7.15; its locked
transitive raw sodium dependency is 0.7.16, as in the JS/TS comparison.
The Kotlin loader must receive the sodium modules after `sodium.ready`. The small
`wasm-entry.mjs` adapter does this, supplies the expected default/namespace shapes,
and includes Node CommonJS helpers for the bundled sodium dependency. It does not
change the protocol or cryptography. The optimized asset stays beside `client.mjs`.
Recorded Node/Linux results are in [wasm-measurements.json](wasm-measurements.json).
The runnable distribution consists of two assets:
| Asset | Bytes | Gzip level 9 bytes |
|---|---:|---:|
| Minified loader, sodium and joda | 1,319,098 | 368,938 |
| Optimized Kotlin executable | 764,584 | 247,073 |
| Total | 2,083,682 | 616,011 |
Gzip is applied separately to each transferred asset and the sizes are summed.
The distribution passed initialization, Ed25519 identity generation, full DH,
server pinning and authenticated echo against the local protocol-2 JVM fixture.
This qualifies the comparison on Node; no browser runtime result is claimed.
+5
View File
@@ -0,0 +1,5 @@
// Kotlin binding imports must execute after sodium resolves its WASM functions.
exports.load = async function load() {
await require('libsodium-wrappers-sumo').ready;
return require('./baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js');
};
+24
View File
@@ -0,0 +1,24 @@
// Check that the measured baseline is a usable full client, not dead-code-only output.
const { WebSocket } = require('ws');
async function main() {
if (!process.argv.includes('--bundled')) await require('libsodium-wrappers-sumo').ready;
const { BaselineClient } = process.argv.includes('--bundled')
? await require('./build/measurement/kotlin.cjs').load()
: require('./baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js');
const socket = new WebSocket(process.env.KILOPARSEC_TEST_URL ?? 'ws://127.0.0.1:18732/kp');
const client = new BaselineClient(bytes => socket.send(Buffer.from(bytes)));
socket.on('message', bytes => client.receiveBlock(new Int8Array(bytes)));
socket.on('open', async () => {
try {
await client.connect('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8');
const result = await client.echo('Kotlin baseline ✓');
if (result !== 'Kotlin baseline ✓') throw Error('Wrong echo');
console.log('BASELINE_AUTHENTICATED_ECHO_OK');
} catch (error) { console.error(error); process.exitCode = 1; }
finally { client.close(); socket.close(); }
});
socket.on('error', error => { console.error(error); process.exitCode = 1; });
setTimeout(() => { console.error('Baseline timeout'); process.exit(1); }, 10000).unref();
}
main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,5 @@
plugins { kotlin("multiplatform"); kotlin("plugin.serialization") }
kotlin {
js { nodejs(); binaries.executable(); generateTypeScriptDefinitions() }
sourceSets { jsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") } }
}
@@ -0,0 +1,41 @@
@file:OptIn(ExperimentalJsExport::class, kotlin.ExperimentalUnsignedTypes::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import net.sergeych.crypto2.*
import net.sergeych.kiloparsec.*
import net.sergeych.kiloparsec.adapter.ProxyDevice
import kotlin.js.Promise
/** Native JS WebSocket bridge: retains the full authenticated protocol without Ktor client. */
@JsExport
class BaselineClient(private val sendBlock: (ByteArray) -> Unit) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val input = Channel<UByteArray>(Channel.UNLIMITED)
private val output = Channel<UByteArray>(Channel.UNLIMITED)
private var client: KiloClientConnection<Unit>? = null
fun receiveBlock(bytes: ByteArray) { check(input.trySend(bytes.toUByteArray()).isSuccess) }
fun connect(expectedPublicKeyHex: String): Promise<Unit> = scope.promise {
initCrypto()
val local = KiloInterface<Unit>()
val connection = KiloClientConnection(local, ProxyDevice(input, output), Unit, SigningSecretKey.new())
client = connection
scope.launch { for (block in output) sendBlock(block.asByteArray()) }
scope.launch { connection.run() }
val actual = connection.remoteId()?.keyBytes?.joinToString("") { it.toString(16).padStart(2, '0') }
if (actual != expectedPublicKeyHex) { close(); error("Unexpected server identity") }
}
fun echo(text: String): Promise<String> = scope.promise {
val echo by command<String, String>()
requireNotNull(client).call(echo, text)
}
fun close() { scope.cancel() }
}
fun main() {}
/** Evaluation-only cold startup workload: initialize sodium and generate one identity. */
@JsExport
fun initializeIdentity(): Promise<String> = CoroutineScope(Dispatchers.Default).promise {
initCrypto()
SigningSecretKey.new().verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') }
}
+13
View File
@@ -0,0 +1,13 @@
plugins {
kotlin("jvm") version "2.2.21" apply false
kotlin("multiplatform") version "2.2.21" apply false
kotlin("plugin.serialization") version "2.2.21" apply false
}
allprojects {
repositories {
mavenLocal(); mavenCentral()
maven("https://maven.universablockchain.com/")
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven")
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "evaluation",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@js-joda/core": "3.2.0",
"format-util": "1.0.5",
"libsodium-wrappers-sumo": "0.7.15",
"ws": "8.18.0"
}
},
"node_modules/@js-joda/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-3.2.0.tgz",
"integrity": "sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==",
"license": "BSD-3-Clause"
},
"node_modules/format-util": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz",
"integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==",
"license": "MIT"
},
"node_modules/libsodium-sumo": {
"version": "0.7.16",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz",
"integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.7.15"
}
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
{"private":true,"dependencies":{"@js-joda/core":"3.2.0","libsodium-wrappers-sumo":"0.7.15","format-util":"1.0.5","ws":"8.18.0"}}
@@ -0,0 +1,8 @@
plugins { kotlin("jvm"); kotlin("plugin.serialization"); application }
kotlin { jvmToolchain(21); compilerOptions { optIn.add("kotlin.ExperimentalUnsignedTypes") } }
dependencies {
implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT")
implementation("io.ktor:ktor-server-netty:3.1.1")
implementation("org.slf4j:slf4j-simple:2.0.17")
}
application { mainClass.set("InteropServerKt") }
@@ -0,0 +1,36 @@
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import kotlinx.coroutines.runBlocking
import net.sergeych.crypto2.*
import net.sergeych.kiloparsec.*
import net.sergeych.kiloparsec.adapter.setupWebsocketServer
// Public reproducible TEST KEY ONLY. Never use this identity outside tests.
fun main(args: Array<String>) = runBlocking {
initCrypto()
val key = SigningSecretKey(("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8").chunked(2).map { it.toUByte(16) }.toUByteArray())
val echo by command<String, String>()
val sum by command<List<Int>, Int>()
val identity by command<Unit, String>()
val callback by command<String, String>()
val clientEcho by command<String, String>()
val push by command<String, Unit>()
val notice by command<String, Unit>()
val fail by command<String, String>()
val api = KiloInterface<Unit>().apply {
on(echo) { it }
on(sum) { it.sum() }
on(identity) { remoteIdentity?.keyBytes?.joinToString("") { b -> b.toString(16).padStart(2, '0') } ?: "anonymous" }
on(callback) { remote.call(clientEcho, it) }
on(push) { remote.call(notice, it) }
on(fail) { throw IllegalArgumentException(it) }
}
val port = args.firstOrNull()?.toInt() ?: 18732
println("TEST_SERVER_PUBLIC_KEY_HEX=" + key.verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') })
println("TEST_SERVER_URL=ws://127.0.0.1:$port/kp")
embeddedServer(Netty, port = port, host = "127.0.0.1") {
setupWebsocketServer(api, serverKey = key) { Unit }
}.start(wait = true)
Unit
}
+10
View File
@@ -0,0 +1,10 @@
pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } }
rootProject.name = "kiloparsec-evaluation"
include("server", "baseline", "wasm-baseline")
// Both fixtures resolve this checkout, never a stale published snapshot.
includeBuild("../..") {
dependencySubstitution {
substitute(module("net.sergeych:kiloparsec")).using(project(":"))
}
}
@@ -0,0 +1,8 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins { kotlin("multiplatform"); kotlin("plugin.serialization") }
kotlin {
@OptIn(ExperimentalWasmDsl::class)
wasmJs { nodejs(); binaries.executable() }
sourceSets { wasmJsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0-SNAPSHOT") } }
}
@@ -0,0 +1,57 @@
@file:OptIn(kotlin.js.ExperimentalJsExport::class, kotlin.ExperimentalUnsignedTypes::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import net.sergeych.crypto2.*
import net.sergeych.kiloparsec.*
import net.sergeych.kiloparsec.adapter.ProxyDevice
import kotlin.js.JsExport
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var input = Channel<UByteArray>(Channel.UNLIMITED)
private var client: KiloClientConnection<Unit>? = null
private fun UByteArray.hex() = joinToString("") { it.toString(16).padStart(2, '0') }
private fun String.bytes() = chunked(2).map { it.toUByte(16) }.toUByteArray()
/** Full-protocol evaluation bridge. Hex avoids exposing Kotlin objects to JS. */
@JsExport
fun start(expectedPublicKeyHex: String, sendBlock: (String) -> Unit, onReady: () -> Unit, onError: (String) -> Unit) {
scope.launch {
try {
initCrypto()
val output = Channel<UByteArray>(Channel.UNLIMITED)
val connection = KiloClientConnection(KiloInterface<Unit>(), ProxyDevice(input, output), Unit, SigningSecretKey.new())
client = connection
scope.launch { for (block in output) sendBlock(block.hex()) }
scope.launch { connection.run() }
if (connection.remoteId()?.keyBytes?.hex() != expectedPublicKeyHex) error("Unexpected server identity")
onReady()
} catch (e: Throwable) { onError(e.toString()) }
}
}
@JsExport
fun receiveBlock(hex: String) { check(input.trySend(hex.bytes()).isSuccess) }
@JsExport
fun echo(text: String, onResult: (String) -> Unit, onError: (String) -> Unit) {
scope.launch {
try {
val echo by command<String, String>()
onResult(requireNotNull(client).call(echo, text))
} catch (e: Throwable) { onError(e.toString()) }
}
}
@JsExport
fun close() { scope.cancel() }
/** Same startup workload as the JS and TS comparison: sodium plus one identity. */
@JsExport
fun initializeIdentity(onResult: (String) -> Unit, onError: (String) -> Unit) {
scope.launch {
try { initCrypto(); onResult(SigningSecretKey.new().verifyingKey.keyBytes.hex()) }
catch (e: Throwable) { onError(e.toString()) }
}
}
fun main() {}
+12
View File
@@ -0,0 +1,12 @@
// Sodium must be initialized before instantiating Kotlin/Wasm bindings.
import sodiumModules from './wasm-sodium.cjs';
const { sodium, rawSodium } = sodiumModules;
import * as joda from '@js-joda/core';
import { instantiate } from './wasm-baseline/build/compileSync/wasmJs/main/productionExecutable/kotlin/kiloparsec-evaluation-wasm-baseline.uninstantiated.mjs';
await sodium.ready;
const { exports: api } = await instantiate({
'@js-joda/core': joda,
'libsodium-sumo': rawSodium,
'libsodium-wrappers-sumo': { default: sodium },
});
export { api };
+43
View File
@@ -0,0 +1,43 @@
// Disposable localhost interop of the measured optimized Wasm distribution.
import { spawn } from 'node:child_process';
import net from 'node:net';
import { setTimeout as pause } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { WebSocket } from 'ws';
const listener = net.createServer();
await new Promise((resolve,reject)=>listener.once('error',reject).listen(0,'127.0.0.1',resolve));
const port=listener.address().port;
await new Promise(resolve=>listener.close(resolve));
const server=spawn(fileURLToPath(new URL('./server/build/install/server/bin/server',import.meta.url)),[String(port)],{stdio:['ignore','pipe','pipe']});
let diagnostics=''; let exited=false; let socket; let api;
server.stdout.on('data',b=>{ diagnostics=(diagnostics+b).slice(-8000); });
server.stderr.on('data',b=>{ diagnostics=(diagnostics+b).slice(-8000); });
server.on('exit',()=>{exited=true;});
try {
let ready=false;
for(let n=0;n<100&&!exited;n++) {
ready=await new Promise(resolve=>{const s=net.connect(port,'127.0.0.1');s.once('connect',()=>{s.destroy();resolve(true);});s.once('error',()=>resolve(false));});
if(ready) break;
await pause(100);
}
if(!ready) throw Error(`Fixture did not start: ${diagnostics}`);
({api}=await import('./build/measurement/wasm/client.mjs'));
socket=new WebSocket(`ws://127.0.0.1:${port}/kp`);
socket.on('message',bytes=>api.receiveBlock(Buffer.from(bytes).toString('hex')));
await new Promise((resolve,reject)=>{
const timeout=setTimeout(()=>reject(Error('Wasm handshake timeout')),10000);
socket.once('error',reject);
socket.once('open',()=>api.start('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8',
hex=>socket.send(Buffer.from(hex,'hex')),()=>{clearTimeout(timeout);resolve();},message=>{clearTimeout(timeout);reject(Error(message));}));
});
const result=await new Promise((resolve,reject)=>{
const timeout=setTimeout(()=>reject(Error('Wasm echo timeout')),10000);
api.echo('Kotlin Wasm baseline ✓',value=>{clearTimeout(timeout);resolve(value);},message=>{clearTimeout(timeout);reject(Error(message));});
});
if(result!=='Kotlin Wasm baseline ✓') throw Error('Wrong echo');
console.log('WASM_BASELINE_AUTHENTICATED_PINNED_ECHO_OK');
} catch(e) { console.error(String(e));process.exitCode=1; }
finally {
api?.close(); socket?.terminate(); server.kill('SIGTERM');
if(!exited){const timer=setTimeout(()=>server.kill('SIGKILL'),3000);await new Promise(resolve=>server.once('exit',resolve));clearTimeout(timer);}
}
+3
View File
@@ -0,0 +1,3 @@
// Match the wrapper's CommonJS import path to retain a single sodium instance.
exports.sodium = require('libsodium-wrappers-sumo');
exports.rawSodium = require('libsodium-sumo');