97 Commits
Author SHA1 Message Date
sergeych 009d62a155 Release 0.8.0 with replay-safe prioritized transport 2026-10-21 16:35:20 +04:00
sergeych 237d404bcd Add authenticated protocol 2 and TypeScript WASM client 2026-10-19 04:17:34 +04:00
sergeych 88ceeb2ae9 Clarify cross-platform KLIB publication support 2026-10-15 08:41:51 +04:00
sergeych a1f1789d7b Migrate API documentation publisher to Dokka 2.2
Use the current publication task and retain the existing output directory and upload destination. Allocate sufficient memory for Kotlin multiplatform API analysis.
2026-10-15 08:40:13 +04:00
sergeych 617f22dda1 Add prioritized transport, bounded reconnect, and configurable keepalive
Carry numeric priorities through encrypted RPC and adapter writers with bounded admission. Correct push semantics and socket lifecycle cleanup, expose dispatch gating, and add opt-in connection health.

Preserve the WebSocket setup API while allowing applications to override keepalive settings. Enable Android socket publication and document the 0.7.2-SNAPSHOT release workflow.
2026-10-15 08:36:34 +04:00
sergeych 0a52bb5c03 Add Google repository for documentation build 2026-08-11 14:57:25 +04:00
sergeych 271cdd18e3 Close owned WebSocket clients after reconnects 2026-08-11 14:45:37 +04:00
sergeych 19b349143d Add disconnect lifecycle handlers 2026-08-11 14:40:50 +04:00
sergeych 7bebbf7bd7 0.7.1-SNAPSHOT: small improvements to make it more universal and easy to use 2026-05-31 00:12:35 +03:00
sergeych 2829bcfecd Merge remote-tracking branch 'origin/master'
# Conflicts:
#	build.gradle.kts
2025-12-17 10:08:54 +01:00
sergeych 7cec803174 0.7.0-SNAPSHOT: migration to kotlin 2.2.21, upgrade dependencies, IOS/MacOS compatibility for broken kotlin.time/kotlinx.datetime 2025-12-17 10:07:02 +01:00
sergeych fe4cb2ca21 readme formatting 2025-11-02 14:13:47 +01:00
sergeych 6dc246e68c readme errors fix 2025-11-02 14:12:00 +01:00
sergeych bc5b5b3b3a 0.6.12 for all platforms: upgrade kotlin & dependencies 2025-10-24 18:46:19 +02:00
sergeych bc46e11158 cosmetics 2025-09-20 05:21:36 +04:00
sergeych 40f4352a31 more info n update in readme 2025-04-30 14:28:50 +04:00
sergeych cb696f5c0f 0.6.9-snapshot: wbesock can use text frames too (good for xiaomi) 2025-04-30 14:16:07 +04:00
sergeych fe96ac69d7 removed unused stuff 2025-03-27 13:37:45 +03:00
sergeych 212f82dedd 0.6.8 for all platforms 2025-03-27 00:26:10 +03:00
sergeych 59b7310385 version bump 2025-03-27 00:08:50 +03:00
sergeych 8837e49248 0.6.6 for all platforms 2025-03-26 23:50:20 +03:00
sergeych 7e1f7ec4aa fixed bug that prevented kilo client to restart if the channel was closed while channel send operation was suspended (featurebug of channels, one of) 2025-03-16 11:52:48 +03:00
sergeych 146878629e v.0.6.6-SNAPSHOT experimental wasmJs support, newer 2025-03-08 02:13:13 +03:00
sergeych d7fb26c03b v.0.6.5-SNAPSHOT experimental wasmJs support 2025-03-07 15:16:07 +03:00
sergeych 74c8bdde71 v.0.6.3 stable 2025-03-07 03:00:58 +03:00
sergeych f22ecf493b upgrade deps 2025-02-24 09:28:03 +03:00
sergeych 2b35112566 added copyright & tradewartime licensing 2025-02-20 12:10:09 +03:00
sergeych a80820b9a8 fixed/improved readme for publications 2025-02-20 11:58:13 +03:00
sergeych c0bc0e3dfe fix some ktor deprecations 2025-02-18 11:31:21 +03:00
sergeych 2d593e4107 coroutines core version upgraded 2025-02-18 11:30:47 +03:00
sergeych c1bd6f09a9 0.6.* started:
kotlin upgrade to 2.1.0
ktor updgared to 3.1.6
less debug noise
yet no wasmJS
2025-02-18 11:24:47 +03:00
sergeych 0ff27e6de9 kotlin upgrade to 2.1.0
less debug noise
use upgraded crypto2 version
2025-02-03 17:14:29 +03:00
sergeych 59906bbd2f kotlin upgrade to 2.0.21 2024-12-26 03:25:11 +03:00
sergeych 7871dc2d3d better support for code-based exceptions 2024-12-10 10:36:57 +03:00
sergeych a0dce8e604 we don't need to install Routing in web socket server - it is not used and an conflict 2024-11-27 10:45:23 +07:00
sergeych 04ffde421d 0.5.1-SNAPSHOT: crypto2 upgraded 2024-11-26 18:57:59 +07:00
sergeych 9545ca28cf websocket client now includes transport device to use in higher order protocols 2024-11-23 11:47:56 +07:00
sergeych 4098358233 +client.localIdentity 2024-11-22 09:18:07 +07:00
sergeych f2d8330ccc multiple onConnect in sessions support 2024-11-19 18:20:53 +07:00
sergeych 1032eebbbe extending functionality for kilogin 2024-11-19 12:12:41 +07:00
sergeych 93ab8ddf91 extending functionality for kilogin 2024-09-15 12:28:40 +03:00
sergeych 6ce1b576ee version bump 2024-09-01 19:42:23 +02:00
sergeych 99e98827f7 version bump 2024-08-30 11:05:19 +02:00
sergeych 26564b6081 0.4.1: performance, incompatible keys ID (as of crypto2 upgrade), fixed tests to properly run on Macos on arm64 target 2024-08-19 18:34:11 +02:00
sergeych 9ddb1209c9 more UDP tests 2024-08-11 19:08:49 +02:00
sergeych b68232653a readme docs 2024-08-11 16:31:08 +02:00
sergeych 2e4f551e8e more docs 2024-08-11 16:28:09 +02:00
sergeych 40b8723132 0.3.2: UDP timeout support, fix #4 2024-08-11 15:11:32 +02:00
sergeych 4d178d951f 0.3.2: UDP support 2024-08-11 14:17:54 +02:00
sergeych f6fbf8e58e readme fixes 2024-08-10 11:39:37 +02:00
sergeych 3c915a8f58 updates for moved home page in gitea 2024-08-10 11:37:02 +02:00
sergeych 515278c264 more docs 2024-08-10 11:26:52 +02:00
sergeych 7100fa5f76 more docs 2024-08-10 11:26:24 +02:00
sergeych 77e293bef2 documentation script 2024-08-10 11:15:22 +02:00
sergeych d6f257de14 0.3.1 release: async pushes and better binary format 2024-08-10 11:14:26 +02:00
sergeych 8a21a836e5 docs 2024-08-05 17:57:27 +02:00
sergeych 439e229294 release 0.2.6: ktor-based async sockets for all native targets 2024-08-04 02:05:35 +02:00
sergeych ffcdcf7350 0.2.5-snapshot native support for tcp client/server 2024-08-03 15:36:04 +02:00
sergeych 26d1f3522f started migration to ktor sockets (e.g. tcp on all platforms but JS) 2024-08-02 02:47:26 +02:00
sergeych 326b92142d readme update 2024-08-01 03:03:23 +02:00
sergeych 18c878dfb5 release 0.2.4 2024-08-01 02:36:37 +02:00
sergeych 1f7e7f88fa release 0.2.4 2024-08-01 02:36:16 +02:00
sergeych e0b6dea168 release 0.2.3 2024-08-01 02:31:32 +02:00
sergeych 26db60a211 readme update 2024-08-01 02:17:45 +02:00
sergeych 7c0edfb898 extending functionality for kilogin 2024-07-24 22:49:30 +03:00
sergeych 5df6143c75 adopted crypto2 0.4.* 2024-06-25 10:39:00 +07:00
sergeych 38fbca955c fixed automatic connection reestablishing for web and tcp 2024-06-18 16:11:13 +07:00
sergeych 825c0bd5f7 fixed automatic reconnection in TCP client/server 2024-06-17 17:42:16 +07:00
sergeych 0d3a8ae95c separate BreakConnectionException and more correct processing for ClosedException. 2024-06-17 13:01:09 +07:00
sergeych 4f6bc3c77e missing docs on builder#addErrors 2024-06-17 10:49:01 +07:00
sergeych 38d800c7ac added method to import errors into client interface KiloClient.Builder#addErrors 2024-06-17 10:44:12 +07:00
sergeych ae624ee051 packed nonce is now unsigned 2024-06-17 10:00:41 +07:00
sergeych 3dd1654f70 simplifies nonce calculation 2024-06-14 11:52:43 +07:00
sergeych 9660379891 migrated to crypto2 and kitlin2, no direct libsodium. Better session 2024-06-13 11:57:32 +07:00
sergeych 51873aa9b1 work in progress: restore connection 2024-04-23 21:49:15 +03:00
sergeych 93dc66acc5 work in porgress: fixing close connection 2024-03-23 01:29:48 +01:00
sergeych c0ca802a30 use mp bintools and added more macos apple targets 2024-03-16 20:33:22 +01:00
sergeych 745eb9ccdf small fix in client, more docs 2024-02-20 02:09:25 +03:00
sergeych 192f7e135f WS layer with tests 2023-11-23 10:58:37 +03:00
sergeych 3a56e67c24 remove cmd prefix from commands by delegate 2023-11-23 01:21:01 +03:00
sergeych ae3af68dab crypto2 as a separate library 2023-11-23 01:01:44 +03:00
sergeych 67c0009b5b use top-level Seal class 2023-11-22 12:38:57 +03:00
sergeych 7042e41d70 refactoring signing keys 2023-11-22 11:52:43 +03:00
sergeych e619b45485 removed nullable array from transport device, as channels close() is more than enough 2023-11-20 15:49:58 +03:00
sergeych e7abbe6d1d KiloSession.onConnected handler added 2023-11-17 17:43:38 +03:00
sergeych c6ac6f5907 fixed local host with accept and added tests 2023-11-17 17:22:09 +03:00
sergeych f02b390ed4 tcp optimization and graceful close 2023-11-15 11:47:56 +03:00
sergeych f92431a281 network transport with remote address, universal server 2023-11-15 02:25:57 +03:00
sergeych 96edbb2040 fixes, tcp/ip optimizations 2023-11-15 02:11:52 +03:00
sergeych bcf0140edb more readme 2023-11-14 03:36:58 +03:00
sergeych 4e2748f2ae added readme 2023-11-14 03:27:36 +03:00
sergeych 814263c11d removed debug noise 2023-11-14 03:08:37 +03:00
sergeych 0c05cce13b cancellability experiments in tests 2023-11-14 02:54:18 +03:00
sergeych 75e91f8092 async socket closing logic 2023-11-14 02:50:42 +03:00
sergeych 1814572a04 working suspended TCP block device on JVM (server and client) 2023-11-14 02:27:10 +03:00
sergeych fe29bec1b0 basic tcp connect async inmplementation for JVM/NIO 2023-11-14 01:47:30 +03:00
sergeych 8fc24567f0 splitting bracnhes 2023-11-13 22:38:11 +03:00
175 changed files with 8781 additions and 1389 deletions

No files matched your search

+14 -3
View File
@@ -5,6 +5,7 @@ build/
!**/src/test/**/build/ !**/src/test/**/build/
### IntelliJ IDEA ### ### IntelliJ IDEA ###
.idea
.idea/modules.xml .idea/modules.xml
.idea/jarRepositories.xml .idea/jarRepositories.xml
.idea/compiler.xml .idea/compiler.xml
@@ -23,8 +24,7 @@ out/
.project .project
.settings .settings
.springBeans .springBeans
.sts4-cache .sts4-caches
bin/
!**/src/main/**/bin/ !**/src/main/**/bin/
!**/src/test/**/bin/ !**/src/test/**/bin/
@@ -39,4 +39,15 @@ bin/
.vscode/ .vscode/
### Mac OS ### ### Mac OS ###
.DS_Store .DS_Store
# More
.kotlin
/.idea/workspace.xml
/.gigaide/gigaide.properties
local.properties
/kotlin-js-store/yarn.lock
# JVM crash diagnostics must never be published
*.hprof
hs_err_pid*.log
-8
View File
@@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="uk.co.ben_gibson.git.link.SettingsState">
<option name="host" value="e0f86390-1091-4871-8aeb-f534fbc99cf0" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.1.2-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.1.2-SNAPSHOT.jar" />
</artifact>
</component>
@@ -1,7 +1,7 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.1.0-SNAPSHOT"> <artifact type="jar" name="kiloparsec-js-0.2.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path> <output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.1.0-SNAPSHOT.jar"> <root id="archive" name="kiloparsec-js-0.2.1-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jsMain" /> <element id="module-output" name="kiloparsec.jsMain" />
</root> </root>
</artifact> </artifact>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.2.2-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.2.2-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.2.3">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.2.3.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.2.4">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.2.4.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.2.5">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.2.5.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.2.5-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.2.5-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.2.6">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.2.6.jar" />
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.3.1">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.3.1.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.3.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.3.1-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.3.2">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.3.2.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.3.3">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.3.3.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.4.1">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.4.1.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-js-0.4.3">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-js-0.4.3.jar">
<element id="module-output" name="kiloparsec.jsMain" />
</root>
</artifact>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.1.2-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.1.2-SNAPSHOT.jar" />
</artifact>
</component>
@@ -1,7 +1,7 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.1.0-SNAPSHOT"> <artifact type="jar" name="kiloparsec-jvm-0.2.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path> <output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.1.0-SNAPSHOT.jar"> <root id="archive" name="kiloparsec-jvm-0.2.1-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jvmMain" /> <element id="module-output" name="kiloparsec.jvmMain" />
</root> </root>
</artifact> </artifact>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.2.2-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.2.2-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.2.3">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.2.3.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.2.4">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.2.4.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.2.5">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.2.5.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.2.5-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.2.5-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.2.6">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.2.6.jar" />
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.3.1">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.3.1.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.3.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.3.1-SNAPSHOT.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.3.2">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.3.2.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.3.3">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.3.3.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.4.1">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.4.1.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+8
View File
@@ -0,0 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="kiloparsec-jvm-0.4.3">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="kiloparsec-jvm-0.4.3.jar">
<element id="module-output" name="kiloparsec.jvmMain" />
</root>
</artifact>
</component>
+24
View File
@@ -1,5 +1,29 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<DBN-PSQL>
<case-options enabled="true">
<option name="KEYWORD_CASE" value="lower" />
<option name="FUNCTION_CASE" value="lower" />
<option name="PARAMETER_CASE" value="lower" />
<option name="DATATYPE_CASE" value="lower" />
<option name="OBJECT_CASE" value="preserve" />
</case-options>
<formatting-settings enabled="false" />
</DBN-PSQL>
<DBN-SQL>
<case-options enabled="true">
<option name="KEYWORD_CASE" value="lower" />
<option name="FUNCTION_CASE" value="lower" />
<option name="PARAMETER_CASE" value="lower" />
<option name="DATATYPE_CASE" value="lower" />
<option name="OBJECT_CASE" value="preserve" />
</case-options>
<formatting-settings enabled="false">
<option name="STATEMENT_SPACING" value="one_line" />
<option name="CLAUSE_CHOP_DOWN" value="chop_down_if_statement_long" />
<option name="ITERATION_ELEMENTS_WRAPPING" value="chop_down_if_not_single" />
</formatting-settings>
</DBN-SQL>
<ScalaCodeStyleSettings> <ScalaCodeStyleSettings>
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" /> <option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
</ScalaCodeStyleSettings> </ScalaCodeStyleSettings>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
-6
View File
@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ReplaceUntilWithRangeUntil" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
</profile>
</component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.20" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="showProblemsInCodeBlocks" value="false" />
</component>
</project>
+1 -2
View File
@@ -1,10 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration"> <component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" /> <file type="web" url="file://$PROJECT_DIR$" />
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="17 (5)" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>
</project> </project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ScalaCompilerConfiguration">
<option name="separateProdTestSources" value="false" />
</component>
</project>
Generated
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>
+106
View File
@@ -0,0 +1,106 @@
# Numeric transport priority — protocol 2
```kotlin
import net.sergeych.kiloparsec.call
import net.sergeych.kiloparsec.push
remote.call(command, arguments, priority = 100)
remote.push(notification, arguments, priority = 0)
```
Larger signed `Int` values go first within the allowed nonce range. Equal
priorities use nonce order for encrypted packets and FIFO for unnumbered packets. Existing
call/push entry points remain unchanged and use zero unless invoked inside an
explicit priority call context. The overloads are extension functions, preserving
existing RemoteInterface implementers. A coroutine context carries the selected
value through the encrypted RPC wrappers; it becomes serialized outer-block data.
The transport owns a nonce-aware outgoing writer and submits one block at a time
to the adapter, preserving the selected order through the carrier. Waiting for a response does not occupy the
writer: calls retain independent request IDs and response waiters. Both transport
responses and errors copy the request's priority, including encrypted RPC errors
returned inside an outer response. Pushes have no normal response.
## Wire compatibility
The legacy block tag and body are unchanged. Nonzero priority is appended as four
big-endian two's-complement bytes. Zero omits the trailer entirely. The new reader
defaults to zero only when the trailer is absent; a partial trailer is malformed.
This relies on each Transport.Block having its own finite byte-array frame. TCP's
existing length prefix, WebSocket messages and UDP data blocks provide that
boundary. The old decoder consumes the legacy body and ignores trailing bytes.
Do not use this EOF-terminated extension to concatenate unframed Block objects or
embed them directly in a positional collection; wrap each block in its own bytes.
The priority trailer itself retains its legacy encoding. The secure handshake
requires protocol 2; this does not enable communication with protocol-1 peers. BiPack @Extendable is deliberately not added: it would insert a new
field-count prefix before existing fields and break old readers.
## Adapter behavior
TCP, WebSocket client/server, and UDP use a per-connection priority writer.
The writer reads the legacy header lengths and skips payloads to locate priority;
it does not deserialize/copy/decrypt the payload or guess from the last four bytes.
`Transport.Device.send(block)` defaults to the existing output channel for custom
or legacy devices. Built-in ProxyDevice-based adapters override it to enter their
writer directly. The old output channel remains available, bridged into that writer;
new transports should call send so pending senders are visible to priority ordering.
Transport callers now receive nonce-aware scheduling even with a custom FIFO
adapter. Direct adapter users retain the adapter-level scheduling behavior.
Queue admission is bounded to 128 waiting blocks and 32 MiB (plus the block
currently being written). Overflow fails explicitly rather than allocating an
unbounded backlog. Send awaits its adapter write callback, not a remote RPC reply.
Queued cancellation removes the block; cancellation cannot recall a write already
started. Closing the writer releases pending senders. There is no rate limiter or
batch-collection delay. WebSocket writes flush after each block to avoid excessive
look-ahead in the engine's FIFO. Socket/TLS buffers and network loss can still delay
control behind bytes already submitted; priority is not a preemption guarantee.
## Nonce-aware admission and fairness
`OutQueue.push(message, nonce, priority)` records scheduling metadata locally; no
new wire fields are added. `pop()` finds the smallest queued nonce and selects the
highest-priority message no more than 2730 positions ahead of it. This is two
thirds of the 4096-position receive replay window. Gaps count as nonce distance,
not as transmitted packets. The bound prevents an old encrypted message from
being starved until it becomes stale. It leaves headroom for external reordering,
but does not promise acceptance after arbitrary network delay or reordering.
A transport's preparation mutex covers nonce allocation, encryption, final block
encoding and queue admission. Encryption remains in the secure-layer callbacks.
All encrypted paths (identity/finished, calls, pushes and replies) use this path.
The mutex is released before waiting for transmission. Full queues reject
admission immediately; there is no capacity wait while holding this mutex.
Cancellation may leave a nonce gap but never reuses a nonce.
The transport writer serializes carrier submissions, so a downstream adapter
cannot reorder these submissions again. Public direct writes to an adapter's
output channel bypass this transport contract.
Large-message preparation is not preemptible by priority. A local JVM probe with
concurrent 1 MiB and 1 KiB payloads measured preparation including mutex waiting
at medians of about 87 ms for both sizes (maximum about 118 ms). This includes
encoding and allocation, not just cipher time; it is a local observation, not a
throughput or latency guarantee.
## Current verification
Focused tests cover replay-window boundaries and bitmap wraparound, concurrent
nonce admission in both directions, duplicate/stale delivery, local send failure
recovery, cancellation/capacity/close, and an encrypted 4098-message backlog whose
old low-priority message remains acceptable. The saturated encrypted-link test
continues to pass. Kotlin JVM tests, JS/Wasm compilation, TypeScript tests and
TypeScript-to-JVM interoperability were checked for this change.
## Earlier priority-release verification
Focused JVM tests cover signed numeric ordering, stable ties, queued cancellation,
bounded admission, close, absent/partial trailers, unchanged legacy bodies, and
priority inheritance through encrypted concurrent calls and encrypted errors.
An encrypted simulated 16 KiB/s link saturated in both directions maintains control
round trips below 400 ms and aggregate utilization above 80% of full-duplex capacity.
This simulation does not model kernel/TLS buffering or packet loss.
JVM, Android and JS compilation and socket/resource lifecycle tests pass.
Caravan also checked call/error/response fixtures with the actual packaged
0.7.1-SNAPSHOT binary and the new 0.7.2-SNAPSHOT binary in separate JVM processes.
+362
View File
@@ -0,0 +1,362 @@
# Kiloparsec
The Kotlin implementation and the native [TypeScript + WASM client](typescript/README.md)
are maintained here together. Current source version **0.8.0** 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
provides the following transports:
| name | JVM | JS | wasmJS | native |
|-------------------|:----:|:---:|:-------:|:------:|
| TCP/IP server | ✓ | | | ✓ |
| TCP/IP client | ✓ | | | ✓ |
| UDP server | ✓ | | | ✓ |
| UDP client | ✓ | | | ✓ |
| Websockets server | ✓ | | | |
| Websockets client | ✓ | ✓ | ✓ | ✓ |
### Note on version compatibility
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
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
- iosArm64, iosX64
- macosArm64, macosArm64
- linuxArm64, linuxX64
### Non-native targets
- JS (browser and Node.js)
- JVM (android, macOS, windows, linux, everywhere where JRE is installed)
## TCP/IP and UDP transports
These are the fastest based on async socket implementation of a ktor client. They work everywhere but JS target as
there are currently no widely adopted sockets for browser JavaScript.
While UDP is faster than TCP/IP, it is less reliable, especially with commands and return values that serialize to more
than 240 bytes approx, and has no retransmission facilities (use TCP!). UDP, though, shines when all you need is
to [push](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-remote-interface/push.html) with
little or no data in it.
## Websockets server
While it is much slower than TCP or UDP, it is still faster than any http-based API; it uses binary frames based on
the Ktor server framework to easily integrate with web services. We recommend using it instead of a classic HTTP API as
it beats it in terms of speed and server load even with HTTP/2.
We recommend to create the `KiloInterface<S>` instance and connect it to the websockets and tcp servers in real
applications to get easy access from anywhere.
## Websocket client
It is slower than TCP or UDP, but it works on literally all platforms. See the sample below.
# Usage
The library should be used as a maven dependency, not as source.
## Adding dependency
### Declare maven repository:
Add the private repository to your `build.gradle.kts`, like:
```kotlin
repositories {
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
}
```
### Add dependency block
It could be, depending on your project structure, something like:
```kotlin
val commonMain by getting {
dependencies {
api("net.sergeych:kiloparsec:0.6.8")
}
}
```
## Create a shared interface for your server and the client
It could be a multiplatform library that exports it or just a shared or copied source file declaring structures
and functions available, like:
```kotlin
// Api.kt
@Serializable
class FooArgs(val text: String, val number: Int = 42)
// Server-side interface
val cmdSetFoo by command<FooArgs, Unit>()
val cmdGetFoo by command<Unit, FooArgs>()
val cmdPing by command<String, String>()
val cmdCheckConnected by command<Unit, Boolean>()
// client-side interface (called from the server)
val cmdPushClient by command<String, Unit>()
```
## Call it from the client:
Remember, we need to implement client interface `cmdPushClient` in our example, so we need to provide
local interface too:
```kotlin
// Unit: no session on the client:
val client = websocketClient<Unit>("wss://your.host.com/kp") {
// This is server-callable function we export:
on(cmdPushClient) {
"server push: $it"
}
}
// If we want to collect connected state changes (this is optional)
launch {
client.connectedStateFlow.collect {
if (it)
println("I am connected")
else
println("trying to connect...")
}
}
// now we can call server's functions
client.call(cmdSetFoo, FooArgs("bar", 117))
assertEquals(FooArgs("bar", 117), client.call(cmdGetFoo))
```
## Create a ktor-based server
Normally the server side needs some session. It is convenient and avoids sending repeating data on each request speeding
up
the protocol. With KILOPARSEC, it is a rather basic operation:
~~~kotlin
// Our session just keeps Foo for cmd{Get|Set}Foo:
data class Session(var fooState: FooArgs? = null)
// Let's now provide interface we export, it will be used on each connection automatically:
// Note server interface uses Session:
val serverInterface = KiloInterface<Session>().apply {
onConnected {
// Do some initialization
session.fooState = null
}
// Exceptions are passed through the network and re-created (re-thrown) on other side:
on(cmdGetFoo) { session.fooState ?: throw IllegalStateException("foo is not yet set") }
on(cmdSetFoo) { session.fooState = it }
}
// now create server using ktor (see ktor project for more):
val ns: NettyApplicationEngine = embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = {
setupWebsocketServer(serverInterface) { Session() }
}).start(wait = false)
~~~
## Create a TCP / IP client and server
Using plain TCP/IP is even simpler, and it works way faster than websocket one, and is _the same
protected as `wss://` (and `ws://`) the variant above due to same kiloparsec encryption in both cases. Still, a TCP/IP
client is not available in JavaScript browser targets, and custom TCP ports could often be blocked by firewalls.
Documentation is available in samples here:
- [TCP/IP server creation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-server/index.html)
- [TCP/IP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/index.html)
In short, there are two functions that implement asynchronous TCP/IP transport on all platforms buy JS:
- [acceptTcpDevice](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/accept-tcp-device.html?query=fun%20acceptTcpDevice(port:%20Int):%20Flow%3CInetTransportDevice%3E)
to create a server
- [connectTcpDevice](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/connect-tcp-device.html)
to connect to the server
## UDP client and server
Is very much straightforward, same as with TCP/IP:
- [UDP server creation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/accept-udp-device.html)
- [Connect UDP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/connect-udp-device.html)
### UDP specifics
#### Command size
Each command invocation and result are packed in a separate UDP diagram using effective binary packing.
Thus, for the best results commands and results should be relatively short, best to fit into 240 bytes. While bigger
datagrams are often transmitted successfully, the probability of the effective transmission drops with the size.
Kiloparsec UDP transport does not retransmit not delivered packets. Use TCP/IP or websocket if it is a concern.
For the best results, we recommend
using [push](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-remote-interface/index.html#1558240250%2FFunctions%2F788909594)
for remote interfaces with UDP.
#### Timeouts
As Datagrams do not form protocol itself, kiloparsec issues pings when no data is circulated between parties.
When no pings are received long enough, the kiloparsec connection is closed. There are `maxInactivityTimeout` in all
relevant functions and constructors.
Client should not issue pings manually.
## Reusing code between servers
The same instance of
the [KiloInterface](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-interface/index.html?query=open%20class%20KiloInterface%3CS%3E%20:%20LocalInterface%3CKiloScope%3CS%3E%3E)
could easily be reused with all instances of servers with different protocols.
This is a common practice to create a business logic in a `KiloInterface`, then create a TCP/IP and Websocket servers
passing the same instance of the logic to both.
## Note on the server identification
We do not recommend relying on TLS (HTTPS://, WSS://) host identification solely; in the modern world there is
a high probability of attacks on unfriendly (in respect to at least some of your users) states to the SSL certificates
chain, in which case the [MITM attack] and spoofing will be undetected. Check
the [remoteId](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/remote-id.html?query=suspend%20fun%20remoteId():%20VerifyingPublicKey?)
in your client on each connection and provide the
safe [serverSecretKey](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-server/index.html)
when creating a server.
This will effectively protect against certificate chain spoofing in the case of the application installed from the
trusted source.
__Important note__. The web application could not be completely secured this way unless is loaded from the IP-address,
as the DNS could be spoofed the same, especially when used with `Cloudflare` or other CDN that can transparently
substitute the whole site. For applications, we strongly recommend not using CDN except your own, controlled ones. You
generally can't neither detect nor repel [MITM attack] performed from _any single cloudflare 'ray'_.
## See also:
- [Source documentation](https://code.sergeych.net/docs/kiloparsec/)
- [Project's WIKI](https://gitea.sergeych.net/SergeychWorks/kiloparsec/wiki)
# Details
It is not compatible with Parsec family and no more based on the Universa crypto library. To better fit
the modern state of threats and rate of cyber crimes, KiloParsec uses more encryption and random key exchange on each
and every connection (while parsec caches session keys to avoid time-consuming keys exchange). For the same reason,
keys cryptography for session is shifted to use ed25519 curves, which are supposed to provide agreeable strength with
enough speed to protect every connection with unique new keys. Also, we completely get rid of SHA2.
Kiloparsec also uses a denser binary format [bipack](https://gitea.sergeych.net/SergeychWorks/mp_bintools), no more
key-values,
which reveals much less on the inner data structure, providing advanced
typed RPC interfaces with kotlinx.serialization. There is also Rust
implementation [bipack_ru](https://gitea.sergeych.net/DiWAN/bipack_ru).
The architecture allows connecting the same functional interfaces to several various type channels at once.
Also, the difference from parsecs is that there are no more unencrypted layer commands available to users.
All RPC is performed over the encrypted connection.
# Technical description
Kiloparsec is a full-duplex fully async (coroutine-based) Remote Procedure Call protocol with typed parameters
and support for serializing exceptions (e.g. exception thrown while executing remote command will be caught and
rethrown at the caller context).
Kiloparsec is not REST, it _has advanced session mechanisms_ and built-in authentication based on the same curve keys.
Integrated tools to prevent MITM attacks include also non-transferred independently generated token that is calculated
independently on the ends and is never transferred with the network. Comparing it somehow (visually, with QR code, etc.)
could add a very robust guarantee of the connection safety and ingenuity.
Kiloparsec has a built-in completely asynchronous (coroutine-based top-down) transport layer based on TCP (JVM only as
for
now) and the same async Websocket-based transport based on KTOR. Websocket client is multiplatform, though the server is
JVM only insofar.
# Licensing
This is a work in progress, not yet moved to the public domain;
you need to obtain a license from https://8-rays.dev or [Sergey Chernov]. For open source projects it will most be free
on some special terms.
It will be moved to open source; we also guarantee that it will be moved to open source immediately if the software
export restrictions are lifted. We do not support such practices here at 8-rays.dev.
[MITM]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack
[Sergey Chernov]: https://t.me/real_sergeych
[^1]: On some new Xiaomi phones we found problems with websocket binary frames, probably in ktor; use text frames
otherwise.
## Numeric transport priority (0.7.2-SNAPSHOT)
`call(command, args, priority = 100)` and `push(command, args, priority = 0)`
select numeric outgoing priority. Responses inherit request priority, calls remain
concurrent, and the framed wire extension interoperates with legacy peers.
See [priority API, compatibility and limits](PRIORITY.md).
## Connection health and WebSocket keepalive (0.7.2-SNAPSHOT)
Encrypted application heartbeat is opt-in: `KiloClientConnection.run()` does not
start it. Pass `ConnectionHealth` explicitly only when periodic probes are needed.
`ReconnectionOptions` configures connection deadlines, retry delay, and an optional
finite attempt budget; `reconnectExhausted` signals when that budget ends.
The server WebSocket helper retains its existing defaults and signature. Its new
configuration overload permits applications to disable the transport pinger:
```kotlin
setupWebsocketServer(api, path = "/rpc", serverKey = key,
configureWebSockets = { pingPeriodMillis = 0 }) { Session() }
```
Without heartbeat, applications must handle transport closure and bound real
request response times. A successful socket write does not prove peer reachability.
Proxy/NAT inactivity timeouts remain independent of these settings.
## Publishing
`./gradlew publishAllPublicationsToMavenRepository` publishes the configured
version to the private Gitea Maven registry using the existing local credential
file. Never commit that credential. This project can compile and publish its Apple
KLIB variants from Linux; final Apple application linking and runtime qualification
still require the appropriate Apple toolchain and devices. Check publication
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).
+15
View File
@@ -0,0 +1,15 @@
# Kiloparsec 0.8.0
Stable release of authenticated protocol 2 and the TypeScript/WASM client source.
- Authenticate RPC kind, correlation and priority; confirm accepted client identity with an encrypted server-finished response.
- Discard duplicate and stale authenticated packets without closing healthy sessions or consuming pending response waiters.
- Bound priority overtaking with the nonce-aware outgoing queue. Serialize encryption and queue admission, retaining progress for older low-priority messages.
- Preserve sessions after local send failures and use a bounded bitmap for replay tracking.
- Honor application initialization rejection on both peers, including subclasses of security and connection-break exceptions.
Protocol 2 requires coordinated peer upgrades from protocol 1. The fixes preserve the protocol-2 wire encoding. The TypeScript client retains synchronous FIFO WebSocket sending; its private npm package is distributed as source in this release.
Validation: 64 JVM tests, 47 Kotlin/JS Node tests, 38 TypeScript unit tests and live TypeScript/JVM interoperability passed. Real loopback TCP/UDP tests cover bidirectional calls and UDP duplication/reordering. All 13 Maven publications built; a separate consumer using staged release artifacts passed interoperability without source substitution. Browser, Android and Apple runtimes were not qualified in this release run.
See [the review](REVIEW-237d404.md) for issue resolutions and validation limits, and [priority semantics](PRIORITY.md) for scheduling guarantees.
+207
View File
@@ -0,0 +1,207 @@
# Review of authenticated protocol 2
Reviewed commit: `237d404bcd4d52e015b5e0d7cd4feaec4bd848ff` — Add authenticated protocol 2 and TypeScript WASM client.
Review date: 2026-10-21. Baseline: parent commit `88ceeb2`.
This document records findings for separate discussion and repair. Issue numbers are stable. Implementation changes and their validation are recorded under closed issues. Proposed remedies for open issues are discussion options, not an agreed redesign.
## Assessment
The reviewed commit fixed real authentication gaps, particularly unprotected RPC response correlation, but introduced availability regressions. The findings below preserve the original analysis. I1–I5 are now fixed for release 0.8.0; I6–I7 were closed as non-defects. No review issues remain open.
The nonce itself was already present in protocol 1. Receiver-side replay tracking, the fixed window, and counter-exhaustion checks are new. These changes should be evaluated separately from authenticated RPC metadata and the server-finished acknowledgement.
## Reason for the original change
The committed assessment says the TypeScript port exposed two protocol-1 weaknesses. Inspection of the parent code supports both mechanisms:
1. Encrypted results had inner `forId = 0`; their actual request correlation was only in the plaintext outer transport ID. An intermediary could swap two outer IDs and associate authentic answers with the wrong concurrent calls when result codecs were compatible. Protocol 2 authenticates kind, correlation and priority inside encryption.
2. `L0ClientId` returned plaintext `Unit`. An intermediary could forge that completion without the server accepting the identity message. Protocol 2 returns an encrypted server-finished record echoing the exact accepted identity bytes.
Other changes include separate client/server signature domains, fresh server exchange keys per connection, repeated-handshake rejection, nonce-state preservation during identity completion, and counter-exhaustion checks. These are distinct design decisions and need not be discussed as one indivisible feature.
## What server-finished means
It is the response to the existing `L0ClientId` RPC, not an additional network round trip or an acknowledgement of every UDP packet.
Previously: client sends encrypted identity; server replies with plaintext Unit. Now: client sends encrypted identity; server replies with encrypted `ProtectedMessage(SERVER_FINISHED, 0, 0, identityBytes)`.
The client decrypts it and checks the kind, zero correlation/priority, and exact identity payload. With an honest authenticated server, this establishes that the session-key holder processed and accepted that identity message before the client announces connection completion. It does not establish application authorization, authenticate an otherwise untrusted server key, or acknowledge subsequent RPC execution.
The server completes its local established state before sending this response. Losing the response can therefore leave the server established while the client is still waiting. This is an acknowledgement, not an atomic agreement or a delivery-recovery mechanism.
## Existing nonces versus new replay checks
Protocol 1 already contained `Package(nonce: ULong, encryptedMessage)`, `private var nonce = 0UL`, and `val n = protectedOp.invoke { nonce++ }`. It derived the cipher nonce from the session token and that counter. Each sending direction has its own encryption key/counter context.
The encryption nonce selects a unique encryption instance under a key. It is not secret, is not the RPC ID, and does not require packets to arrive in order. Reusing a nonce for distinct plaintexts under the same key is unsafe. Retransmitting an already-produced ciphertext is a different question from encrypting new plaintext with a reused nonce.
Protocol 1 decrypted using the counter supplied with each packet, without remembering which counters it had already seen. Consequently, a captured valid encrypted call could be replayed and reach application execution again.
Protocol 2 adds receiver memory: highest authenticated nonce plus the set of seen nonces in a 4,096-position window. This recognizes repeats and bounds memory, at the cost of rejecting sufficiently old unseen packets. Only successfully authenticated packets advance the window; a forged large counter cannot move it forward.
The tasks are distinct: nonce uniqueness protects encryption; replay tracking prevents repeated acceptance; authenticated RPC correlation binds an answer to its request. Neither correlation nor server-finished intrinsically requires this particular replay-window size or session-closing policy.
## Repair validation (2026-10-21)
- 42 focused JVM tests passed, including existing security, priority and transport tests and the new replay/queue/admission regressions.
- TypeScript: 38 passed; the normally skipped interop fixture was run separately and passed against this checkout's JVM server.
- Release validation: all 64 JVM and 47 Kotlin/JS Node tests passed. Real loopback UDP exercised reordered and duplicated packets and concurrent bidirectional RPCs; real TCP exercised concurrent bidirectional RPCs and priority. The new carrier test cancels its collector before closing the listener.
- All 13 Maven publications and Dokka built successfully. A standalone consumer resolved staged 0.8.0 artifacts without source substitution and passed the TypeScript interoperability suite (authentication, duplex, pushes, errors, concurrent calls and reconnect).
- Kotlin Wasm compilation passed. Browser, Android and Apple runtime qualification was not performed. Apple KLIB publication does not qualify final Apple application linking or runtime.
- Protocol-2 wire encoding remains unchanged; older protocol-2 peers still retain their own previous replay-disconnect behavior until upgraded.
- The encrypted backlog test validates all 4098 messages, including an old low-priority message, decrypt successfully after scheduling. Concurrent real JVM preparation exercises both directions and calls, replies, pushes and handshake messages.
- A temporary preparation benchmark (source retained at `/tmp/PreparationBenchmarkTest.kt`) ran concurrent 1 MiB and 1 KiB payloads under the preparation mutex. Measured medians including lock waiting were 86.86 ms and 86.60 ms, with maxima 117.40 ms and 117.65 ms. This is a local JVM observation including serialization and allocation; large-message preparation can delay small messages before queue scheduling. The benchmark source was removed from the checkout.
- Historical probe/test counts below describe the original review, not the final repair run.
## Original review validation and limits
- 14 focused JVM tests passed: 7 existing authenticated-envelope tests, 3 security-boundary tests, and 4 temporary review probes.
- The review probes checked replay-window boundaries, adjacent reordering survival, deep reordering session closure, and local send failure session closure.
- Follow-up I5 check: 3 additional focused JVM probes passed, reproducing ignored client/server initialization rejection and confirming rejection through the application `execute` gate. Probe source saved at `/tmp/ReviewI5Test.kt` and removed from the checkout.
- TypeScript tests: 35 passed, 1 interoperability test skipped.
- No live UDP impairment, browser qualification, full cryptographic audit, or sustained-load benchmark was performed.
- Temporary probe source was saved at `/tmp/Review237d404Test.kt` and removed from the checkout. This path is ephemeral.
- Existing `.idea/misc.xml` changes and untracked `kotlin-js-store/` were left untouched.
All review issues are closed: I1–I5 are included in release 0.8.0; I6–I7 require no remediation.
## Closed issues
### Issue 1 — Duplicate or stale packets terminate the session
**Resolution (2026-10-21):** Done — duplicate/stale encrypted packets are silently discarded on Kotlin and TypeScript. Incoming calls/pushes are not executed again; response authentication/replay checks run before consuming the pending waiter. A valid later reply can still complete it. Authentication/routing failures remain fatal. No automatic retry was added.
**Original finding (before repair):**
**Priority:** High for UDP. **Status:** Confirmed regression; reproduced.
`KiloParams.decrypt` rejects a previously received nonce, or an unseen nonce at least 4,096 positions behind the highest authenticated nonce. `KiloL0Interface.executeTransportBlock` turns rejection into `SecurityException`, a subclass of `BreakConnectionException`, and the transport closes.
Observed behavior:
| Delivery | Result |
| --- | --- |
| Adjacent packets reversed | Both accepted |
| Unseen nonce 4,095 positions behind highest | Accepted |
| Unseen nonce 4,096 positions behind highest | Rejected |
| Duplicate incoming call or push | Rejected; session closes |
Duplicate responses are different: the outer transport normally ignores responses whose pending request was already removed. A stale response for a still-pending call reaches authentication and can close the session.
A controlled test held an encrypted push, delivered 4,096 newer pushes, then delivered the original. The valid original packet caused session closure. An adjacent-reordering test survived. The existing duplicate-call regression also explicitly expects connection termination.
**Impact:** ordinary datagram duplication or sufficiently deep reordering becomes a session-wide failure, affecting unrelated calls. This is an availability issue, not evidence of broken encryption.
**Discussion direction:** distinguish duplicate/stale packet disposition from fatal errors; discard without repeating application execution. If reliable delivery is desired, separately specify timeouts, retransmission and response caching. Discarding duplicates alone does not implement reliable RPC.
References: [KiloParams.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt), [KiloL0Interface.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt), [Transport.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt), [AuthenticatedEnvelopeTest.kt](src/commonTest/kotlin/AuthenticatedEnvelopeTest.kt).
### Issue 2 — Priority scheduling can exceed the replay window locally
**Resolution (2026-10-21):** Done — `OutQueue` enforces the 2730-position allowance; one preparation mutex covers nonce allocation, sealing, encoding and admission. The transport owns the nonce-aware writer and submits serially to the existing adapter, preserving its selected order. Encryption stays in secure-layer callbacks. Full queues reject immediately rather than wait under the mutex.
**Original finding (before repair):**
**Priority:** High under sustained priority traffic. **Status:** Code-established interaction; actual scheduler saturation not reproduced.
Encryption allocates a nonce before `PriorityBlockWriter` selects transmission order. A low-priority encrypted packet can remain queued while newer high-priority packets overtake it. There is no fairness or maximum-overtaking guarantee. The 128-item queue bound limits simultaneous occupancy, not the total number of packets that can overtake one waiting packet.
If overtaking spans 4,096 nonce positions, eventual delivery triggers issue 1. This can occur even with ordered TCP/WebSocket delivery: the reordering happens before the carrier sends the bytes. Calls, replies and pushes share one nonce sequence per sending direction.
**Agreed design (2026-10-21):** introduce a specific `OutQueue` with `push(message, nonce, priority)` and `pop()`. Preserve encryption in its existing layer. Pass nonce metadata explicitly to the queue rather than decoding encrypted-package structures in the scheduler.
For receiver window `W = 4096`, use a local overtaking allowance `B = floor(2 * W / 3) = 2730`:
```text
pop():
oldest = minimum queued nonce
eligible = messages with nonce - oldest <= B
select highest priority among eligible
break priority ties by smallest nonce
```
Use subtraction after establishing the minimum to avoid addition overflow. The allowance is based on nonce distance, not the number of successful sends. This accommodates gaps from failed or cancelled messages and prevents starvation by indefinitely newer high-priority traffic.
One mutex per sending direction covers nonce allocation, encryption/encoding, and queue insertion as one ordered preparation/admission operation. Queue insertion and selection must themselves be synchronized. This prevents an older allocated nonce from remaining outside the queue while newer packets are transmitted. All encrypted message paths sharing that sending key must participate, including calls, replies, pushes, and handshake ciphertexts.
Wait for transmission completion outside the mutex. Queue-capacity waiting must not prevent the writer from draining the queue; coordinate capacity reservation/admission accordingly. Failed preparation may consume a nonce, which must never be reused. Cancellation and close must release reservations and completion waiters.
The queue and writer must preserve the selected transmission order. This bounds scheduler-induced reordering while leaving roughly one third of the receiver window for subsequent network/receive-processing reordering. It does not guarantee acceptance after arbitrarily deep external reordering; issue 1 remains a separate fix.
**Validation targets:** priority selection inside the permitted range; oldest-message progress under sustained higher-priority traffic; exact window boundary; nonce gaps; concurrent preparation/admission; cancellation, capacity and close behavior; and an integration test showing a delayed low-priority encrypted message arrives without becoming stale. Measure preparation-lock contention with large messages rather than assuming encryption cost is negligible.
**Implementation status:** implemented and verified. A 4098-message encrypted backlog, concurrent duplex admission, queue capacity/cancellation/close and existing saturated-link responsiveness tests pass. Network reordering beyond the receiver window remains discardable, without closing the session.
References: [KiloParams.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt), [PriorityBlockWriter.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/adapter/PriorityBlockWriter.kt).
### Issue 3 — Local send failures are misclassified as authentication failures
**Resolution (2026-10-21):** Done — plaintext remote errors are explicitly distinguished at the receive boundary. Local preparation/admission/write failures retain their original errors and do not abort the healthy session. A local reply-send failure is logged without fabricating a plaintext remote error; the caller retains its timeout/cancellation policy.
**Original finding (before repair):**
**Priority:** Medium. **Status:** Confirmed regression; reproduced.
`KiloRemoteInterface.call` catches almost every exception from `transport.callWithId`, closes the transport, and throws `SecurityException("unauthenticated outer error")`. That operation includes local encryption and sending, not only receipt of a plaintext remote error.
The priority writer throws `RemoteInterface.Exception("Outgoing block queue is full")` on admission failure. Injecting that same local send exception reproduced an authentication error and termination of both peers.
**Impact:** local congestion closes healthy sessions and unrelated pending calls; diagnostics incorrectly suggest a remote security failure.
**Discussion direction:** explicitly distinguish plaintext remote errors at the receive boundary from local encoding, admission and send failures.
References: [KiloRemoteInterface.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt), [PriorityBlockWriter.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/adapter/PriorityBlockWriter.kt).
### Issue 4 — Replay tracking adds a full-window scan per advancing packet
**Resolution (2026-10-21):** Done — Kotlin and TypeScript use 4096-bit circular replay windows. Normal in-order advancement clears one bit, large jumps clear a fixed bitmap, and only successfully authenticated packets update history.
**Original finding (before repair):**
**Priority:** Medium performance concern. **Status:** Confirmed algorithmic cost; throughput impact not benchmarked.
Kotlin uses `received.removeAll` when the high watermark advances; TypeScript scans its received set. In steady in-order traffic with a populated window, this is approximately 4,096 age checks per packet. Kotlin performs this under the lock also used for nonce allocation and decryption.
**Impact:** bounded memory, but avoidable CPU work and contention for a high-throughput transport.
**Discussion direction:** if retaining windowed replay protection, use a circular bitmap or another bounded representation with inexpensive advancement.
References: [KiloParams.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt), [client.ts](typescript/src/client.ts). Algorithm background: [RFC 6479](https://www.rfc-editor.org/rfc/rfc6479.html).
### Issue 5 — Connection-init policy rejection is swallowed
**Status:** Done on 2026-10-21 — initialization rejection now propagates on both ends.
**Design decision:** preserve Kotlin's code-based remote-party authorization through the local interface and initialization handlers. Both peers receive verified identity and session data through `KiloScope`. No trusted-key-list constructor parameter is required; the earlier proposal to add one is withdrawn.
**Original defect:** the shared `invokeAll` helper discarded every handler exception using `runCatching`, including deliberate security/connection rejection. This defect predates commit `237d404`. Follow-up probes reproduced successful connection and application execution despite rejection on either peer.
**Fix:** replace `runCatching` with explicit `try/catch`. Rethrow `LocalInterface.BreakConnectionException` and all subclasses, including `RemoteInterface.SecurityException`; continue ignoring other handler failures as before. Rejection stops subsequent handlers. The shared helper also propagates this exception family when invoked for disconnect handlers.
**Validation:** `ConnectionInitializationTest` covers client and server initialization rejection using a custom `SecurityException` subclass and a custom `BreakConnectionException` subclass. Each case verifies that the policy sees the verified remote identity, both connection runs finish, later init handlers do not run, connected state is not announced, and a subsequent client RPC fails as closed. A fifth test verifies that ordinary handler failures still allow initialization and a successful RPC. The four rejection cases failed before the fix and passed after it. All 15 focused JVM tests passed, including `AuthenticatedEnvelopeTest` and `SecurityBoundaryTest`.
**Scope:** this completes the requested exception-propagation repair. Parameter publication order and initialization-time RPC semantics were not changed; these tests do not establish a general concurrent-dispatch barrier before initialization completes.
References: [KiloClientConnection.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt), [ConnectionInitializationTest.kt](src/commonTest/kotlin/ConnectionInitializationTest.kt).
### Issue 6 — Protocol 2 requires coordinated peer upgrades
**Status:** Closed — intentional compatibility decision, not a defect; no remediation required.
Both peers require protocol 2, with no automatic v1 fallback. The new envelope and identity acknowledgement deliberately break wire compatibility with v1. Coordinated peer upgrades were an agreed design decision, not an outstanding issue.
The original issue number is retained for reference.
References: [KiloProtocol.kt](src/commonMain/kotlin/net/sergeych/kiloparsec/KiloProtocol.kt), [SECURITY.md](typescript/docs/SECURITY.md).
### Issue 7 — Replay protection is not business-operation deduplication
**Status:** Closed on 2026-10-21 — not a protocol defect; excluded from remediation.
Kiloparsec protects against replayed encrypted packets and forged or altered protocol messages. A new valid RPC issued by application code, on the same connection or after reconnecting, is a new request. The protocol cannot determine whether repeating that operation is intentional or an application mistake.
Business-operation deduplication requires application-defined semantics, such as a durable operation identifier and an explicit idempotency contract. It belongs to business logic and should not have been classified as a Kiloparsec issue.
**Resolution:** no protocol change required. The original issue number is retained for reference.
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
#
# 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.
#
set -e
# Dokka needs more Metaspace than the default Gradle daemon on this KMP project.
./gradlew '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' dokkaGeneratePublicationHtml
rsync -avz ./build/dokka/* code.sergeych.net:/bigstore/sergeych_pub/code/docs/kiloparsec
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Local-only verification; no publishing or deployment.
set -euo pipefail
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
"$repo_dir/gradlew" -p "$repo_dir" jvmTest jsNodeTest typescriptTest "$@"
npm --prefix "$repo_dir/typescript" run test:interop
+167 -44
View File
@@ -1,83 +1,206 @@
import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType /*
* Copyright (c) 2025-2026. 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.
*/
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins { plugins {
kotlin("multiplatform") version "1.9.20" kotlin("multiplatform") version "2.2.21"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20" id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21"
id("com.android.library") version "8.7.2" apply true
`maven-publish` `maven-publish`
id("org.jetbrains.dokka") version "2.2.0"
} }
group = "net.sergeych" group = "net.sergeych"
version = "0.1.0-SNAPSHOT" version = "0.8.0"
repositories { repositories {
google()
mavenCentral() mavenCentral()
mavenLocal() mavenLocal()
maven("https://maven.universablockchain.com/") maven("https://maven.universablockchain.com/")
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven")
} }
kotlin { kotlin {
jvm { jvmToolchain(21)
jvmToolchain(8) jvm()
withJava() js {
testRuns.named("test") { browser()
executionTask.configure { nodejs {
useJUnitPlatform() // Replay-window tests deliberately process more than 4096 messages.
} testTask { useMocha { timeout = "30s" } }
} }
} }
js(KotlinJsCompilerType.IR) { linuxX64()
browser { linuxArm64()
// commonWebpackConfig {
// cssSupport { macosArm64()
// enabled.set(true) macosX64()
// } iosX64()
// } iosArm64()
} iosSimulatorArm64()
mingwX64()
androidTarget {
publishLibraryVariants("release")
} }
val hostOs = System.getProperty("os.name") @OptIn(ExperimentalWasmDsl::class)
val isArm64 = System.getProperty("os.arch") == "aarch64" wasmJs {
val isMingwX64 = hostOs.startsWith("Windows") browser()
val nativeTarget = when {
hostOs == "Mac OS X" && isArm64 -> macosArm64("native")
hostOs == "Mac OS X" && !isArm64 -> macosX64("native")
hostOs == "Linux" && isArm64 -> linuxArm64("native")
hostOs == "Linux" && !isArm64 -> linuxX64("native")
isMingwX64 -> mingwX64("native")
else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
} }
val ktor_version = "3.1.1"
sourceSets { sourceSets {
all { all {
languageSettings.optIn("kotlinx.serialization.ExperimentalSerializationApi") languageSettings.optIn("kotlinx.serialization.ExperimentalSerializationApi")
languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes") languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
languageSettings.optIn("kotlin.time.ExperimentalTime")
} }
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
api("io.ktor:ktor-client-core:$ktor_version")
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.0") api("net.sergeych:crypto2:0.9.0")
api("com.ionspin.kotlin:bignum:0.3.8")
api("net.sergeych:mp_bintools:0.0.6-SNAPSHOT")
api("net.sergeych:mp_stools:1.4.1")
} }
} }
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:$ktor_version")
}
}
val ktorSocketMain by creating {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-network:$ktor_version")
}
}
androidMain.dependsOn(ktorSocketMain)
val commonTest by getting { val commonTest by getting {
dependencies { dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
implementation("org.slf4j:slf4j-simple:2.0.9") implementation("org.slf4j:slf4j-simple:2.0.17")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
}
val ktorSocketTest by creating {
dependsOn(commonTest)
}
val jvmMain by getting {
dependencies {
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version")
implementation("io.ktor:ktor-server-websockets:${ktor_version}")
implementation("io.ktor:ktor-server-netty:$ktor_version")
api("io.ktor:ktor-client-cio:$ktor_version")
}
dependsOn(ktorSocketMain)
}
val jvmTest by getting {
dependsOn(ktorSocketTest)
}
val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:$ktor_version")
} }
} }
val jvmMain by getting
val jvmTest by getting
val jsMain by getting
val jsTest by getting val jsTest by getting
val nativeMain by getting val macosArm64Main by getting {
val nativeTest by getting dependsOn(ktorSocketMain)
}
val macosArm64Test by getting {
dependsOn(ktorSocketTest)
}
val macosX64Main by getting {
dependsOn(ktorSocketMain)
}
val iosX64Main by getting {
dependsOn(ktorSocketMain)
}
val iosX64Test by getting {
dependsOn(ktorSocketTest)
}
val iosArm64Main by getting {
dependsOn(ktorSocketMain)
}
val iosArm64Test by getting {
dependsOn(ktorSocketTest)
}
val linuxArm64Main by getting {
dependsOn(ktorSocketMain)
}
val linuxArm64Test by getting {
dependsOn(ktorSocketTest)
}
val linuxX64Main by getting {
dependsOn(ktorSocketMain)
}
val linuxX64Test by getting {
dependsOn(ktorSocketTest)
}
} }
} }
publishing {
val mavenToken by lazy {
File("${System.getProperty("user.home")}/.gitea_token").readText()
}
repositories {
if (!providers.gradleProperty("localPublishingOnly").map(String::toBoolean).getOrElse(false)) {
maven {
credentials(HttpHeaderCredentials::class) {
name = "Authorization"
value = mavenToken
}
url = uri("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
authentication {
create("Authorization", HttpHeaderAuthentication::class)
}
}
}
}
}
dokka {
dokkaPublications.html {
outputDirectory.set(layout.buildDirectory.dir("dokka"))
}
}
android {
namespace = "net.sergeych.kiloparsec"
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
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
View File
@@ -1 +1,20 @@
#
# 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.
#
kotlin.code.style=official kotlin.code.style=official
kotlin.mpp.applyDefaultHierarchyTemplate=false
kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.native.ignoreDisabledTargets=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
org.gradle.caching=true
+11 -1
View File
@@ -1,5 +1,15 @@
#
# 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.
#
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
Vendored
+5 -11
View File
@@ -1,19 +1,13 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # You may use, distribute and modify this code under the
# you may not use this file except in compliance with the License. # terms of the private license, which you must obtain from the author
# You may obtain a copy of the License at
# #
# https://www.apache.org/licenses/LICENSE-2.0 # To obtain the license, contact the author: https://t.me/real_sergeych or email to
# # real dot sergeych at gmail.
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# #
############################################################################## ##############################################################################
+11
View File
@@ -1,5 +1,16 @@
/*
* 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.
*/
pluginManagement { pluginManagement {
repositories { repositories {
google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }
@@ -1,27 +0,0 @@
package net.sergeych.crypto
import com.ionspin.kotlin.crypto.LibsodiumInitializer
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private var isReady = false
private val readyAccess = Mutex()
/**
* Library initialization: should be called before all other calls.
* It is safe and with little performance penalty to call it multiple times.
*/
suspend fun initCrypto() {
// faster to check with no lock
if( !isReady) {
readyAccess.withLock {
// recheck with lock, it could be ready by now
if( !isReady ) {
LibsodiumInitializer.initialize()
isReady = true
}
}
}
}
@@ -1,81 +0,0 @@
package net.sergeych.crypto
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
/**
* Multi-signed data box. Use [SignedBox.invoke] to easily create
* instances and [SignedBox.plus] to add more signatures (signing keys), and
* [SignedBox.contains] to check for a specific key signature presence.
*
* It is serializable and checks integrity on deserialization. If any of seals does not
* match the signed [message], it throws [IllegalSignatureException] _on deserialization_.
* E.g., if you have it deserialized, it is ok, check it contains all needed keys among
* signers.
*
* __The main constructor is used for deserializing only__. Don't use it directly unless you
* know what you are doing as it may be dangerous.Use one of the above to create or change it.
*/
@Serializable
class SignedBox(
val message: UByteArray,
private val seals: List<Seal>,
@Transient
private val checkOnInit: Boolean = true
) {
/**
* If this instance is not signed by a given key, return new instance signed also by this
* key, or return unchanged (same) object if it is already signed by this key; you
* _can't assume it always returns a copied object!_
*/
operator fun plus(key: Key.Signing): SignedBox =
if (key.verifying in this) this
else SignedBox(message, seals + Seal.create(key, message), false)
/**
* Check that it is signed with a specified key.
*/
operator fun contains(verifyingKey: Key.Verifying): Boolean {
return seals.any { it.key == verifyingKey }
}
init {
if (seals.isEmpty()) throw IllegalArgumentException("there should be at least one seal")
if (checkOnInit) {
if (!seals.all { it.verify(message) }) throw IllegalSignatureException()
}
}
/**
* A key + signature pair for [SignedBox] boxes, usually you don't use it
* directly bug call [SignedBox] constructor or [SignedBox.plus] to
* add seals.
*/
@Serializable
data class Seal(val key: Key.Verifying, val signature: UByteArray) {
fun verify(message: UByteArray): Boolean = key.verify(signature, message)
companion object {
fun create(key: Key.Signing, message: UByteArray): Seal {
return Seal(key.verifying, key.sign(message))
}
}
}
companion object {
/**
* Create a new instance with a specific data sealed by one or more
* keys. At least one key is required to disallow providing not-signed
* instances, e.g. [SignedBox] is guaranteed to be properly sealed when
* successfully instantiated.
*
* @param data a message to sign
* @param keys a list of keys to sign with, should be at least one key.
* @throws IllegalArgumentException if keys are not specified.
*/
operator fun invoke(data: UByteArray, vararg keys: Key.Signing): SignedBox =
SignedBox(data, keys.map { Seal.create(it, data) }, false)
}
}
@@ -1,7 +0,0 @@
package net.sergeych.crypto
import net.sergeych.bintools.CRC
fun isValidContrail(data: UByteArray): Boolean = CRC.crc8(data.copyOfRange(1, data.size)) == data[0]
fun createContrail(data: UByteArray): UByteArray = ubyteArrayOf(CRC.crc8(data)) + data
@@ -1,75 +0,0 @@
package net.sergeych.crypto
import com.ionspin.kotlin.crypto.signature.InvalidSignatureException
import com.ionspin.kotlin.crypto.signature.Signature
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.sergeych.crypto.Key.Signing
/**
* Keys in general: public, secret and later symmetric too.
* Keys could be compared to each other for equality and used
* as a Map keys (not sure about js).
*
* Use [Signing.pair] to create new keys.
*/
@Serializable
sealed class Key {
abstract val packed: UByteArray
override fun equals(other: Any?): Boolean {
return other is Key && other.packed contentEquals packed
}
override fun hashCode(): Int {
return packed.contentHashCode()
}
override fun toString(): String = packed.encodeToBase64Url()
/**
* Public key to verify signatures only
*/
@Serializable
@SerialName("pvk")
class Verifying(override val packed: UByteArray) : Key() {
/**
* Verify the signature and return true if it is correct.
*/
fun verify(signature: UByteArray, message: UByteArray): Boolean = try {
Signature.verifyDetached(signature, message, packed)
true
} catch (_: InvalidSignatureException) {
false
}
override fun toString(): String = "Pub:${super.toString()}"
}
/**
* Secret key to sign only
*/
@Serializable
@SerialName("ssk")
class Signing(override val packed: UByteArray) : Key() {
val verifying: Verifying by lazy {
Verifying(Signature.ed25519SkToPk(packed))
}
fun sign(message: UByteArray): UByteArray = Signature.detached(message, packed)
override fun toString(): String = "Sct:${super.toString()}"
companion object {
data class Pair(val signing: Signing, val verifying: Verifying)
fun pair(): Pair {
val p = Signature.keypair()
return Pair(Signing(p.secretKey), Verifying(p.publicKey))
}
}
}
}
class IllegalSignatureException: RuntimeException("signed data is tampered or signature is corrupted")
@@ -1,85 +0,0 @@
@file:Suppress("unused")
package net.sergeych.crypto
import com.ionspin.kotlin.crypto.secretbox.SecretBox
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
import com.ionspin.kotlin.crypto.util.LibsodiumRandom
import kotlinx.serialization.Serializable
import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
class DecryptionFailedException : RuntimeException("can't encrypt: wrong key or tampered message")
@Serializable
data class WithNonce(
val cipherData: UByteArray,
val nonce: UByteArray,
)
@Serializable
data class WithFill(
val data: UByteArray,
val safetyFill: UByteArray? = null
) {
constructor(data: UByteArray, fillSize: Int) : this(data, randomBytes(fillSize))
}
fun randomBytes(n: Int): UByteArray = if (n > 0) LibsodiumRandom.buf(n) else ubyteArrayOf()
fun randomBytes(n: UInt): UByteArray = if (n > 0u) LibsodiumRandom.buf(n.toInt()) else ubyteArrayOf()
/**
* Uniform random in `0 ..< max` range
*/
fun randomUInt(max: UInt) = LibsodiumRandom.uniform(max)
fun randomUInt(max: Int) = LibsodiumRandom.uniform(max.toUInt())
fun <T: Comparable<T>>T.limit(range: ClosedRange<T>) = when {
this < range.start -> range.start
this > range.endInclusive -> range.endInclusive
else -> this
}
fun <T: Comparable<T>>T.limitMax(max: T) = if( this < max ) this else max
fun <T: Comparable<T>>T.limitMin(min: T) = if( this > min ) this else min
fun randomNonce(): UByteArray = randomBytes(crypto_secretbox_NONCEBYTES)
/**
* Secret-key encrypt with authentication.
* Generates random nonce and add some random fill to protect
* against some analysis attacks. Nonce is included in the result. To be
* used with [decrypt].
* @param secretKey a _secret_ key, see [SecretBox.keygen()] or like.
* @param plain data to encrypt
* @param fillSize number of random fill data to add. Use random value or default.
*/
fun encrypt(
secretKey: UByteArray,
plain: UByteArray,
fillSize: Int = randomUInt((plain.size * 3 / 10).limitMin(3)).toInt()
): UByteArray {
val filled = BipackEncoder.encode(WithFill(plain, fillSize))
val nonce = randomNonce()
val encrypted = SecretBox.easy(filled.toUByteArray(), nonce, secretKey)
return BipackEncoder.encode(WithNonce(encrypted, nonce)).toUByteArray()
}
/**
* Decrypt a secret-key-based message, normally encrypted with [encrypt].
* @throws DecryptionFailedException if the key is wrong or a message is tampered with (MAC
* check failed).
*/
fun decrypt(secretKey: UByteArray, cipher: UByteArray): UByteArray {
val wn: WithNonce = BipackDecoder.decode(cipher.toDataSource())
try {
return BipackDecoder.decode<WithFill>(
SecretBox.openEasy(wn.cipherData, wn.nonce, secretKey).toDataSource()
).data
}
catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
throw DecryptionFailedException()
}
}
@@ -1,8 +0,0 @@
package net.sergeych.crypto
import net.sergeych.bintools.toDump
import net.sergeych.mp_tools.encodeToBase64Url
fun UByteArray.toDump(wide: Boolean = false) = toByteArray().toDump(wide)
fun UByteArray.encodeToBase64Url(): String = toByteArray().encodeToBase64Url()
@@ -0,0 +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.
*/
package net.sergeych.kiloparsec
import io.ktor.utils.io.*
object AsyncVarint {
suspend fun encodeUnsigned(value: ULong, output: ByteWriteChannel) {
var rest = value
do {
val x = (rest and 127u).toInt()
rest = rest shr 7
if (rest > 0u)
output.writeByte(x or 0x80)
else
output.writeByte(x)
} while (rest > 0u)
}
suspend fun decodeUnsigned(source: ByteReadChannel): ULong {
var result: ULong = 0u
var count = 0
while (true) {
val x = source.readUByte().toInt()
result = result or ((x and 0x7F).toULong() shl count)
if ((x and 0x80) == 0)
break
count += 7
}
return result
}
}
suspend fun ByteReadChannel.readUByte(): UByte = this.readByte().toUByte()
suspend fun ByteWriteChannel.writeByte(byte: Int) {
this.writeByte(byte.toByte())
}
@@ -0,0 +1,52 @@
/*
* 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.
*/
package net.sergeych.kiloparsec
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Multiplatform atomically mutable value to be used in [kotlinx.coroutines],
* with suspending mutating operations, see [mutate].
*
* Actual value can be either changed in a block of [mutate] when
* new value _depends on the current value_ or with [reset].
*
* [value] getter is suspended because it waits until the mutation finishes
*/
open class AtomicAsyncValue<T>(initialValue: T) {
private var actualValue = initialValue
private val access = Mutex()
/**
* Change the value: get the current and set to the returned, all in the
* atomic suspend operation. All other mutating requests including assigning to [value]
* will be blocked and queued.
* @return result of the mutation. Note that immediate call to property [value]
* could already return modified bu some other thread value!
*/
suspend fun mutate(mutator: suspend (T) -> T): T = access.withLock {
actualValue = mutator(actualValue)
actualValue
}
/**
* Atomic get or set the value. Atomic get means if there is a [mutate] in progress
* it will wait until the mutation finishes and then return the correct result.
*/
suspend fun value() = access.withLock { actualValue }
/**
* Set the new value without checking it. Shortcut to
* ```mutate { value = newValue }```
*/
suspend fun reset(value: T) = mutate { value }
}
@@ -0,0 +1,18 @@
package net.sergeych.kiloparsec
import kotlinx.coroutines.withContext
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
/** Local propagation through the encrypted RPC layers; serialized on the outer transport block. */
internal class CallPriority(val value: Int) : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<CallPriority>
}
/** Larger values are sent first. Existing two-argument calls remain priority zero. */
suspend fun <A, R> RemoteInterface.call(cmd: Command<A, R>, args: A, priority: Int): R =
withContext(CallPriority(priority)) { call(cmd, args) }
suspend fun <R> RemoteInterface.call(cmd: Command<Unit, R>, priority: Int): R = call(cmd, Unit, priority)
suspend fun <A> RemoteInterface.push(cmd: Command<A, Unit>, args: A, priority: Int) =
withContext(CallPriority(priority)) { push(cmd, args) }
suspend fun RemoteInterface.push(cmd: Command<Unit, Unit>, priority: Int) = push(cmd, Unit, priority)
@@ -1,3 +1,13 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
@@ -5,15 +15,28 @@ import kotlinx.serialization.Serializable
import net.sergeych.bintools.toDataSource import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder import net.sergeych.bipack.BipackEncoder
import net.sergeych.kiloparsec.Command.Companion.unpackCall
import net.sergeych.utools.unpack import net.sergeych.utools.unpack
/** /**
* Typesafe command definition. Command is a universal entity in Divan: it is used * Typesafe command definition. Command is a universal entity in kiloparsec (and also diwan): it is used
* in node-2-node protocols and client API, and most importantly in calling smart contract * in node-2-node protocols and client API, and most importantly in calling smart contract
* methods. This is essentially a Kotlin binding to typesafe serialize command calls and * methods. This is essentially a Kotlin binding to typesafe serialize command calls and
* deserialize results. * deserialize results.
*
* To create command instances, it is recommended to use [command] that returns [CommandDelegate].
*
* Use [packCall] to serialize the command call with some arguments.
*
* Note that `Command` instances themselves are not serialized, instead, the call is serialized,
* in the form of [Call], containing name and properly serialized arguments.
*
* [unpackCall] deserialized result of the [packCall] so the proper handler for the command could
* be used. Then the result of the execution could be packed with [exec] and then unpacked with
* [unpackResult].
*
*/ */
class Command<A, R>( open class Command<A, R>(
val name: String, val name: String,
val argsSerializer: KSerializer<A>, val argsSerializer: KSerializer<A>,
val resultSerializer: KSerializer<R> val resultSerializer: KSerializer<R>
@@ -21,20 +44,41 @@ class Command<A, R>(
@Serializable @Serializable
data class Call(val name: String,val serializedArgs: UByteArray) data class Call(val name: String,val serializedArgs: UByteArray)
fun packCall(args: A): UByteArray = BipackEncoder.encode( /**
Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray()) * Pack command invocation with specified arguments.
).toUByteArray() */
fun packCall(args: A): UByteArray = BipackEncoder.encode(createCall(args)).toUByteArray()
/**
* Create [Call] instance for specified args vy serializing it properly
*/
fun createCall(args: A): Call = Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray())
/**
* Unpack result, obtained by [exec].
*/
fun unpackResult(packedResult: UByteArray): R = fun unpackResult(packedResult: UByteArray): R =
unpack(resultSerializer, packedResult) unpack(resultSerializer, packedResult)
/**
* Execute a command unpacking args.
*
* @param packedArgs arguments, as provided by [packCall] in the [Call] instance
* @param handler actual code to execute the command
* @return properly serialized result to be unpacked with [unpackResult].
*/
suspend fun exec(packedArgs: UByteArray, handler: suspend (A) -> R): UByteArray = suspend fun exec(packedArgs: UByteArray, handler: suspend (A) -> R): UByteArray =
BipackEncoder.encode( BipackEncoder.encode(
resultSerializer, resultSerializer,
handler(BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer)) handler(
BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer))
).toUByteArray() ).toUByteArray()
companion object { companion object {
/**
* Unpack command invocation instance from [packCall]. Use [exec] to deserialize arguments and
* perform command.
*/
fun unpackCall(packedCall: UByteArray): Call = BipackDecoder.decode(packedCall.toDataSource()) fun unpackCall(packedCall: UByteArray): Call = BipackDecoder.decode(packedCall.toDataSource())
} }
} }
@@ -1,3 +1,13 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
@@ -7,6 +17,9 @@ import kotlin.reflect.KProperty
/** /**
* delegate returning function that creates a [Command] in the current context which by default has the name of * delegate returning function that creates a [Command] in the current context which by default has the name of
* the property. * the property.
*
* The Default name is a property name except the "cmd" prefix if present, which will be
* removed automatically.
*/ */
inline fun <reified A, reified R> command(overrideName: String? = null): CommandDelegate<A, R> { inline fun <reified A, reified R> command(overrideName: String? = null): CommandDelegate<A, R> {
return CommandDelegate( return CommandDelegate(
@@ -16,19 +29,33 @@ inline fun <reified A, reified R> command(overrideName: String? = null): Command
) )
} }
/**
* Declare a Push: Unit-returning command usually used with [RemoteInterface.push]
*/
inline fun <reified A> push(overrideName: String? = null): CommandDelegate<A, Unit> = command(overrideName)
/** /**
* Delegate to create [Command] via property * Delegate to create [Command] via property
*/ */
class CommandDelegate<A, R>( class CommandDelegate<A, R>(
private val argsSerializer: KSerializer<A>, private val argsSerializer: KSerializer<A>,
private val resultSerializer: KSerializer<R>, private val resultSerializer: KSerializer<R>,
private val overrideName: String? = null private val overrideName: String? = null,
) { ) {
private var name: String = ""
operator fun getValue(nothing: Nothing?, property: KProperty<*>): Command<A, R> { operator fun getValue(nothing: Nothing?, property: KProperty<*>): Command<A, R> {
if (name.isEmpty()) {
name = overrideName ?: removeCmd(property.name)
}
return Command( return Command(
overrideName ?: property.name, name,
argsSerializer, argsSerializer,
resultSerializer resultSerializer
) )
} }
} }
private fun removeCmd(name: String) =
if (name.startsWith("cmd"))
name.substring(3)
else name
@@ -1,3 +1,13 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
/** /**
@@ -0,0 +1,27 @@
package net.sergeych.kiloparsec
import kotlinx.serialization.builtins.serializer
/** Opt-in end-to-end encrypted heartbeat, independent of the carrier's keepalive. */
data class ConnectionHealth(
val intervalMillis: Long = 15_000,
val timeoutMillis: Long = 5_000,
) {
init { require(intervalMillis > 0 && timeoutMillis > 0) }
}
/** Null attempt limit preserves legacy unlimited reconnect behavior. */
data class ReconnectionOptions(
val health: ConnectionHealth? = null,
val maxAttempts: Int? = null,
val connectTimeoutMillis: Long = 15_000,
val retryDelayMillis: Long = 700,
) {
init {
require(maxAttempts == null || maxAttempts > 0)
require(connectTimeoutMillis > 0 && retryDelayMillis >= 0)
}
}
// Reserved inner command: encrypted with the current session and handled before application dispatch.
internal val connectionPing = Command("@kiloparsec/ping", Long.serializer(), Long.serializer())
@@ -0,0 +1,20 @@
/*
* 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.
*/
package net.sergeych.kiloparsec
/**
* External errors wich needs to be encoded with a specific code
* should implement this interface so it will be serialized properly.
*/
interface ExceptionWithCode {
val code: String
val message: String?
}
@@ -1,29 +1,75 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import net.sergeych.crypto.Key import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.crypto2.SigningKey
import net.sergeych.crypto2.VerifyingKey
import net.sergeych.crypto2.VerifyingPublicKey
import net.sergeych.mp_logger.LogTag import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug import net.sergeych.mp_logger.debug
import net.sergeych.mp_logger.exception
import net.sergeych.mp_tools.globalLaunch import net.sergeych.mp_tools.globalLaunch
/** /**
* The auto-connecting client that reconnects to the kiloparsec server * The auto-connecting client that reconnects to the kiloparsec server,
* [KiloServer],
* and maintain connection state flow. Client factory launches a disconnected * and maintain connection state flow. Client factory launches a disconnected
* set of coroutines to support automatic reconnection, so you _must_ [close] * set of coroutines to support automatic reconnection (unlimited by default; see [options]), so you _must_ [close]
* it manually when it is not needed, otherwise it will continue to reconnect. * it manually when it is unnecessary, otherwise it will continue to reconnect.
*
* ## Usage
*
* Suppose we have TCP/IP server as in the [KiloServer] usage sample. Then we can connect
* to it providing TCP/IP connector like:
*
* ```kotlin
* val client = KiloClient<Unit>() {
* connect { connectTcpDevice("localhost:$port") }
* }
*
* // now we can invoke remote commands:
* assertEquals("unknown", client.call(cmdLoad))
*
* client.call(cmdSave, "foobar")
* assertEquals("foobar", client.call(cmdLoad))
* ```
*
* ## See also
*
* [KiloServer]
*
*/ */
class KiloClient<S>( class KiloClient<S>(
localInterface: KiloInterface<S>, val localInterface: KiloInterface<S>,
secretKey: Key.Signing? = null, secretKey: SigningKey? = null,
val options: ReconnectionOptions = ReconnectionOptions(),
connectionDataFactory: ConnectionDataFactory<S>, connectionDataFactory: ConnectionDataFactory<S>,
) : RemoteInterface, ) : RemoteInterface,
Loggable by LogTag("CLIF") { Loggable by LogTag("CLIF") {
constructor(localInterface: KiloInterface<S>, secretKey: SigningKey? = null, connectionDataFactory: ConnectionDataFactory<S>)
: this(localInterface, secretKey, ReconnectionOptions(), connectionDataFactory)
private val stopped = MutableStateFlow(false)
private val exhausted = MutableStateFlow(false)
/** True when the finite attempt budget has ended. Create a new client for another carrier. */
val reconnectExhausted = exhausted.asStateFlow()
val _state = MutableStateFlow(false) val _state = MutableStateFlow(false)
@@ -32,42 +78,103 @@ class KiloClient<S>(
* to authenticate a client on connection restore, for example. * to authenticate a client on connection restore, for example.
*/ */
@Suppress("unused") @Suppress("unused")
val state = _state.asStateFlow() val connectedStateFlow = _state.asStateFlow()
/**
* The verifying, or public, key identifying client sessions. It could be used to
* restore environment on reconnection. This is what remote side, e.g. server, sees as
* [KiloScope.remoteIdentity].
*/
@Suppress("unused")
val localIdentity: VerifyingKey? = secretKey?.verifyingKey
private var deferredClient = CompletableDeferred<KiloClientConnection<S>>() private var deferredClient = CompletableDeferred<KiloClientConnection<S>>()
private val defMutex = Mutex()
private val job = private val job =
globalLaunch { globalLaunch {
debug { "starting connector" } debug { "starting connector" }
while (isActive) { var attempts = 0
try { try {
debug { "getting connection" } while (isActive && (options.maxAttempts == null || attempts < options.maxAttempts)) {
val kc = connectionDataFactory() attempts++
debug { "get device and session" } var connectedAt: kotlin.time.TimeMark? = null
val client = KiloClientConnection(localInterface, kc,secretKey) try {
deferredClient.complete(client) val client = kotlinx.coroutines.withTimeout(options.connectTimeoutMillis) {
client.run { KiloClientConnection(localInterface, connectionDataFactory(), secretKey)
}
deferredClient.complete(client)
kotlinx.coroutines.coroutineScope {
val setupDeadline = launch {
delay(options.connectTimeoutMillis)
throw RemoteInterface.ClosedException()
}
val onState: (Boolean) -> Unit = {
_state.value = it
if (it) {
connectedAt = kotlin.time.TimeSource.Monotonic.markNow()
setupDeadline.cancel()
}
}
if (options.health == null) client.run(onState)
else client.run(options.health, onState)
setupDeadline.cancel()
}
} catch (t: kotlinx.coroutines.TimeoutCancellationException) {
debug { "connection setup timed out" }
} catch (t: CancellationException) { throw t }
catch (t: Throwable) { debug { "connection ended: $t" } }
finally {
_state.value = false _state.value = false
resetDeferredClient()
} }
debug { "client run finished" } // Short authenticated-but-dead sessions must still exhaust the budget.
} catch (_: RemoteInterface.ClosedException) { val stableMillis = options.health?.let { 3 * (it.intervalMillis + it.timeoutMillis) } ?: 30_000L
debug { "remote closed" } if ((connectedAt?.elapsedNow()?.inWholeMilliseconds ?: 0) > stableMillis) attempts = 0
} catch (_: CancellationException) { if (options.maxAttempts == null || attempts < options.maxAttempts) delay(options.retryDelayMillis)
debug { "cancelled" }
} catch (t: Throwable) {
exception { "unexpected exception" to t }
} }
exhausted.value = true
} finally {
_state.value = false _state.value = false
if (deferredClient.isActive) deferredClient.completeExceptionally(RemoteInterface.ClosedException())
deferredClient = CompletableDeferred()
} }
} }
fun close() { fun close() {
stopped.value = true
job.cancel() job.cancel()
debug { "client is closed" }
} }
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R = deferredClient.await().call(cmd, args) private suspend fun resetDeferredClient() {
defMutex.withLock {
if (!deferredClient.isActive && !exhausted.value && !stopped.value) {
deferredClient = CompletableDeferred()
}
}
}
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R =
try {
deferredClient.await().call(cmd, args)
} catch (t: RemoteInterface.ClosedException) {
resetDeferredClient()
throw t
}
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
try {
deferredClient.await().push(cmd, args)
} catch (t: RemoteInterface.ClosedException) {
resetDeferredClient()
throw t
}
}
/** Perform an encrypted liveness round trip on the active connection. */
suspend fun ping() = deferredClient.await().ping()
/** /**
* Current session token. This is a per-connection unique random value same on the client and server part so * Current session token. This is a per-connection unique random value same on the client and server part so
@@ -77,14 +184,14 @@ class KiloClient<S>(
suspend fun token() = deferredClient.await().token() suspend fun token() = deferredClient.await().token()
/** /**
* Remote party shared key ([Key.Verifying]]), could be used ti ensure server is what we expected and * Remote party shared key ([VerifyingPublicKey]), could be used ti ensure server is what we expected and
* there is no active MITM attack. * there is no active MITM attack.
* *
* Non-null value means the key was successfully authenticated, null means remote party did not provide * Non-null value means the key was successfully authenticated, null means remote party did not provide
* a key. Connection is established either with a properly authenticated key or no key at all. * a key. Connection is established either with a properly authenticated key or no key at all.
*/ */
@Suppress("unused") @Suppress("unused")
suspend fun remoteId() = deferredClient.await().remoteId() suspend fun remoteIdentity(): VerifyingPublicKey? = deferredClient.await().remoteId()
companion object { companion object {
class Builder<S>() { class Builder<S>() {
@@ -96,16 +203,44 @@ class KiloClient<S>(
} }
private var connectionBuilder: (suspend () -> Transport.Device)? = null private var connectionBuilder: (suspend () -> Transport.Device)? = null
var secretIdKey: Key.Signing? = null var reconnectionOptions: ReconnectionOptions = ReconnectionOptions()
var secretIdKey: SigningKey? = null
/** /**
* Build local command implementations (remotely callable ones), exception * Build local command implementations, those callable from the server, exception
* class handlers, etc. * class handlers, and anything else [KiloInterface] allows. Usage sample:
*
* ```kotlin
* val client = KiloClient {
* connect { connectTcpDevice("localhost:$port") }
* local {
* on(cmdPing) {
* "pong! $it"
* }
* }
* }
* ```
*/ */
fun local(f: KiloInterface<S>.() -> Unit) { fun local(f: KiloInterface<S>.() -> Unit) {
interfaceBuilder = f interfaceBuilder = f
} }
/**
* Import exception (error) registrations from a shared LocalInterface (usually, server side one) to
* DRY error registration on both server and client sides.
* Normally you need to:
*
* - Register all your errors in server's LocalInterface
*
* - In the client builder call [addErrors(serverLocalInterface]
*
* See also [LocalInterface.registerError] for exception registration
*/
fun addErrors(from: LocalInterface<*>) {
errorProviders += from
}
/** /**
* Create a new session object, otherwise Unit session will be used * Create a new session object, otherwise Unit session will be used
*/ */
@@ -117,12 +252,15 @@ class KiloClient<S>(
connectionBuilder = f connectionBuilder = f
} }
val errorProviders = mutableListOf<LocalInterface<*>>()
internal fun build(): KiloClient<S> { internal fun build(): KiloClient<S> {
val i = KiloInterface<S>() val i = KiloInterface<S>()
for (ep in errorProviders) i.addErrorProvider(ep)
interfaceBuilder?.let { i.it() } interfaceBuilder?.let { i.it() }
val connector = connectionBuilder ?: throw IllegalArgumentException("connect handler was not set") val connector = connectionBuilder ?: throw IllegalArgumentException("connect handler was not set")
return KiloClient(i,secretIdKey) { return KiloClient(i, secretIdKey, reconnectionOptions) {
KiloConnectionData(connector(),sessionBuilder()) KiloConnectionData(connector(), sessionBuilder())
} }
} }
} }
@@ -1,102 +1,158 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
import kotlinx.coroutines.* import kotlinx.coroutines.*
import net.sergeych.crypto.Key import net.sergeych.crypto2.SafeKeyExchange
import net.sergeych.crypto2.SigningKey
import net.sergeych.crypto2.VerifyingPublicKey
import net.sergeych.mp_logger.LogTag import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug import net.sergeych.mp_logger.debug
import net.sergeych.mp_logger.info import net.sergeych.mp_logger.info
import net.sergeych.utools.pack import net.sergeych.utools.pack
import net.sergeych.utools.unpack
private var clientIds = 0 private var clientIds = 0
class KiloClientConnection<S>( class KiloClientConnection<S>(
private val clientInterface: LocalInterface<KiloScope<S>>, private val clientInterface: KiloInterface<S>,
private val device: Transport.Device, private val device: Transport.Device,
private val session: S, private val session: S,
private val secretIdKey: Key.Signing? = null, private val secretIdKey: SigningKey? = null,
) : RemoteInterface, Loggable by LogTag("KPC:${++clientIds}") { ) : RemoteInterface, Loggable by LogTag("KPC:${++clientIds}") {
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: Key.Signing? = null) constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: SigningKey? = null)
: this(localInterface, connection.device, connection.session, secretIdKey) : this(localInterface, connection.device, connection.session, secretIdKey)
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>() private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
private val deferredParams = CompletableDeferred<KiloParams<S>>() private val deferredParams = CompletableDeferred<KiloParams<S>>()
suspend fun remoteId(): Key.Verifying? = deferredParams.await().remoteIdentity suspend fun remoteId(): VerifyingPublicKey? = deferredParams.await().remoteIdentity
/** /**
* Run the client, blocking until the device is closed, or some critical exception * Run the client, blocking until the device is closed, or some critical exception
* will stop the transport, or the calling scope will be canceled. * will stop the transport, or the calling scope will be canceled.
* Cancelling the scope where server is running is a preferred way to stop the client. * Cancelling the scope where server is running is a preferred way to stop the client.
*/ */
suspend fun run(onConnectedStateChanged: ((Boolean) -> Unit)? = null) { suspend fun run(onConnectedStateChanged: ((Boolean) -> Unit)? = null) = runConfigured(null, onConnectedStateChanged)
suspend fun run(health: ConnectionHealth, onConnectedStateChanged: ((Boolean) -> Unit)? = null) =
runConfigured(health, onConnectedStateChanged)
private suspend fun runConfigured(health: ConnectionHealth?, onConnectedStateChanged: ((Boolean) -> Unit)?) {
coroutineScope { coroutineScope {
var job: Job? = null var job: Job? = null
var connectedScope: KiloScope<S>? = null
try { try {
// in parallel: keys and connection // in parallel: keys and connection
val deferredKeyPair = async { KeyExchange.keypair() } val deferredKeyPair = async { SafeKeyExchange() }
debug { "opening device" }
debug { "got a transport device $device" } debug { "got a transport device $device" }
// client transport has no dedicated commands (unlike the server's), // client transport has no dedicated commands (unlike the server's),
// it is a calling party: // it is a calling party:
val l0Interface = KiloL0Interface(clientInterface, deferredParams) val l0Interface = KiloL0Interface(clientInterface, deferredParams, allowHandshakeCallbacks = true)
val transport = Transport(device, l0Interface, Unit) val transport = Transport(device, l0Interface, Unit)
job = launch { transport.run() } job = launch { transport.run() }
debug { "transport started" } debug { "transport started" }
val pair = deferredKeyPair.await() val pair = deferredKeyPair.await()
debug { "keypair ready" } debug { "keypair ready (1)" }
val serverHe = transport.call(L0Request, Handshake(1u, pair.publicKey)) val serverHe = transport.call(L0Request, Handshake(KILO_PROTOCOL_VERSION, pair.publicKey))
val sk = KeyExchange.clientSessionKeys(pair.publicKey, pair.secretKey, serverHe.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) var params = KiloParams(false, transport, sk, session, null, this@KiloClientConnection)
// Check ID if any // Check ID if any
serverHe.serverSharedKey?.let { k -> serverHe.signature?.let { s ->
if (serverHe.signature == null) if (!s.isValid(serverAuthentication(params.token)))
throw RemoteInterface.SecurityException("missing signature")
if (!k.verify(serverHe.signature, params.token))
throw RemoteInterface.SecurityException("wrong signature") throw RemoteInterface.SecurityException("wrong signature")
params = params.copy(remoteIdentity = k) params = params.authenticated(s.publicKey)
} }
transport.call( val identity = pack(ClientIdentity(secretIdKey?.verifyingKey,
L0ClientId, params.encrypt( secretIdKey?.sign(clientAuthentication(params.token))))
pack( val finished = unpack<ProtectedMessage>(transport.callWithId(L0ClientId,
ClientIdentity( readResponse = params::decrypt) { params.encode(identity) })
secretIdKey?.verifying, if (finished.kind != ProtectedKind.SERVER_FINISHED || finished.correlation != 0uL ||
secretIdKey?.sign(params.token) finished.priority != 0 || !finished.payload.contentEquals(identity))
) throw RemoteInterface.SecurityException("invalid server finished")
)
)
)
deferredParams.complete(params) deferredParams.complete(params)
kiloRemoteInterface.complete( kiloRemoteInterface.complete(
KiloRemoteInterface(deferredParams, clientInterface) KiloRemoteInterface(deferredParams, clientInterface)
) )
connectedScope = params.scope
clientInterface.onConnectHandlers.invokeAll(connectedScope)
onConnectedStateChanged?.invoke(true) onConnectedStateChanged?.invoke(true)
job.join() val heartbeat = health?.let { options ->
launch {
while (isActive) {
delay(options.intervalMillis)
if (withTimeoutOrNull(options.timeoutMillis) {
withContext(CallPriority(100)) { ping() }; true
} != true) throw RemoteInterface.ClosedException()
}
}
}
try { job.join() }
finally { heartbeat?.cancelAndJoin() }
} catch (x: CancellationException) { } catch (x: CancellationException) {
info { "client is cancelled" } info { "client is cancelled" }
} catch (x: RemoteInterface.ClosedException) { } catch (x: RemoteInterface.ClosedException) {
info { "connection closed by remote" } debug { "connection closed/refused by remote" }
} finally { } finally {
if (!deferredParams.isCompleted) deferredParams.completeExceptionally(RemoteInterface.ClosedException())
if (!kiloRemoteInterface.isCompleted) kiloRemoteInterface.completeExceptionally(RemoteInterface.ClosedException())
connectedScope?.let { clientInterface.onDisconnectHandlers.invokeAll(it) }
onConnectedStateChanged?.invoke(false) onConnectedStateChanged?.invoke(false)
job?.cancel() withContext(NonCancellable) {
device.apply { runCatching { close() } } job?.cancelAndJoin()
device.apply { runCatching { close() } }
}
} }
} }
} }
/** Encrypted round trip. Older peers' encrypted UnknownCommand also proves liveness. */
suspend fun ping() {
val nonce = kotlin.random.Random.nextLong()
try { check(call(connectionPing, nonce) == nonce) { "Invalid heartbeat response" } }
catch (_: RemoteInterface.UnknownCommand) { /* Compatible with peers predating native ping. */ }
}
suspend fun token() = deferredParams.await().token suspend fun token() = deferredParams.await().token
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R = override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R =
kiloRemoteInterface.await().call(cmd, args) kiloRemoteInterface.await().call(cmd, args)
}
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
kiloRemoteInterface.await().push(cmd, args)
}
}
internal fun <S>Collection<KiloHandler<S>>.invokeAll(scope: KiloScope<S>) =
forEach { handler ->
try {
scope.handler()
} catch (e: LocalInterface.BreakConnectionException) {
// Includes SecurityException and application-defined rejection subclasses.
throw e
} catch (_: Throwable) {
// Preserve best-effort notification for other handler failures.
}
}
@@ -1,14 +1,57 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
typealias KiloHandler<S> = KiloScope<S>.()->Unit
/** /**
* The local interface to provice functions, register errors for Kiloparsec users. Use it * The local interface to provide functions, register errors for Kiloparsec users. Use it
* with [KiloClient], [KiloClientConnection], [KiloServerConnection], etc. * with [KiloClient], [KiloClientConnection], [KiloServerConnection], etc.
* *
* BAse implementation registers relevant exceptions. * Base class implementation does the following:
*
* - It registers common exceptions from [RemoteInterface] and kotlin/java `IllegalArgumentException` and
* `IllegalStateException`
* - It provides [onConnected] and [onDisconnected] handlers
*
* See [KiloServer] for usage sample.
*/ */
class KiloInterface<S> : LocalInterface<KiloScope<S>>() { open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
internal val onConnectHandlers = mutableListOf<KiloHandler<S>>()
internal val onDisconnectHandlers = mutableListOf<KiloHandler<S>>()
/**
* Registers handler [f] for [onConnected] event, to the head or the end of a handler list.
*
* @param addFirst if true, [f] will be added to the beginning of the list of handlers
*/
fun onConnected(addFirst: Boolean = false, f: KiloScope<S>.()->Unit) {
if( addFirst ) onConnectHandlers.add(0, f) else onConnectHandlers += f
}
/**
* Registers handler [f] for [onDisconnected] event, to the head or the end of a handler list.
*
* It is called with the same connection [KiloScope] as [onConnected], after an established
* connection is closed.
*
* @param addFirst if true, [f] will be added to the beginning of the list of handlers
*/
fun onDisconnected(addFirst: Boolean = false, f: KiloScope<S>.()->Unit) {
if( addFirst ) onDisconnectHandlers.add(0, f) else onDisconnectHandlers += f
}
init { init {
registerError { RemoteInterface.UnknownCommand() } registerError { RemoteInterface.UnknownCommand(it) }
registerError { RemoteInterface.InternalError(it) }
registerError { RemoteInterface.ClosedException(it) } registerError { RemoteInterface.ClosedException(it) }
registerError { RemoteInterface.SecurityException(it) } registerError { RemoteInterface.SecurityException(it) }
registerError { RemoteInterface.InvalidDataException(it) } registerError { RemoteInterface.InvalidDataException(it) }
@@ -17,4 +60,3 @@ class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
registerError { IllegalArgumentException(it) } registerError { IllegalArgumentException(it) }
} }
} }
@@ -1,36 +1,52 @@
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import net.sergeych.tools.AtomicCounter
import net.sergeych.utools.pack 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]. /** Authenticates dispatch metadata before looking up or deserializing an application command. */
*
* It is recommended to use [KiloClientConnection] and [KiloServerConnection] instead.
*/
internal class KiloL0Interface<T>( internal class KiloL0Interface<T>(
private val clientInterface: LocalInterface<KiloScope<T>>, private val clientInterface: LocalInterface<KiloScope<T>>,
private val deferredParams: CompletableDeferred<KiloParams<T>>, private val deferredParams: CompletableDeferred<KiloParams<T>>,
): LocalInterface<Unit>() { private val allowHandshakeCallbacks: Boolean = false,
init { ) : LocalInterface<Unit>() {
// local interface uses the same session as a client: override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
addErrorProvider(clientInterface) init { addErrorProvider(clientInterface) }
// Plaintext transport errors must never use application exception constructors.
override fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? = null
on(L0Call) { override suspend fun executeTransportBlock(scope: Unit, block: Transport.Block.Call): PreparedPayload {
val params = deferredParams.await() if (block.name != L0Call.name) return super.executeTransportBlock(scope, block)
val call = Command.unpackCall(params.decrypt(it)) if (!allowHandshakeCallbacks && !deferredParams.isCompleted)
// tricky part: we need to encrypt a result or error: we can't allow default throw RemoteInterface.SecurityException("application call before authentication")
// error transport (as it is not encrypted), so we re-use transport blocks here: val params = deferredParams.await()
val result: Transport.Block = try { val frame: ProtectedMessage = try { params.decryptMessage(unpack(block.packedArgs)) }
Transport.Block.Response( catch (e: DiscardPacket) { throw e }
0u, catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid authenticated call") }
clientInterface.execute(params.scope, call.name, call.serializedArgs) val expected = if (block.id == 0u) ProtectedKind.PUSH else ProtectedKind.CALL
) if (frame.kind != expected || frame.correlation != block.id.toULong() || frame.priority != block.priority)
} catch (t: Throwable) { throw RemoteInterface.SecurityException("authenticated routing mismatch")
clientInterface.encodeError(0u, t) val call = try { Command.unpackCall(frame.payload) }
} catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid command encoding") }
params.encrypt(pack(result)) 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 PreparedPayload { OutgoingData(pack(Unit)) }
val kind = if (result is Transport.Block.Error) ProtectedKind.ERROR else ProtectedKind.RESPONSE
val payload = pack(result)
return PreparedPayload {
val encoded = params.encodeMessage(ProtectedMessage(kind, frame.correlation, frame.priority, payload))
OutgoingData(pack(encoded.bytes), encoded.nonce)
} }
} }
} }
@@ -1,23 +1,23 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import com.ionspin.kotlin.crypto.keyexchange.KeyExchangeSessionKeyPair
import com.ionspin.kotlin.crypto.secretbox.SecretBox
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import net.sergeych.bintools.toDataSource import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder import net.sergeych.bipack.Unsigned
import net.sergeych.crypto.DecryptionFailedException import net.sergeych.crypto2.*
import net.sergeych.crypto.Key import net.sergeych.synctools.ProtectedOp
import net.sergeych.crypto.randomBytes import net.sergeych.synctools.invoke
import net.sergeych.crypto.randomUInt
import net.sergeych.tools.ProtectedOp
import net.sergeych.utools.pack import net.sergeych.utools.pack
import net.sergeych.utools.unpack
import org.komputing.khash.keccak.Keccak
import org.komputing.khash.keccak.KeccakParameter
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -32,105 +32,83 @@ import kotlin.math.roundToInt
data class KiloParams<S>( data class KiloParams<S>(
val isServer: Boolean, val isServer: Boolean,
val transport: RemoteInterface, val transport: RemoteInterface,
val sessionKeyPair: KeyExchangeSessionKeyPair, val sessionKey: SafeKeyExchange.SessionKey,
val scopeSession: S, val scopeSession: S,
val remoteIdentity: Key.Verifying?, val remoteIdentity: VerifyingPublicKey?,
val remoteTransport: RemoteInterface val remoteTransport: RemoteInterface,
) { ) {
@Serializable @Serializable
data class Package( data class Package(
@Unsigned
val nonce: ULong, val nonce: ULong,
val encryptedMessage: UByteArray, val encryptedMessage: UByteArray,
) )
@Serializable
data class FilledData(
val message: UByteArray,
val fill: UByteArray,
)
private var nonce = 0UL private var nonce = 0UL
private var received = ReplayWindow()
val scope: KiloScope<S> by lazy { val scope: KiloScope<S> by lazy {
object : KiloScope<S> { object : KiloScope<S> {
override val session = scopeSession override val session = scopeSession
override val remote: RemoteInterface = remoteTransport override val remote: RemoteInterface = remoteTransport
override val sessionToken: UByteArray = token override val sessionToken: UByteArray = token
override val remoteIdentity: Key.Verifying? = this@KiloParams.remoteIdentity override val remoteIdentity: VerifyingPublicKey? = this@KiloParams.remoteIdentity
} }
} }
val token: UByteArray by lazy { val token: UByteArray by lazy {
val base = if (isServer) sessionKeyPair.sendKey + sessionKeyPair.receiveKey blake2b("token_".encodeToUByteArray() + sessionKey.sessionTag).sliceArray(0..<SymmetricKey.nonceLength)
else sessionKeyPair.receiveKey + sessionKeyPair.sendKey
Keccak.digest(
base.toByteArray(), KeccakParameter.KECCAK_256
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
} }
private val sendBase by lazy { private val numericNonce = NumericNonce(token)
Keccak.digest(
sessionKeyPair.sendKey.toByteArray(), KeccakParameter.KECCAK_256
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
}
private val receiveBase by lazy { private val protectedOp = ProtectedOp()
Keccak.digest(
sessionKeyPair.receiveKey.toByteArray(), KeccakParameter.KECCAK_256
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
}
private inline fun encodeNonce(base: UByteArray, nonce: ULong): UByteArray {
val result = base.copyOf()
var x = nonce
var i = 0
while (x > 0u) {
result[i] = result[i] xor (x and 0xFFu).toUByte()
x = x shr 8
i++
}
return result
}
private inline fun encodeSendNonce(nonce: ULong): UByteArray = encodeNonce(sendBase, nonce)
private inline fun encodeReceiveNonce(nonce: ULong): UByteArray = encodeNonce(receiveBase, nonce)
fun encrypt(plainText: String): UByteArray = encrypt(plainText.encodeToUByteArray())
private val proptectedOp = ProtectedOp()
/** /**
* Encrypt using send keys and proper nonce * Encrypt using send keys and proper nonce
*/ */
fun encrypt(message: UByteArray, fillFactor: Float = 0f): UByteArray { fun encrypt(message: UByteArray, fillFactor: Float = 0f): UByteArray = encode(message, fillFactor).bytes
val fill: UByteArray = if (fillFactor > 0f)
randomBytes(randomUInt((message.size * fillFactor).roundToInt())) internal fun encode(message: UByteArray, fillFactor: Float = 0f): OutgoingData {
val fill = if (fillFactor > 0f)
0..(message.size * fillFactor).roundToInt()
else else
ubyteArrayOf() null
val withFill = BipackEncoder.encode(FilledData(message, fill)).toUByteArray() val n = protectedOp.invoke {
if (nonce == ULong.MAX_VALUE) throw RemoteInterface.SecurityException("session nonce exhausted")
nonce++
}
val n = proptectedOp { nonce++ } return OutgoingData(pack(
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
return pack( ), n)
Package(n, SecretBox.easy(withFill, encodeSendNonce(n), sessionKeyPair.sendKey))
)
} }
fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray() /** Authenticate and consume one incoming nonce, allowing bounded out-of-order delivery. */
fun decrypt(encryptedMessage: UByteArray): UByteArray { fun decrypt(encryptedMessage: UByteArray): UByteArray {
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource()) val p = protectDecryption { BipackDecoder.decode<Package>(encryptedMessage.toDataSource()) }
try { return protectedOp.invoke {
return unpack<FilledData>( if (received.containsOrStale(p.nonce)) throw DiscardPacket()
SecretBox.openEasy( val clear = protectDecryption {
p.encryptedMessage, sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
encodeReceiveNonce(p.nonce), }
sessionKeyPair.receiveKey received.record(p.nonce)
) clear
).message
} catch (_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
throw DecryptionFailedException()
} }
} }
}
/** 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.received = received.copy()
}
}
internal fun encodeMessage(message: ProtectedMessage): OutgoingData = encode(pack(message))
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,33 +1,63 @@
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import net.sergeych.mp_logger.LogTag import kotlinx.coroutines.CancellationException
import net.sergeych.mp_logger.Loggable import kotlinx.coroutines.currentCoroutineContext
import net.sergeych.utools.unpack import net.sergeych.utools.unpack
private var L1IdCounter = 0 /** Full-duplex remote interface with authenticated request/result correlation. */
// todo: We don't need it deferred here
class KiloRemoteInterface<S>( class KiloRemoteInterface<S>(
private val deferredParams: CompletableDeferred<KiloParams<S>>, private val deferredParams: CompletableDeferred<KiloParams<S>>,
private val clientInterface: LocalInterface<KiloScope<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 { override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
val params = deferredParams.await() val params = deferredParams.await()
val block: Transport.Block = unpack( val transport = params.transport as Transport<*>
params.decrypt( val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
params.transport.call(L0Call, params.encrypt(cmd.packCall(args))) var requestId = 0u
) val call = cmd.packCall(args)
) val encoded = try {
return when (block) { transport.callWithId(L0Call, readResponse = params::decrypt) { id ->
is Transport.Block.Response -> { requestId = id
cmd.unpackResult(block.packedResult) params.encodeMessage(ProtectedMessage(ProtectedKind.CALL, id.toULong(), priority, call))
} }
} catch (e: CancellationException) { throw e }
catch (e: RemoteInterface.ClosedException) { throw e }
catch (e: RemoteInterface.SecurityException) {
transport.abortSecurity()
throw e
}
val frame: ProtectedMessage
val block: Transport.Block
try {
frame = unpack(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) 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()
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
val payload = cmd.packCall(args)
(params.transport as Transport<*>).pushPrepared(L0Call.name, priority) {
params.encodeMessage(ProtectedMessage(ProtectedKind.PUSH, 0uL, priority, payload))
} }
} }
} }
@@ -1,6 +1,17 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import net.sergeych.crypto.Key import net.sergeych.crypto2.SigningKey
import net.sergeych.crypto2.VerifyingPublicKey
/** /**
* Scope for Kiloparsec client/server commands execution, contain per-connection specific data. The scope * Scope for Kiloparsec client/server commands execution, contain per-connection specific data. The scope
@@ -26,8 +37,8 @@ interface KiloScope<S> {
val sessionToken: UByteArray val sessionToken: UByteArray
/** /**
* If the remote part has provided a secret key, e.g., gave non-null [Key.Signing] on construction, * If the remote part has provided a secret key, e.g., gave non-null [SigningKey] on construction,
* the kiloparsec checks it in the MITM-safe way and provides its [Key.Verifying] shared key here. * the kiloparsec checks it in the MITM-safe way and provides its [VerifyingPublicKey] shared key here.
* Knowing a remote party shared key, it is possible to be sure that the connection is made directly * Knowing a remote party shared key, it is possible to be sure that the connection is made directly
* to this party with no middle point intruders. * to this party with no middle point intruders.
* *
@@ -37,6 +48,6 @@ interface KiloScope<S> {
* In spite of the above said, which means, non-null value in this field means the key is authorized, but * In spite of the above said, which means, non-null value in this field means the key is authorized, but
* It is up to the caller to ensure it is expected key of the remote party. * It is up to the caller to ensure it is expected key of the remote party.
*/ */
val remoteIdentity: Key.Verifying? val remoteIdentity: VerifyingPublicKey?
} }
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025-2026. 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.
*/
package net.sergeych.kiloparsec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import net.sergeych.crypto2.SigningKey
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.debug
import net.sergeych.mp_logger.exception
import net.sergeych.mp_logger.info
import net.sergeych.mp_tools.globalLaunch
import net.sergeych.tools.AtomicCounter
private val instances = AtomicCounter()
/**
* The Kiloparsec server.
* Server accepts incoming connections and serves them using the same [clientInterface].
*
* ## Incoming connections
*
* Server collecting incoming connections provided by [connections] `Flow`. For each incoming connection
* the Kiloparsec handshake is performed, then the session object is created, see below, and connection is
* served with [clientInterface] until closed.
*
* ## Session param [S]
*
* After the successful handshake server creates new session for each connection calling the [sessionBuilder].
* Then it creates a [KiloScope] so [KiloScope.session] holds this connection-specific instance. Then
* [KiloInterface.onConnected] is called with this scope. Session can be used to hold connection state. Session
* objects are not persistent, but could be initialized in [KiloInterface.onConnected] where the remote side
* [KiloScope.remoteIdentity] is already verified and set.
*
* ## Usage:
*
* Create a shared library between you server and clients, to specify the interface (otherwise you can
* share sources).
*
* Suppose we have session with a state:
* ```kotlin
* data class Session(
* var data: String,
* )
*```
*
* And some commands to access and change it, in the shared library too:
*
* ```kotlin
* val cmdSave by command<String, Unit>()
* val cmdLoad by command<Unit, String>()
* val cmdDrop by command<Unit, Unit>()
* val cmdException by command<Unit, Unit>()
* ```
* Then the server code (TCP/IP variant) could look like:
*
* ```kotlin
* // The server implementation (could be shared between server instances connected
* // to different protocol adapters):
*
* val cli = KiloInterface<Session>().apply {
* // Suppose we want to throw this exception at the caller site, so we need to register it:
* registerError { SomeException() }
*
* // Session initialization. If you need a sessino to depend initially on the client's identity.
* // you can do it here:
* onConnected {
* // check the remoteIdentity
* session.data = if( remoteIdentity == somePublicVerifyingKey )
* "known"
* else
* "unknown"
* }
* on(cmdSave) { session.data = it }
* on(cmdLoad) {
* session.data
* }
* on(cmdException) {
* throw TestException()
* }
* on(cmdDrop) {
* throw LocalInterface.BreakConnectionException()
* }
* }
*
* // Create the server instance that accepts incoming TCP/IP connections on all local interfaces using the
* // specified port:
*
* val server = KiloServer(cli, acceptTcpDevice(port)) {
* // This creates a new session
* Session("unknown")
* }
* ```
*
* See [KiloClient] to connect to the server.
*
* @param S the type of the server session object, returned by [sessionBuilder]. See above
* @param clientInterface the interface available for remote calls
* @param connections flow of incoming connections. Server stops when the flow is fully collected (normally
* it shouldn't)
* @param serverSecretKey the [SigningKey] used to identify this server during Kiloparsec handshake.
* @param sessionBuilder callback that creates session objects for successful incoming connections
*/
class KiloServer<S>(
private val clientInterface: KiloInterface<S>,
private val connections: Flow<Transport.Device>,
private val serverSecretKey: SigningKey? = null,
private val sessionBuilder: () -> S,
) : LogTag("KS:${instances.incrementAndGet()}") {
private val job = globalLaunch {
connections.collect { device ->
launch {
try {
info { "connected ${device}" }
KiloServerConnection(clientInterface, device, sessionBuilder(), serverSecretKey)
.apply { debug { "server connection is ready" } }
.run()
} catch (_: CancellationException) {
} catch (cce: LocalInterface.BreakConnectionException) {
info { "Closed exception caught, closing (${cce.flushSendQueue}" }
} catch (t: Throwable) {
exception { "unexpected while creating kiloclient" to t }
}
}
}
}
/**
* Stop the server and cancel all pending sessions. Unlike finishing the flow passed
* for [KiloServer.connections], it will cancel all currently active sessions.
*/
fun close() {
job.cancel()
}
/**
* Server is closed either if [close] was called or all [KiloServer.connections] were collected and flow was
* closed.
*/
@Suppress("unused")
val isClosed: Boolean get() = job.isCompleted
}
@@ -1,8 +1,20 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import net.sergeych.crypto.Key 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 import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.debug import net.sergeych.mp_logger.debug
@@ -23,15 +35,15 @@ class KiloServerConnection<S>(
private val clientInterface: KiloInterface<S>, private val clientInterface: KiloInterface<S>,
private val device: Transport.Device, private val device: Transport.Device,
private val session: S, private val session: S,
private val serverSigningKey: Key.Signing? = null private val serverSigningKey: SigningKey? = null
) : RemoteInterface, Loggable by LogTag("SRV${++serverIds}") { ) : RemoteInterface, Loggable by LogTag("SRV${++serverIds}") {
/** /**
* Shortcut to construct with [KiloConnectionData] intance * Shortcut to construct with [KiloConnectionData] intance
*/ */
@Suppress("unused") @Suppress("unused")
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSigningKey: Key.Signing? = null) constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSecretKey: SigningKey? = null)
: this(localInterface, connection.device, connection.session, serverSigningKey) : this(localInterface, connection.device, connection.session, serverSecretKey)
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>() private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
@@ -43,62 +55,66 @@ class KiloServerConnection<S>(
suspend fun run() { suspend fun run() {
val deferredParams = CompletableDeferred<KiloParams<S>>() val deferredParams = CompletableDeferred<KiloParams<S>>()
val deferredTransport = CompletableDeferred<Transport<*>>() val deferredTransport = CompletableDeferred<Transport<*>>()
var connectedScope: KiloScope<S>? = null
val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply { val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply {
var params: KiloParams<S>? = null var params: KiloParams<S>? = null
on(L0Request) { val handshakeLock = Mutex()
val sk = KeyExchange.serverSessionKeys( var established = false
pair.publicKey, pair.secretKey, it.publicKey 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( onPrepared(L0ClientId) { encoded -> handshakeLock.withLock {
true, var p = params ?: throw RemoteInterface.SecurityException("wrong handshake sequence")
deferredTransport.await(), val identity = p.decrypt(encoded)
sk, if (established) throw RemoteInterface.SecurityException("repeated client identity")
session, val ci = unpack<ClientIdentity>(identity)
null, if ((ci.clientIdKey == null) != (ci.signature == null))
this@KiloServerConnection throw RemoteInterface.SecurityException("incomplete client identity")
) if (ci.clientIdKey != null) {
if (!ci.clientIdKey.verify(ci.signature!!, clientAuthentication(p.token)))
var verifying: Key.Verifying? = null
var signature: UByteArray? = null
if( serverSigningKey != null ) {
verifying = serverSigningKey.verifying
signature = serverSigningKey.sign(params!!.token)
}
Handshake(1u, pair.publicKey, verifying, signature)
}
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))
throw RemoteInterface.SecurityException("wrong signature") throw RemoteInterface.SecurityException("wrong signature")
p = p.copy(remoteIdentity = ci.clientIdKey) p = p.authenticated(ci.clientIdKey)
params = p params = p
} }
established = true
deferredParams.complete(p) deferredParams.complete(p)
kiloRemoteInterface.complete( kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams, clientInterface))
KiloRemoteInterface(deferredParams, clientInterface) connectedScope = p.scope
) clientInterface.onConnectHandlers.invokeAll(connectedScope)
} PreparedPayload {
val result = p.encodeMessage(ProtectedMessage(ProtectedKind.SERVER_FINISHED, 0uL, 0, identity))
OutgoingData(net.sergeych.utools.pack(result.bytes), result.nonce)
}
} }
} }
val transport = Transport(device, l0Interface, Unit) val transport = Transport(device, l0Interface, Unit)
deferredTransport.complete(transport) deferredTransport.complete(transport)
kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams,clientInterface)) kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams,clientInterface))
debug { "starintg the transport"} debug { "starting the transport"}
transport.run() try {
debug { "server transport finished" } transport.run()
debug { "server transport finished" }
} finally {
connectedScope?.let { clientInterface.onDisconnectHandlers.invokeAll(it) }
}
} }
companion object {
val pair = KeyExchange.keypair()
}
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R { override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
return kiloRemoteInterface.await().call(cmd, args) return kiloRemoteInterface.await().call(cmd, args)
} }
}
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
kiloRemoteInterface.await().push(cmd, args)
}
}
@@ -1,14 +1,52 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import net.sergeych.mp_logger.LogTag
import net.sergeych.mp_logger.Loggable
import net.sergeych.mp_logger.exception
import net.sergeych.mp_logger.info
import net.sergeych.tools.AtomicCounter
import net.sergeych.utools.firstNonNull import net.sergeych.utools.firstNonNull
import net.sergeych.utools.unpack
import kotlin.reflect.KClass import kotlin.reflect.KClass
private typealias RawCommandHandler<C> = suspend (C, UByteArray) -> UByteArray private typealias RawCommandHandler<C> = suspend (C, UByteArray) -> UByteArray
open class LocalInterface<S> { private val idCounter = AtomicCounter()
open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.incrementAndGet()}") {
private val commands = mutableMapOf<String, RawCommandHandler<S>>() private val commands = mutableMapOf<String, RawCommandHandler<S>>()
/**
* Instruct the transport to immediately break the connection.
* This exception is not passed to the remote end, instead, transport device breaks
* connection to remote when receiving it.
*
* Remote interface will throw [RemoteInterface.ClosedException] as the break will be detected. As reaction time
* it depends on the transport in use, we recommend sending some registered exception first if you need
* to pass important data, or implement special commands on both sides.
*
* __Important note:__ _it is not allowed to throw [RemoteInterface.ClosedException] directly!_
* This exception is processed internally and can't be sent over the network.
*/
open class BreakConnectionException(
text: String = "break connection request",
val flushSendQueue: Boolean = true,
) : RuntimeException(text) {
override val message: String?
get() = super.message + " (flush=$flushSendQueue)"
}
/** /**
* New session creator. Rarely needed directlym it can be used for delegation * New session creator. Rarely needed directlym it can be used for delegation
* of local interfaces. * of local interfaces.
@@ -35,19 +73,38 @@ open class LocalInterface<S> {
} }
} }
suspend fun execute( /** Override to gate dispatch before command lookup and argument deserialization. */
open suspend fun execute(
scope: S, scope: S,
name: String, name: String,
packedArgs: UByteArray, packedArgs: UByteArray,
): UByteArray = ): UByteArray =
(commands[name] ?: throw RemoteInterface.UnknownCommand()) (commands[name] ?: throw RemoteInterface.UnknownCommand(name))
.invoke(scope, packedArgs) .invoke(scope, packedArgs)
private val preparedHandlers = mutableMapOf<String, suspend (S, UByteArray) -> PreparedPayload>()
/** Secure handshake replies can defer sealing until atomic outgoing admission. */
internal fun <A> onPrepared(command: Command<A, UByteArray>, handler: suspend S.(A) -> PreparedPayload) {
preparedHandlers[command.name] = { scope, args -> scope.handler(unpack(command.argsSerializer, args)) }
}
/** Dispatch now; encode/seal the result later under the outgoing admission mutex. */
internal open suspend fun executeTransportBlock(scope: S, block: Transport.Block.Call): PreparedPayload {
preparedHandlers[block.name]?.let { return it(scope, block.packedArgs) }
val result = execute(scope, block.name, block.packedArgs)
return PreparedPayload { OutgoingData(result) }
}
private val errorByClass = mutableMapOf<KClass<*>, String>() private val errorByClass = mutableMapOf<KClass<*>, String>()
private val errorBuilder = mutableMapOf<String, (String, UByteArray?) -> Throwable>() private val errorBuilder = mutableMapOf<String, (String, UByteArray?) -> Throwable>()
fun <T : Throwable> registerError( /**
* Register exception for automatic transmission over the kiloparsec connection using its [klass]
* and possibly override code. In most cases it is simpler to use [registerError].
*/
fun <T : Throwable> registerErrorClass(
klass: KClass<T>, code: String = klass.simpleName!!, klass: KClass<T>, code: String = klass.simpleName!!,
exceptionBuilder: (String, UByteArray?) -> T, exceptionBuilder: (String, UByteArray?) -> T,
) { ) {
@@ -55,10 +112,17 @@ open class LocalInterface<S> {
errorBuilder[code] = exceptionBuilder errorBuilder[code] = exceptionBuilder
} }
/**
* Register an exception to be transmitted automatically over the kiloparsec connection.
* Example:
* ```kotlin
* localInterface.registerError { IllegalArgumentException(it) }
* ```
*/
inline fun <reified T : Throwable> registerError( inline fun <reified T : Throwable> registerError(
noinline exceptionBuilder: (String) -> T, noinline exceptionBuilder: (String) -> T,
) { ) {
registerError(T::class) { msg, _ -> exceptionBuilder(msg) } registerErrorClass(T::class) { msg, _ -> exceptionBuilder(msg) }
} }
val errorProviders = mutableListOf<LocalInterface<*>>() val errorProviders = mutableListOf<LocalInterface<*>>()
@@ -68,18 +132,26 @@ open class LocalInterface<S> {
} }
fun getErrorCode(t: Throwable): String? = fun getErrorCode(t: Throwable): String? =
errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) } (t as? ExceptionWithCode)?.code
?: errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) }
fun encodeError(forId: UInt, t: Throwable): Transport.Block.Error = fun encodeError(forId: UInt, t: Throwable): Transport.Block.Error =
getErrorCode(t)?.let { Transport.Block.Error(forId, it, t.message) } if (t is RemoteInterface.ClosedException) {
?: Transport.Block.Error(forId, "UnknownError", t.message) exception { "Illegal attempt to send ClosedException" to t }
encodeError(forId, RemoteInterface.InternalError("TCE"))
}
else
getErrorCode(t)?.let { Transport.Block.Error(forId, it, t.message) }
?: Transport.Block.Error(forId, "UnknownError", "${t::class.simpleName}: ${t.message}")
open fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? = open fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? =
errorBuilder[code] ?: errorProviders.firstNonNull { it.getErrorBuilder(code) } errorBuilder[code] ?: errorProviders.firstNonNull { it.getErrorBuilder(code) }
fun decodeError(tbe: Transport.Block.Error): Throwable = fun decodeError(tbe: Transport.Block.Error): Throwable =
getErrorBuilder(tbe.code)?.invoke(tbe.message, tbe.extra) getErrorBuilder(tbe.code)?.invoke(tbe.message, tbe.extra)
?: RemoteInterface.RemoteException(tbe) ?: RemoteInterface.RemoteException(tbe).also {
info { "can't decode error ${tbe.code}: ${tbe.message}" }
}
fun decodeAndThrow(tbe: Transport.Block.Error): Nothing { fun decodeAndThrow(tbe: Transport.Block.Error): Nothing {
throw decodeError(tbe) throw decodeError(tbe)
@@ -1,3 +1,13 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
/** /**
@@ -15,11 +25,12 @@ interface RemoteInterface {
/** /**
* Is thrown when the channel is closed, in an attempt to execute a command, also to all pending * Is thrown when the channel is closed, in an attempt to execute a command, also to all pending
* calls (see [call]). * calls (see [call]). Client code should never throw it. If command handler needs to break connection
* it should throw [LocalInterface.BreakConnectionException]
*/ */
open class ClosedException(t: String = "connection is closed") : Exception(t) open class ClosedException(t: String = "connection is closed") : Exception(t)
open class SecurityException(t: String = "invalid remote id and signature") : ClosedException(t) open class SecurityException(t: String = "invalid remote id and signature") : LocalInterface.BreakConnectionException(t)
open class InvalidDataException(msg: String="invalid data, can't unpack") : Exception(msg) open class InvalidDataException(msg: String="invalid data, can't unpack") : Exception(msg)
@@ -39,12 +50,37 @@ interface RemoteInterface {
/** /**
* Command is not supported by the remote party * Command is not supported by the remote party
*/ */
class UnknownCommand : RemoteException("UnknownCommand") class UnknownCommand(commandName: String) : RemoteException("UnknownCommand: $commandName")
open class InternalError(code: String="0"): RemoteException("Internal error: $code")
suspend fun <R> call(cmd: Command<Unit, R>): R = call(cmd, Unit) suspend fun <R> call(cmd: Command<Unit, R>): R = call(cmd, Unit)
/** /**
* Call the remote procedure with specified args and return its result * Call the remote procedure with specified [args] and return its result of type [R]. The calling coroutine
* suspends until the request is performed on the remote side and the result value (also `Unit`) will be received.
*
* When it is not necessary to wait for the return value and/or command execution, it is recommended to
* use [push] instead.
*
* @throws RemoteException if the execution caused exception on the remote size
* @throws Exception for registered exceptions, see [LocalInterface.registerError], etc.
*/ */
suspend fun <A, R> call(cmd: Command<A, R>, args: A): R suspend fun <A, R> call(cmd: Command<A, R>, args: A): R
/**
* Push the notification without waiting for reception or processing.
* It returns immediately after sending data to the transport (e.g., to the network).
* Use [call] if it is necessary to wait until the command will be received and processed by the remote.
*
* Push is onlu available for commands without returned value.
*/
suspend fun <A> push(cmd: Command<A, Unit>, args: A)
/**
* Push the command with no args.
* It returns immediately after sending data to the transport (e.g., to the network).
* Use [call] if it is necessary to wait until the command will be received and processed by the remote.
*/
suspend fun push(cmd: Command<Unit,Unit>) = push(cmd,Unit)
} }
@@ -0,0 +1,47 @@
package net.sergeych.kiloparsec
internal const val REPLAY_WINDOW_SIZE = 4096
internal const val MAX_NONCE_OVERTAKING = REPLAY_WINDOW_SIZE * 2 / 3
/** A packet disposition, not a session failure. Never dispatched or replied to. */
internal class DiscardPacket : RuntimeException("Duplicate or stale encrypted packet")
/** Fixed circular bitmap. Callers serialize check/authenticate/record as one operation. */
internal class ReplayWindow {
private val bits = LongArray(REPLAY_WINDOW_SIZE / 64)
private var highest: ULong? = null
fun copy(): ReplayWindow = ReplayWindow().also {
bits.copyInto(it.bits)
it.highest = highest
}
private fun index(n: ULong) = (n % REPLAY_WINDOW_SIZE.toULong()).toInt()
fun containsOrStale(n: ULong): Boolean {
val h = highest ?: return false
if (n > h) return false
if (h - n >= REPLAY_WINDOW_SIZE.toULong()) return true
val i = index(n)
return bits[i / 64] and (1L shl (i % 64)) != 0L
}
fun record(n: ULong) {
val h = highest
if (h == null || n > h) {
if (h == null || n - h >= REPLAY_WINDOW_SIZE.toULong()) bits.fill(0)
else {
// Clear only newly entered positions, not every remembered nonce.
var next = h
repeat((n - h).toInt()) {
val i = index(++next)
bits[i / 64] = bits[i / 64] and (1L shl (i % 64)).inv()
}
}
highest = n
}
val i = index(n)
bits[i / 64] = bits[i / 64] or (1L shl (i % 64))
}
}
/** Local-only metadata; does not change wire encoding. */
internal data class OutgoingData(val bytes: UByteArray, val nonce: ULong? = null)
internal fun interface PreparedPayload { fun encode(): OutgoingData }
internal class UnauthenticatedOuterError : RemoteInterface.SecurityException("Unauthenticated outer error")
@@ -1,6 +1,18 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -13,14 +25,14 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import net.sergeych.crypto.toDump import net.sergeych.bipack.Unsigned
import net.sergeych.kiloparsec.Transport.Device import net.sergeych.kiloparsec.adapter.PriorityBlockWriter
import net.sergeych.mp_logger.* import net.sergeych.mp_logger.*
import net.sergeych.utools.pack import net.sergeych.utools.pack
import net.sergeych.utools.unpack import net.sergeych.utools.unpack
/** /**
* Divan channel that operates some block [Device] exporting a given [localInterface] * Kiloparsec channel that operates some block [Device] exporting a given [localInterface]
* to remote callers. [LocalInterface] allows session managing, transmitting exceptions * to remote callers. [LocalInterface] allows session managing, transmitting exceptions
* in a scure and multiplatform way and provide local command execution (typed RPC) * in a scure and multiplatform way and provide local command execution (typed RPC)
*/ */
@@ -41,7 +53,7 @@ class Transport<S>(
* Input blocks. When the device is disconnected, it should send one null to this channel * Input blocks. When the device is disconnected, it should send one null to this channel
* to notify the owner. When [close] is called, the channel should be closed. * to notify the owner. When [close] is called, the channel should be closed.
*/ */
val input: ReceiveChannel<UByteArray?> val input: ReceiveChannel<UByteArray>
/** /**
* Send a binary block to a remote party where it should be received and put into [input] * Send a binary block to a remote party where it should be received and put into [input]
@@ -54,12 +66,27 @@ class Transport<S>(
* possible. This method must not throw exceptions. * possible. This method must not throw exceptions.
*/ */
suspend fun close() suspend fun close()
suspend fun flush() {}
/** Adapter admission; legacy devices retain their existing output-channel behavior. */
suspend fun send(block: UByteArray) { output.send(block) }
} }
@Serializable(TransportBlockSerializer::class) @Serializable(TransportBlockSerializer::class)
sealed class Block { sealed class Block {
// Kept outside the legacy generated body serializer. Only the outer custom
// serializer appends it, so old field positions and constructors stay intact.
@kotlinx.serialization.Transient
var priority: Int = 0
@Serializable @Serializable
data class Call(val id: UInt, val name: String, val packedArgs: UByteArray) : Block() { data class Call(
@Unsigned
val id: UInt,
val name: String,
val packedArgs: UByteArray
) : Block() {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Call) return false if (other !is Call) return false
@@ -79,44 +106,123 @@ class Transport<S>(
} }
@Serializable @Serializable
data class Response(val forId: UInt, val packedResult: UByteArray) : Block() data class Response(@Unsigned val forId: UInt, val packedResult: UByteArray) : Block()
@Serializable @Serializable
data class Error(val forId: UInt, val code: String, val text: String? = null, val extra: UByteArray? = null) : data class Error(@Unsigned val forId: UInt, val code: String, val text: String? = null, val extra: UByteArray? = null) :
Block() { Block() {
val message by lazy { text ?: "remote exception: $code" } val message by lazy { text ?: "remote exception: $code" }
} }
} }
private val outgoing = Mutex()
private val writerReady = CompletableDeferred<PriorityBlockWriter>()
private val responseReaders = mutableMapOf<UInt, (UByteArray) -> UByteArray>()
private val access = Mutex() private val access = Mutex()
private var lastId = 0u private var lastId = 0u
private val calls = mutableMapOf<UInt, CompletableDeferred<UByteArray>>() private val calls = mutableMapOf<UInt, CompletableDeferred<UByteArray>>()
var isClosed: Boolean = false var isClosed: Boolean = false
/** /**
* Send a call block for a command and packed args and return packed result if it is not an error * Send a call block for a command and packed args and return packed result if it is not an error. It suspends
* until receiving answer from the remote side, even if returns `Unit`.
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead. * @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
* @throws RemoteInterface.ClosedException * @throws RemoteInterface.ClosedException
*/ */
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray { private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray =
sendBoundCallBlock(name) { OutgoingData(packedArgs) }
/** Allocate the outer ID before sealing its authenticated inner counterpart. */
private suspend fun sendBoundCallBlock(
name: String,
readResponse: ((UByteArray) -> UByteArray)? = null,
packedArgs: (UInt) -> OutgoingData,
): UByteArray {
if (isClosed) throw RemoteInterface.ClosedException() if (isClosed) throw RemoteInterface.ClosedException()
val b: Block val id: UInt
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
val deferred = CompletableDeferred<UByteArray>() val deferred = CompletableDeferred<UByteArray>()
// We need to shield calls and lastID with mutex, but nothing more: // We need to shield calls and lastID with mutex, but nothing more:
access.withLock { access.withLock {
if (isClosed) throw RemoteInterface.ClosedException() if (isClosed) throw RemoteInterface.ClosedException()
b = Block.Call(++lastId, name, packedArgs) // the order is important: first id in use MUST BE >= 1, not zero:
calls[b.id] = deferred if (lastId == UInt.MAX_VALUE) throw RemoteInterface.ClosedException("RPC ID space exhausted")
id = ++lastId
calls[id] = deferred
if (readResponse != null) responseReaders[id] = readResponse
} }
// now we have mutex freed so we can call: // Suspend on actual channel backpressure; never poll or delay a successful send.
val r = device.output.trySend(pack(b).also { debug { ">>>\n${it.toDump()}" } }) try {
if (!r.isSuccess) deferred.completeExceptionally(RemoteInterface.ClosedException()) sendPrepared {
val payload = packedArgs(id)
OutgoingData(pack<Block>(Block.Call(id, name, payload.bytes).also { it.priority = priority }), payload.nonce)
}
return deferred.await()
} catch (_: ClosedSendChannelException) {
throw RemoteInterface.ClosedException()
} finally {
// Cancellation must not retain a response waiter for the connection lifetime.
withContext(NonCancellable) { access.withLock { calls.remove(id); responseReaders.remove(id) } }
}
}
// it returns packed result or throws a proper error: /**
return deferred.await() * Send a call block for a command and packed args and return packed result if it is not an error. It suspends
* until receiving answer from the remote side, even if returns `Unit`.
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
* @throws RemoteInterface.ClosedException
*/
private suspend fun sendPushBlock(name: String, packedArgs: UByteArray) {
if (isClosed) throw RemoteInterface.ClosedException()
// All push blocks have the same id == 0:
val b = Block.Call(0u, name, packedArgs).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
val r = runCatching { send(b) }
when(val e = r.exceptionOrNull()) {
null -> return
is RemoteInterface.ClosedException, is CancellationException, is RemoteInterface.RemoteException
-> throw e
else -> throw RemoteInterface.ClosedException()
}
}
internal suspend fun callWithId(
cmd: Command<UByteArray, UByteArray>,
readResponse: (UByteArray) -> UByteArray = { it },
build: (UInt) -> OutgoingData,
): UByteArray = sendBoundCallBlock(cmd.name, { raw ->
try { readResponse(unpack(cmd.resultSerializer, raw)) }
catch (e: DiscardPacket) { throw e }
catch (e: Throwable) { throw RemoteInterface.SecurityException("Invalid authenticated response") }
}) { id ->
val payload = build(id)
OutgoingData(pack(cmd.argsSerializer, payload.bytes), payload.nonce)
}
internal suspend fun pushPrepared(name: String, priority: Int, prepare: () -> OutgoingData) {
sendPrepared {
val payload = prepare()
OutgoingData(pack<Block>(Block.Call(0u, name, pack(payload.bytes)).also { it.priority = priority }), payload.nonce)
}
}
/** Atomic preparation/admission, with carrier completion awaited outside the mutex. */
private suspend fun sendPrepared(prepare: () -> OutgoingData) {
val writer = writerReady.await()
val awaitSent = outgoing.withLock {
if (isClosed) throw RemoteInterface.ClosedException()
val frame = prepare()
writer.enqueue(frame.bytes, frame.nonce)
}
awaitSent()
}
internal suspend fun abortSecurity() {
isClosed = true
device.close()
} }
/** /**
@@ -127,6 +233,10 @@ class Transport<S>(
return unpack(cmd.resultSerializer, result) return unpack(cmd.resultSerializer, result)
} }
override suspend fun <A>push(cmd: Command<A,Unit>,args: A) {
sendPushBlock(cmd.name, pack(cmd.argsSerializer, args))
}
/** /**
* Start running the transport. This function suspends until the transport is closed * Start running the transport. This function suspends until the transport is closed
* normally or by error. If you need to cancel it prematurely, cancel the coroutine * normally or by error. If you need to cancel it prematurely, cancel the coroutine
@@ -136,77 +246,128 @@ class Transport<S>(
*/ */
suspend fun run() { suspend fun run() {
coroutineScope { coroutineScope {
// One ordered submission to the carrier at a time: adapter queues cannot
// undo the nonce-aware scheduling performed here.
val writer = PriorityBlockWriter(this, continueOnWriteFailure = true) { device.send(it) }
writerReady.complete(writer)
debug { "awaiting incoming blocks" } debug { "awaiting incoming blocks" }
// todo: rewrite it to close the job with no exceptions at all, always
while (isActive && !isClosed) { while (isActive && !isClosed) {
try { try {
device.input.receive()?.let { packed -> device.input.receive().let { packed ->
debug { "<<<\n${packed.toDump()}" }
val b = unpack<Block>(packed) val b = unpack<Block>(packed)
debug { "<<$ $b" }
debug { "access state: ${access.isLocked}" }
when (b) { when (b) {
is Block.Error -> access.withLock { is Block.Error -> access.withLock {
val error = localInterface.decodeError(b) val error = if (responseReaders.containsKey(b.forId)) UnauthenticatedOuterError() else localInterface.decodeError(b)
warning { "decoded error: ${error::class.simpleName}: $error" } warning { "decoded error: ${error::class.simpleName}: $error" }
calls.remove(b.forId)?.completeExceptionally(localInterface.decodeError(b)) responseReaders.remove(b.forId)
calls.remove(b.forId)?.completeExceptionally(error)
?: warning { "error handler not found for ${b.forId}" } ?: warning { "error handler not found for ${b.forId}" }
info { "error processed"} info { "error processed" }
} }
is Block.Response -> access.withLock { is Block.Response -> access.withLock {
calls.remove(b.forId)?.let { val pending = calls[b.forId]
debug { "activating wait handle for ${b.forId}" } if (pending != null) {
it.complete(b.packedResult) try {
val result = responseReaders[b.forId]?.invoke(b.packedResult) ?: b.packedResult
calls.remove(b.forId)
responseReaders.remove(b.forId)
pending.complete(result)
} catch (_: DiscardPacket) {
// Keep the waiter: a valid response may still arrive.
} catch (failure: Throwable) {
calls.remove(b.forId)
responseReaders.remove(b.forId)
pending.completeExceptionally(failure)
}
} }
?: warning { "wait handle not found for ${b.forId}" }
} }
is Block.Call -> launch { is Block.Call -> launch {
try { try {
send( val result = localInterface.executeTransportBlock(commandContext, b)
Block.Response( if (b.id != 0u) {
b.id, try {
localInterface.execute(commandContext, b.name, b.packedArgs) sendPrepared {
) val encoded = result.encode()
) OutgoingData(pack<Block>(Block.Response(b.id, encoded.bytes).also { it.priority = b.priority }), encoded.nonce)
} catch (x: RemoteInterface.ClosedException) { }
// strange case: handler throws closed? } catch (failure: CancellationException) { throw failure }
error { "not supported: command handler for $b has thrown ClosedException" } catch (failure: LocalInterface.BreakConnectionException) { throw failure }
send(Block.Error(b.id, "UnexpectedException", x.message)) catch (failure: Throwable) {
// Local reply admission/I/O failure is not a plaintext
// remote business error. Leave peer timeout policy intact.
warning { "Reply send failed: ${failure.message}" }
}
}
} catch (_: DiscardPacket) {
// Duplicate/stale input is silently discarded, including pushes.
} catch (x: LocalInterface.BreakConnectionException) {
// handler forced close
warning { "handler requested closing of the connection (${x.flushSendQueue}" }
isClosed = true
if (x.flushSendQueue) { writer.flush(); device.flush() }
device.close()
} catch (x: CancellationException) {
throw x
} catch (x: RemoteInterface.RemoteException) { } catch (x: RemoteInterface.RemoteException) {
send(Block.Error(b.id, x.code, x.text, x.extra)) send(Block.Error(b.id, x.code, x.text, x.extra).also { it.priority = b.priority })
} catch (t: Throwable) { } catch (t: Throwable) {
send(Block.Error(b.id, "UnknownError", t.message)) send(Block.Error(b.id, "UnknownError", t.message).also { it.priority = b.priority })
} }
.also { debug { "command executed: ${b.name}" } }
} }
} }
} ?: run {
debug { "remote channel close received" }
isClosed = true
} }
// debug { "input step performed closed=$isClosed active=$isActive" }
} catch (_: ClosedSendChannelException) {
info { "closed send channel" }
isClosed = true
} catch (_: ClosedReceiveChannelException) {
info { "closed receive channel" }
isClosed = true
} catch (cce: LocalInterface.BreakConnectionException) {
info { "closing connection by local request ($cce)" }
device.close()
} catch (_: RemoteInterface.ClosedException) {
// it is ok: we just exit the coroutine normally
// and mark we're closing
isClosed = true
} catch (_: CancellationException) { } catch (_: CancellationException) {
info { "loop is cancelled" } info { "loop is cancelled with CancellationException" }
isClosed = true isClosed = true
} catch (t: Throwable) { } catch (t: Throwable) {
exception { "channel closed on error" to t } exception { "channel closed on error" to t }
info { "isa? $isActive / $isClosed" } info { "isa? $isActive / $isClosed" }
runCatching { device.close() }
isClosed = true isClosed = true
} }
} }
debug { "leaving transport loop" }
access.withLock { access.withLock {
debug { "access lock obtained" }
isClosed = true isClosed = true
for (c in calls.values) c.completeExceptionally(RemoteInterface.ClosedException()) debug { "closing device $device, calls in queue ${calls.size}" }
runCatching { device.close() }
for (c in calls.values)
c.completeExceptionally(RemoteInterface.ClosedException())
calls.clear() calls.clear()
responseReaders.clear()
writer.close()
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}" } debug { "no more active: $isActive / ${calls.size}" }
} }
info { "exiting transport loop" } debug { "exiting transport loop" }
} }
private suspend fun send(block: Block) { private suspend fun send(block: Block) {
device.output.send(pack(block)) try {
sendPrepared { OutgoingData(pack(block)) }
} catch (_: ClosedSendChannelException) {
throw RemoteInterface.ClosedException()
}
} }
} }
@@ -231,14 +392,26 @@ object TransportBlockSerializer : KSerializer<Transport.Block> {
encoder.encodeSerializableValue(serializer<Transport.Block.Response>(), value) encoder.encodeSerializableValue(serializer<Transport.Block.Response>(), value)
} }
} }
// Fixed-size trailer is omitted for zero, preserving exact legacy bytes.
if (value.priority != 0) {
for (shift in 24 downTo 0 step 8) encoder.encodeByte((value.priority ushr shift).toByte())
}
} }
override fun deserialize(decoder: Decoder): Transport.Block = override fun deserialize(decoder: Decoder): Transport.Block {
when( val id = decoder.decodeByte().toInt()) { val block = when (val id = decoder.decodeByte().toInt()) {
0 -> decoder.decodeSerializableValue(serializer<Transport.Block.Call>()) 0 -> decoder.decodeSerializableValue(serializer<Transport.Block.Call>())
1 -> decoder.decodeSerializableValue(serializer<Transport.Block.Error>()) 1 -> decoder.decodeSerializableValue(serializer<Transport.Block.Error>())
2 -> decoder.decodeSerializableValue(serializer<Transport.Block.Response>()) 2 -> decoder.decodeSerializableValue(serializer<Transport.Block.Response>())
else -> throw RemoteInterface.InvalidDataException("wrong block type: $id") else -> throw RemoteInterface.InvalidDataException("wrong block type: $id")
} }
} val first = try { decoder.decodeByte().toInt() and 255 }
catch (_: net.sergeych.bintools.DataSource.EndOfData) { return block }
// A present but truncated trailer is malformed, not a legacy priority-zero block.
var priority = first
repeat(3) { priority = (priority shl 8) or (decoder.decodeByte().toInt() and 255) }
block.priority = priority
return block
}
}
@@ -0,0 +1,27 @@
/*
* 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.
*/
package net.sergeych.kiloparsec.adapter
import kotlinx.coroutines.channels.Channel
/**
* Transport device for inet protocol family with graceful shutdown on [close]
*/
@Suppress("unused")
class InetTransportDevice(
inputChannel: Channel<UByteArray>,
outputChannel: Channel<UByteArray>,
val remoteAddress: NetworkAddress,
doClose: (suspend ()->Unit)? = null,
doFlush: (suspend ()->Unit)? = null,
) : ProxyDevice(inputChannel, outputChannel, doClose, doFlush) {
override fun toString(): String = "@$remoteAddress"
}
@@ -1,37 +1,23 @@
/*
* 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.
*/
package net.sergeych.kiloparsec.adapter package net.sergeych.kiloparsec.adapter
import kotlinx.coroutines.channels.ReceiveChannel
/** /**
* Multiplatform implementation of an internet address. * Multiplatform internet address.
* Notice to implementors. It must provide correct and effective [equals] and [hashCode].
*/ */
interface NetworkAddress { data class NetworkAddress(
val host: String val host: String,
val port: Int val port: Int
} ) {
override fun toString(): String {
/** return "$host:$port"
* Multiplatform datagram abstraction
*/
interface Datagram {
val message: UByteArray
val address: NetworkAddress
suspend fun respondWith(message: UByteArray)
}
interface DatagramReceiver {
val incoming: ReceiveChannel<Datagram>
suspend fun send(message: UByteArray, networkAddress: NetworkAddress)
@Suppress("unused")
suspend fun send(message: UByteArray, datagramAddress: String) {
send(message, networkAddressOf(datagramAddress))
} }
suspend fun send(message: UByteArray,host: String,port: Int) =
send(message, NetworkAddress(host,port))
fun close()
} }
expect fun networkAddressOf(address: String): NetworkAddress
expect fun NetworkAddress(host: String,port: Int): NetworkAddress
@@ -0,0 +1,25 @@
package net.sergeych.kiloparsec.adapter
import net.sergeych.kiloparsec.MAX_NONCE_OVERTAKING
/** Externally synchronized. Nonces belong to one sending-key stream. */
internal class OutQueue<T>(private val allowance: ULong = MAX_NONCE_OVERTAKING.toULong()) {
private data class Entry<T>(val value: T, val nonce: ULong?, val priority: Int)
private val entries = mutableListOf<Entry<T>>()
val values get() = entries.map { it.value }
fun push(value: T, nonce: ULong?, priority: Int) { entries.add(Entry(value, nonce, priority)) }
fun pop(): T? {
val oldest = entries.mapNotNull { it.nonce }.minOrNull()
var best: Entry<T>? = null
for (entry in entries) {
if (oldest != null && entry.nonce != null && entry.nonce - oldest > allowance) continue
val previous = best
if (previous == null || entry.priority > previous.priority ||
(entry.priority == previous.priority && entry.nonce != null && previous.nonce != null && entry.nonce < previous.nonce)) best = entry
}
return best?.also { entries.remove(it) }?.value
}
fun remove(value: T) { entries.removeAll { it.value === value } }
fun removeAll(predicate: (T) -> Boolean) { entries.removeAll { predicate(it.value) } }
fun clear() { entries.clear() }
}
@@ -0,0 +1,122 @@
package net.sergeych.kiloparsec.adapter
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.bintools.DataSource
import net.sergeych.kiloparsec.RemoteInterface
/** Reads only headers and skips length-delimited payloads; never copies or decrypts payload data. */
internal fun blockPriority(bytes: UByteArray): Int {
val source = object : DataSource {
var position = 0
override fun readByte(): Byte {
if (position >= bytes.size) throw DataSource.EndOfData()
return bytes[position++].toByte()
}
fun skipBlob() {
val size = readSmartUInt().toLong()
require(size <= bytes.size - position)
position += size.toInt()
}
}
return try {
val tag = source.readUByte().toInt()
source.readSmartUInt() // call ID or response ID
when (tag) {
0 -> { source.skipBlob(); source.skipBlob() }
1 -> {
source.skipBlob()
if (source.readByte() != 0.toByte()) source.skipBlob()
if (source.readByte() != 0.toByte()) source.skipBlob()
}
2 -> source.skipBlob()
else -> return 0 // Opaque legacy device users need not send RPC blocks.
}
if (bytes.size - source.position != 4) return 0
var result = 0
repeat(4) { result = (result shl 8) or source.readUByte().toInt() }
result
} catch (_: Exception) { 0 }
}
/**
* One adapter writer. Send completes after its write callback, not after an RPC reply.
* Queue ordering is nonce-bounded numeric priority; unnumbered ties remain FIFO. Cancellation removes queued work;
* bytes already being written cannot be recalled. No rate limit or idle batching delay.
*/
internal class PriorityBlockWriter(
scope: CoroutineScope,
private val maxQueuedItems: Int = 128,
private val maxQueuedBytes: Int = 32 * 1024 * 1024,
private val continueOnWriteFailure: Boolean = false,
private val write: suspend (UByteArray) -> Unit,
) {
private class Pending(val bytes: UByteArray, val priority: Int, val done: CompletableDeferred<Unit>)
private val mutex = Mutex()
private val wake = Channel<Unit>(Channel.CONFLATED)
private val pending = OutQueue<Pending>()
private var closed = false
private var writing: Pending? = null
private val job = scope.launch {
var current: Pending? = null
try {
while (isActive) {
current = mutex.withLock {
pending.removeAll { !it.done.isActive }
pending.pop().also { writing = it }
}
val entry = current
if (entry == null) { wake.receive(); continue }
try {
if (entry.done.isActive) write(entry.bytes)
entry.done.complete(Unit)
} catch (failure: Throwable) {
entry.done.completeExceptionally(failure)
if (!continueOnWriteFailure || failure is CancellationException) throw failure
}
mutex.withLock { writing = null }
current = null
}
} catch (failure: Throwable) {
current?.done?.completeExceptionally(failure)
throw failure
} finally {
withContext(NonCancellable) {
mutex.withLock {
closed = true
pending.values.forEach { it.done.completeExceptionally(RemoteInterface.ClosedException()) }
pending.clear()
}
}
}
}
/** Admission never waits for the carrier. Caller may hold its preparation mutex here. */
suspend fun enqueue(bytes: UByteArray, nonce: ULong? = null): suspend () -> Unit {
currentCoroutineContext().ensureActive()
val entry = Pending(bytes, blockPriority(bytes), CompletableDeferred())
mutex.withLock {
if (closed || !job.isActive) throw RemoteInterface.ClosedException()
pending.removeAll { !it.done.isActive }
if (pending.values.size >= maxQueuedItems || bytes.size > maxQueuedBytes - pending.values.sumOf { it.bytes.size })
throw RemoteInterface.Exception("Outgoing block queue is full")
pending.push(entry, nonce, entry.priority)
}
wake.trySend(Unit)
return {
try { entry.done.await() }
finally {
entry.done.cancel()
withContext(NonCancellable) { mutex.withLock { pending.remove(entry) } }
}
}
}
suspend fun send(bytes: UByteArray) { enqueue(bytes).invoke() }
suspend fun flush() {
val queued = mutex.withLock { (pending.values + listOfNotNull(writing)).map { it.done } }
queued.forEach { it.await() }
}
fun close() { job.cancel() }
}
@@ -0,0 +1,60 @@
/*
* 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.
*/
package net.sergeych.kiloparsec.adapter
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import net.sergeych.kiloparsec.Transport
import net.sergeych.tools.AtomicCounter
private val counter = AtomicCounter()
open class ProxyDevice(
private val inputChannel: Channel<UByteArray>,
private val outputChannel: Channel<UByteArray>,
private val doClose: (suspend ()->Unit)? = null,
private val doFlush: (suspend ()->Unit)? = null,
): Transport.Device {
internal var prioritizedSend: (suspend (UByteArray) -> Unit)? = null
internal var priorityWriter: PriorityBlockWriter? = null
override suspend fun send(block: UByteArray) {
prioritizedSend?.let { it(block); return }
val writer = priorityWriter
if (writer == null) output.send(block) else writer.send(block)
}
override val input: ReceiveChannel<UByteArray> = inputChannel
override val output: SendChannel<UByteArray> = outputChannel
override suspend fun close() {
priorityWriter?.close()
kotlin.runCatching { doClose?.invoke() }
runCatching { inputChannel.close() }
runCatching { outputChannel.close() }
}
override suspend fun flush() {
priorityWriter?.flush()
doFlush?.invoke()
var cnt = 10
while(!outputChannel.isEmpty) {
if (cnt-- < 0) break
delay(50)
}
super.flush()
}
private val id = counter.incrementAndGet()
override fun toString(): String = "PX$id"
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025-2026. 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.
*/
package net.sergeych.kiloparsec.adapter
import io.ktor.client.*
import io.ktor.client.plugins.websocket.*
import io.ktor.http.*
import io.ktor.websocket.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.coroutines.launch
import kotlinx.io.IOException
import net.sergeych.crypto2.SigningKey
import net.sergeych.kiloparsec.*
import net.sergeych.mp_logger.*
import net.sergeych.mp_tools.decodeBase64Compact
import net.sergeych.mp_tools.encodeToBase64Compact
import net.sergeych.mp_tools.globalLaunch
import net.sergeych.tools.AtomicCounter
private val counter = AtomicCounter()
/**
* Shortcut to create websocket client. Use [websocketTransportDevice] with [KiloClient]
* for fine-grained control.
*/
fun <S> websocketClient(
path: String,
clientInterface: KiloInterface<S> = KiloInterface(),
secretKey: SigningKey? = null,
useTextFrames: Boolean = false,
sessionMaker: () -> S = {
@Suppress("UNCHECKED_CAST")
Unit as S
},
): KiloClient<S> {
return KiloClient(clientInterface, secretKey) {
KiloConnectionData(websocketTransportDevice(path, useTextFrames), sessionMaker())
}
}
/**
* Create kilopaarsec transport over websocket (ws or wss).
* @param path websocket path (must start with ws:// or wss:// and contain a path part)
* @param client optional caller-owned client. When omitted, a client with the
* [WebSockets] plugin is created for this device and closed with it.
*/
fun websocketTransportDevice(
path: String,
useTextFrames: Boolean = false,
client: HttpClient? = null,
): Transport.Device {
val ownsClient = client == null
val actualClient = client ?: HttpClient { install(WebSockets) }
val log = LogTag("WSTD")
var u = Url(path)
log.debug { "Creating websocket transport device at $u" }
if (u.encodedPath.length <= 1) {
log.debug {"Correcting path as up = ${u.encodedPath}" }
u = URLBuilder(u).apply {
encodedPath = "/kp"
}.build()
}
log.debug { "Url to process is $u" }
val input = Channel<UByteArray>()
val output = Channel<UByteArray>()
val writerReady = CompletableDeferred<PriorityBlockWriter>()
val closeHandle = CompletableDeferred<Boolean>()
val connectionJob = globalLaunch {
val log = LogTag("KC:${counter.incrementAndGet()}")
try {
actualClient.webSocket({
url.protocol = u.protocol
url.host = u.host
url.port = u.port
url.encodedPath = u.encodedPath
url.parameters.appendAll(u.parameters)
log.info { "kiloparsec server URL: $url" }
}) {
log.info { "connected to the server" }
// println("SENDING!!!")
// send("Helluva")
val writer = PriorityBlockWriter(this) { block ->
if (useTextFrames) send(Frame.Text(block.asByteArray().encodeToBase64Compact()))
else send(block.asByteArray())
flush() // Do not turn the WebSocket outgoing FIFO into an unbounded look-ahead.
}
writerReady.complete(writer)
launch {
try {
for (block in output) writer.send(block)
log.info { "input is closed, closing the websocket" }
if (closeHandle.isActive) closeHandle.complete(true)
} catch (_: ClosedSendChannelException) {
log.info { "send channel closed" }
} catch (_: CancellationException) {
} catch (t: Throwable) {
log.info { "unexpected exception in websock sender: ${t.stackTraceToString()}" }
closeHandle.completeExceptionally(t)
}
writer.close()
if (closeHandle.isActive) closeHandle.complete(false)
}
launch {
try {
for (f in incoming) {
when (f) {
is Frame.Binary -> input.send(f.readBytes().toUByteArray())
is Frame.Text -> input.send(f.readText().decodeBase64Compact().toUByteArray())
else -> log.warning { "ignoring unexpected frame of type ${f.frameType}" }
}
}
if (closeHandle.isActive) closeHandle.complete(true)
} catch (_: CancellationException) {
if (closeHandle.isActive) closeHandle.complete(false)
} catch (_: ClosedReceiveChannelException) {
log.warning { "receive channel closed unexpectedly" }
if (closeHandle.isActive) closeHandle.complete(false)
} catch (t: Throwable) {
log.exception { "unexpected error" to t }
if (closeHandle.isActive) closeHandle.complete(false)
}
}
if (!closeHandle.await()) {
log.warning { "Client is closing with error" }
throw RemoteInterface.ClosedException()
}
runCatching { output.close() }
runCatching { input.close() }
runCatching { close() }
}
} catch (x: IOException) {
if ("refused" in x.toString()) log.debug { "connection refused" }
else log.warning { "unexpected IO error $x" }
runCatching { output.close() }
runCatching { input.close() }
} finally {
writerReady.completeExceptionally(RemoteInterface.ClosedException())
if (ownsClient) actualClient.close()
}
log.info { "closing connection" }
}
// Wait for connection be established or failed
val device = ProxyDevice(input, output, doClose = {
// we need to explicitly close the coroutine job, or it can hang for a long time
// leaking resources.
runCatching { output.close() }
runCatching { input.close() }
closeHandle.complete(true)
connectionJob.cancel()
// job.cancel()
})
device.prioritizedSend = { block ->
val writer = writerReady.await()
device.priorityWriter = writer
writer.send(block)
}
return device
}
@@ -1,18 +1,29 @@
/*
* 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.
*/
package net.sergeych.kiloparsec package net.sergeych.kiloparsec
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import net.sergeych.crypto.Key import net.sergeych.crypto2.SafeKeyExchange
import net.sergeych.crypto2.Seal
import net.sergeych.crypto2.VerifyingPublicKey
// L0 commands - key exchange and check: // L0 commands - key exchange and check:
@Serializable @Serializable
data class Handshake(val version: UInt, val publicKey: UByteArray, data class Handshake(val version: UInt, val publicKey: SafeKeyExchange.PublicKey,
val serverSharedKey: Key.Verifying? = null, val signature: Seal? = null)
val signature: UByteArray? = null)
@Serializable @Serializable
data class ClientIdentity(val clientIdKey: Key.Verifying?, val signature: UByteArray?) data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: UByteArray?)
// Level 0 command: request key exchange // Level 0 command: request key exchange
internal val L0Request by command<Handshake, Handshake>() 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>() internal val L0Call by command<UByteArray,UByteArray>()
@@ -0,0 +1,34 @@
/*
* 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.
*/
package net.sergeych.kiloparsec
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
fun String.encodeToUByteArray() =
encodeToByteArray().toUByteArray()
class SyncValue<T>(initialValue: T) {
private val access = Mutex()
var value = initialValue
private set
suspend fun mutate(f: suspend (T)->T): T = access.withLock { f(value).also { value = it } }
@Suppress("unused")
suspend fun getAndSet(newValue: T): T = mutate {
val old = value
value = newValue
old
}
}
@@ -1,21 +0,0 @@
package net.sergeych.tools
/**
* Multiplatform interface to perform a regular (not suspend) operation
* protected by a platform mutex (where necessary). Get real implementation
* with [ProtectedOp]
*/
interface ProtectedOpImplementation {
/**
* Call [f] iin mutually exclusive mode, it means that only one invocation
* can be active at a time, all the rest are waiting until the current operation
* will finish.
*/
operator fun <T>invoke(f: ()->T): T
}
/**
* Get the platform-depended implementation of a mutex-protected operation.
*/
expect fun ProtectedOp(): ProtectedOpImplementation
@@ -1,12 +0,0 @@
package net.sergeych.utools
/**
* Scan the collection and return the first non-null result of the [predicate] on it.
* If all the elements give null with predicate call, returns null.
*
* Note that collection is scanned only to the first non-null predicate result.
*/
fun <T,R>Collection<T>.firstNonNull(predicate: (T)->R?): R? {
for( x in this ) predicate(x)?.let { return it }
return null
}
@@ -1,46 +0,0 @@
package net.sergeych.utools
import kotlinx.serialization.KSerializer
import kotlinx.serialization.serializer
import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
/**
* Effectively pack anyk nullable object. The result could be effectively packed
* in turn as a part of a more complex structure.
*
* To avoid packing non-null mark,
* we use a zero-size array, which, if in turn encoded, packs into a single
* zero byte. Thus, we avoid extra byte spending for unnecessary null
* check.
*/
inline fun <reified T> pack(element: T?): UByteArray = pack(serializer<T>(), element)
/**
* Unpack nullable data packed with [pack]
*/
inline fun <reified T: Any?> unpack(encoded: UByteArray): T =
unpack(serializer<T>(), encoded)
/**
* Effectively pack anyk nullable object. The result could be effectively packed
* in turn as a part of a more complex structure.
*
* To avoid packing non-null mark,
* we use a zero-size array, which, if in turn encoded, packs into a single
* zero byte. Thus, we avoid extra byte spending for unnecessary null
* check.
*/
fun <T>pack(serializer: KSerializer<T>, element: T?): UByteArray =
if (element == null) ubyteArrayOf()
else BipackEncoder.encode(serializer,element).toUByteArray()
/**
* Unpack nullable data packed with [pack]
*/
@Suppress("UNCHECKED_CAST")
fun <T: Any?> unpack(serializer: KSerializer<T>, encoded: UByteArray): T =
if (encoded.isEmpty()) null as T
else BipackDecoder.decode(encoded.toByteArray().toDataSource(),serializer)
@@ -1,12 +0,0 @@
@file:Suppress("unused")
package net.sergeych.utools
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
fun now(): Instant = Clock.System.now()
fun nowToSeconds(): Instant = Clock.System.now().truncateToSeconds()
fun Instant.truncateToSeconds(): Instant =
Instant.fromEpochSeconds(toEpochMilliseconds()/1000)
@@ -1,183 +0,0 @@
package org.komputing.khash.keccak
import com.ionspin.kotlin.bignum.integer.BigInteger
import org.komputing.khash.keccak.extensions.fillWith
import kotlin.math.min
object Keccak {
private val BIT_65 = BigInteger.ONE shl (64)
private val MAX_64_BITS = BIT_65 - BigInteger.ONE
fun digest(value: ByteArray, parameter: KeccakParameter): ByteArray {
val uState = IntArray(200)
val uMessage = convertToUInt(value)
var blockSize = 0
var inputOffset = 0
// Absorbing phase
while (inputOffset < uMessage.size) {
blockSize = min(uMessage.size - inputOffset, parameter.rateInBytes)
for (i in 0 until blockSize) {
uState[i] = uState[i] xor uMessage[i + inputOffset]
}
inputOffset += blockSize
if (blockSize == parameter.rateInBytes) {
doF(uState)
blockSize = 0
}
}
// Padding phase
uState[blockSize] = uState[blockSize] xor parameter.d
if (parameter.d and 0x80 != 0 && blockSize == parameter.rateInBytes - 1) {
doF(uState)
}
uState[parameter.rateInBytes - 1] = uState[parameter.rateInBytes - 1] xor 0x80
doF(uState)
// Squeezing phase
val byteResults = mutableListOf<Byte>()
var tOutputLen = parameter.outputLengthInBytes
while (tOutputLen > 0) {
blockSize = min(tOutputLen, parameter.rateInBytes)
for (i in 0 until blockSize) {
byteResults.add(uState[i].toByte().toInt().toByte())
}
tOutputLen -= blockSize
if (tOutputLen > 0) {
doF(uState)
}
}
return byteResults.toByteArray()
}
private fun doF(uState: IntArray) {
val lState = Array(5) { Array(5) { BigInteger.ZERO } }
for (i in 0..4) {
for (j in 0..4) {
val data = IntArray(8)
val index = 8 * (i + 5 * j)
uState.copyInto(data, 0, index, index + data.size)
lState[i][j] = convertFromLittleEndianTo64(data)
}
}
roundB(lState)
uState.fillWith(0)
for (i in 0..4) {
for (j in 0..4) {
val data = convertFrom64ToLittleEndian(lState[i][j])
data.copyInto(uState, 8 * (i + 5 * j))
}
}
}
/**
* Permutation on the given state.
*/
private fun roundB(state: Array<Array<BigInteger>>) {
var lfsrState = 1
for (round in 0..23) {
val c = arrayOfNulls<BigInteger>(5)
val d = arrayOfNulls<BigInteger>(5)
// θ step
for (i in 0..4) {
c[i] = state[i][0].xor(state[i][1]).xor(state[i][2]).xor(state[i][3]).xor(state[i][4])
}
for (i in 0..4) {
d[i] = c[(i + 4) % 5]!!.xor(c[(i + 1) % 5]!!.leftRotate64(1))
}
for (i in 0..4) {
for (j in 0..4) {
state[i][j] = state[i][j].xor(d[i]!!)
}
}
// ρ and π steps
var x = 1
var y = 0
var current = state[x][y]
for (i in 0..23) {
val tX = x
x = y
y = (2 * tX + 3 * y) % 5
val shiftValue = current
current = state[x][y]
state[x][y] = shiftValue.leftRotate64Safely((i + 1) * (i + 2) / 2)
}
// χ step
for (j in 0..4) {
val t = arrayOfNulls<BigInteger>(5)
for (i in 0..4) {
t[i] = state[i][j]
}
for (i in 0..4) {
// ~t[(i + 1) % 5]
val invertVal = t[(i + 1) % 5]!!.xor(MAX_64_BITS)
// t[i] ^ ((~t[(i + 1) % 5]) & t[(i + 2) % 5])
state[i][j] = t[i]!!.xor(invertVal.and(t[(i + 2) % 5]!!))
}
}
// ι step
for (i in 0..6) {
lfsrState = (lfsrState shl 1 xor (lfsrState shr 7) * 0x71) % 256
// pow(2, i) - 1
val bitPosition = (1 shl i) - 1
if (lfsrState and 2 != 0) {
state[0][0] = state[0][0].xor(BigInteger.ONE shl bitPosition)
}
}
}
}
/**
* Converts the given [data] array to an [IntArray] containing UInt values.
*/
private fun convertToUInt(data: ByteArray) = IntArray(data.size) {
data[it].toInt() and 0xFF
}
/**
* Converts the given [data] array containing the little endian representation of a number to a [BigInteger].
*/
private fun convertFromLittleEndianTo64(data: IntArray): BigInteger {
val value = data.map { it.toString(16) }
.map { if (it.length == 2) it else "0$it" }
.reversed()
.joinToString("")
return BigInteger.parseString(value, 16)
}
/**
* Converts the given [BigInteger] to a little endian representation as an [IntArray].
*/
private fun convertFrom64ToLittleEndian(uLong: BigInteger): IntArray {
val asHex = uLong.toString(16)
val asHexPadded = "0".repeat((8 * 2) - asHex.length) + asHex
return IntArray(8) {
((7 - it) * 2).let { pos ->
asHexPadded.substring(pos, pos + 2).toInt(16)
}
}
}
private fun BigInteger.leftRotate64Safely(rotate: Int) = leftRotate64(rotate % 64)
private fun BigInteger.leftRotate64(rotate: Int) = (this shr (64 - rotate)).add(this shl rotate).mod(BIT_65)
}
@@ -1,22 +0,0 @@
@file:Suppress("unused")
package org.komputing.khash.keccak
/**
* Parameters defining the FIPS 202 standard.
*/
enum class KeccakParameter(val rateInBytes: Int,val outputLengthInBytes: Int, val d: Int) {
KECCAK_224(144, 28, 0x01),
KECCAK_256(136, 32, 0x01),
KECCAK_384(104, 48, 0x01),
KECCAK_512(72, 64, 0x01),
SHA3_224(144, 28, 0x06),
SHA3_256(136, 32, 0x06),
SHA3_384(104, 48, 0x06),
SHA3_512(72, 64, 0x06),
SHAKE128(168, 32, 0x1F),
SHAKE256(136, 64, 0x1F)
}
@@ -1,42 +0,0 @@
package org.komputing.khash.keccak.extensions
/**
* Assigns the specified int value to each element of the specified
* range in the specified array of ints. The range to be filled
* extends from index <tt>fromIndex</tt>, inclusive, to index
* <tt>toIndex</tt>, exclusive. (If <tt>fromIndex==toIndex</tt>, the
* range to be filled is empty.)
*
* @param fromIndex the index of the first element (inclusive) to be
* filled with the specified value
* @param toIndex the index of the last element (exclusive) to be
* filled with the specified value
* @param value the value to be stored in all elements of the array
* @throws IllegalArgumentException if <tt>fromIndex &gt; toIndex</tt>
* @throws ArrayIndexOutOfBoundsException if <tt>fromIndex &lt; 0</tt> or
* <tt>toIndex &gt; a.length</tt>
*/
internal fun IntArray.fillWith(value: Int, fromIndex: Int = 0, toIndex: Int = this.size) {
if (fromIndex > toIndex) {
throw IllegalArgumentException(
"fromIndex($fromIndex) > toIndex($toIndex)"
)
}
if (fromIndex < 0) {
throw ArrayIndexOutOfBoundsException(fromIndex)
}
if (toIndex > this.size) {
throw ArrayIndexOutOfBoundsException(toIndex)
}
for (i in fromIndex until toIndex)
this[i] = value
}
/**
* Constructs a new [ArrayIndexOutOfBoundsException]
* class with an argument indicating the illegal index.
* @param index the illegal index.
*/
internal class ArrayIndexOutOfBoundsException(index: Int) : Throwable("Array index out of range: $index")
@@ -1,19 +0,0 @@
@file:Suppress("unused")
package org.komputing.khash.keccak.extensions
import org.komputing.khash.keccak.Keccak
import org.komputing.khash.keccak.KeccakParameter
/**
* Computes the proper Keccak digest of [this] byte array based on the given [parameter]
*/
fun ByteArray.digestKeccak(parameter: KeccakParameter): ByteArray {
return Keccak.digest(this, parameter)
}
/**
* Computes the proper Keccak digest of [this] string based on the given [parameter]
*/
fun String.digestKeccak(parameter: KeccakParameter): ByteArray {
return Keccak.digest(encodeToByteArray(), parameter)
}
@@ -0,0 +1,190 @@
@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()
assertEquals(1, withTimeout(2000) { client.call(mutate, 1) })
runCurrent()
assertTrue(attacked)
assertEquals(1, executions, "The replay must never execute the application operation again")
assertTrue(serverJob.isActive)
assertEquals(2, client.call(mutate, 2))
runCurrent()
assertEquals(2, executions)
} 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,88 @@
@file:OptIn(ExperimentalUnsignedTypes::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto2.SigningSecretKey
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.*
import kotlin.test.*
import kotlin.time.Duration.Companion.seconds
class ConnectionInitializationTest {
private class PolicyRejected : RemoteInterface.SecurityException("Peer rejected by policy")
private class InitializationAborted : LocalInterface.BreakConnectionException("Initialization aborted")
@Test fun serverSecurityRejectionClosesConnection() = rejectsInitialization(true, PolicyRejected())
@Test fun clientSecurityRejectionClosesConnection() = rejectsInitialization(false, PolicyRejected())
@Test fun serverBreakSubclassClosesConnection() = rejectsInitialization(true, InitializationAborted())
@Test fun clientBreakSubclassClosesConnection() = rejectsInitialization(false, InitializationAborted())
private fun rejectsInitialization(rejectOnServer: Boolean, rejection: Throwable) = runTest(timeout = 10.seconds) {
initCrypto()
val (a, b) = createTestDevice()
val serverKey = SigningSecretKey.new()
val clientKey = SigningSecretKey.new()
val clientInterface = KiloInterface<Unit>()
val serverInterface = KiloInterface<Unit>()
var policyCalled = false
var identityMatched = false
var laterHandlerCalled = false
var clientConnected = false
(if (rejectOnServer) serverInterface else clientInterface).apply {
onConnected {
identityMatched = remoteIdentity == if (rejectOnServer) clientKey.verifyingKey else serverKey.verifyingKey
policyCalled = true
throw rejection
}
onConnected { laterHandlerCalled = true }
}
val server = KiloServerConnection(serverInterface, b, Unit, serverKey)
val client = KiloClientConnection(clientInterface, a, Unit, clientKey)
val serverRun = async { runCatching { server.run() } }
val clientRun = async { runCatching { client.run { if (it) clientConnected = true } } }
try {
withTimeout(2000) { serverRun.await(); clientRun.await() }
assertTrue(policyCalled, "Initialization policy must run")
assertTrue(identityMatched, "Policy must receive the verified remote identity")
assertFalse(laterHandlerCalled, "Rejection must stop subsequent initialization handlers")
assertFalse(clientConnected, "Rejected initialization must not announce a connected client")
if (!rejectOnServer) assertSame(rejection, clientRun.await().exceptionOrNull())
val echo by command<String, String>()
assertIs<RemoteInterface.ClosedException>(runCatching { client.call(echo, "after rejection") }.exceptionOrNull())
} finally {
serverRun.cancelAndJoin()
clientRun.cancelAndJoin()
}
}
@Test fun ordinaryHandlerFailuresStillAllowInitialization() = runTest(timeout = 10.seconds) {
initCrypto()
val (a, b) = createTestDevice()
val echo by command<String, String>()
var serverInitialized = false
var clientInitialized = false
val serverInterface = KiloInterface<Unit>().apply {
onConnected { throw IllegalStateException("Nonfatal handler failure") }
onConnected { serverInitialized = true }
on(echo) { it }
}
val clientInterface = KiloInterface<Unit>().apply {
onConnected { throw IllegalStateException("Nonfatal handler failure") }
onConnected { clientInitialized = true }
}
val ready = CompletableDeferred<Unit>()
val server = KiloServerConnection(serverInterface, b, Unit)
val client = KiloClientConnection(clientInterface, a, Unit)
val serverJob = launch { server.run() }
val clientJob = launch { client.run { if (it) ready.complete(Unit) } }
try {
ready.await()
assertTrue(serverInitialized)
assertTrue(clientInitialized)
assertEquals("ok", client.call(echo, "ok"))
} finally {
serverJob.cancelAndJoin()
clientJob.cancelAndJoin()
}
}
}
+27 -27
View File
@@ -1,8 +1,19 @@
import com.ionspin.kotlin.crypto.secretbox.SecretBox /*
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray * Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
import com.ionspin.kotlin.crypto.util.encodeToUByteArray *
* 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.
*/
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import net.sergeych.crypto.* import net.sergeych.crypto2.IllegalSignatureException
import net.sergeych.crypto2.SealedBox
import net.sergeych.crypto2.SigningSecretKey
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.encodeToUByteArray
import net.sergeych.utools.pack import net.sergeych.utools.pack
import net.sergeych.utools.unpack import net.sergeych.utools.unpack
import kotlin.test.* import kotlin.test.*
@@ -11,47 +22,36 @@ class KeysTest {
@Test @Test
fun testCreationAndMap() = runTest { fun testCreationAndMap() = runTest {
initCrypto() initCrypto()
val (stk,pbk) = Key.Signing.pair() val (stk,pbk) = SigningSecretKey.generatePair()
val x = mapOf( stk to "STK!", pbk to "PBK!") val x = mapOf( stk to "STK!", pbk to "PBK!")
assertEquals("STK!", x[stk]) assertEquals("STK!", x[stk])
val s1 = Key.Signing(stk.packed) val s1 = SigningSecretKey(stk.keyBytes)
assertEquals(stk, s1) assertEquals(stk, s1)
assertEquals("STK!", x[s1]) assertEquals("STK!", x[s1])
assertEquals("PBK!", x[pbk]) assertEquals("PBK!", x[pbk])
val data = "8 rays dev!".encodeToUByteArray() val data = "8 rays dev!".encodeToUByteArray()
val data1 = "8 rays dev!".encodeToUByteArray() val data1 = "8 rays dev!".encodeToUByteArray()
val s = SignedBox.Seal.create(stk, data) val s = stk.seal(data)
assertTrue(s.verify(data)) assertTrue(s.isValid(data))
data1[0] = 0x01u data1[0] = 0x01u
assertFalse(s.verify(data1)) assertFalse(s.isValid(data1))
val p2 = Key.Signing.pair() val p2 = SigningSecretKey.generatePair()
val p3 = Key.Signing.pair() val p3 = SigningSecretKey.generatePair()
val ms = SignedBox(data, s1) + p2.signing val ms = SealedBox(data, s1) + p2.secretKey
// non tampered: // non tampered:
val ms1 = unpack<SignedBox>(pack(ms)) val ms1 = unpack<SealedBox>(pack(ms))
assertContentEquals(data, ms1.message) assertContentEquals(data, ms1.message)
assertTrue(pbk in ms1) assertTrue(pbk in ms1)
assertTrue(p2.verifying in ms1) assertTrue(p2.publicKey in ms1)
assertTrue(p3.verifying !in ms1) assertTrue(p3.publicKey !in ms1)
assertThrows<IllegalSignatureException> { assertThrows<IllegalSignatureException> {
unpack<SignedBox>(pack(ms).also { it[3] = 1u }) unpack<SealedBox>(pack(ms).also { it[3] = 1u })
}
}
@Test
fun secretEncryptTest() = runTest {
initCrypto()
val key = SecretBox.keygen()
val key1 = SecretBox.keygen()
assertEquals("hello", decrypt(key, encrypt(key, "hello".encodeToUByteArray())).decodeFromUByteArray())
assertThrows<DecryptionFailedException> {
decrypt(key, encrypt(key1, "hello".encodeToUByteArray())).decodeFromUByteArray()
} }
} }
@@ -0,0 +1,114 @@
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
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
class OutgoingSecurityTest {
@Test fun replayedReplyLeavesWaiterForValidReply() = runTest(timeout = 10.seconds) {
initCrypto()
val echo by command<String, String>()
val (a, b) = createTestDevice()
var armed = false
var saved: UByteArray? = null
val adversary = object : Transport.Device by b {
override suspend fun send(block: UByteArray) {
val bytes = block
val decoded = unpack<Transport.Block>(bytes)
if (armed && decoded is Transport.Block.Response) {
saved?.let { b.send(pack<Transport.Block>(decoded.copy(packedResult = it))) }
saved = decoded.packedResult
}
b.send(bytes)
}
}
val ready = CompletableDeferred<Unit>()
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, adversary, Unit)
val client = KiloClientConnection(KiloInterface<Unit>(), a, Unit)
val sj = launch { server.run() }
val cj = launch { client.run { if (it) ready.complete(Unit) } }
try {
ready.await(); armed = true
assertEquals("first", client.call(echo, "first"))
assertEquals("second", client.call(echo, "second"))
assertTrue(sj.isActive); assertTrue(cj.isActive)
} finally { sj.cancelAndJoin(); cj.cancelAndJoin() }
}
@Test fun localReplySendFailureDoesNotForgePlaintextErrorOrClose() = runTest(timeout = 10.seconds) {
initCrypto()
val echo by command<Int, Int>()
val (a, b) = createTestDevice()
var fail = false
var plaintextErrors = 0
val device = object : Transport.Device by b {
override suspend fun send(block: UByteArray) {
val bytes = block
val decoded = unpack<Transport.Block>(bytes)
if (decoded is Transport.Block.Error) plaintextErrors++
if (fail && decoded is Transport.Block.Response) throw RemoteInterface.Exception("Local write failure")
b.send(bytes)
}
}
val ready = CompletableDeferred<Unit>()
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, device, Unit)
val client = KiloClientConnection(KiloInterface<Unit>(), a, Unit)
val sj = launch { server.run() }
val cj = launch { client.run { if (it) ready.complete(Unit) } }
try {
ready.await(); fail = true
assertNull(withTimeoutOrNull(100) { client.call(echo, 1) })
assertEquals(0, plaintextErrors)
fail = false
assertEquals(2, client.call(echo, 2))
} finally { sj.cancelAndJoin(); cj.cancelAndJoin() }
}
@Test fun allSecurePathsShareOrderedNonceAdmission() = runTest(timeout = 20.seconds) {
initCrypto()
val echo by command<Int, Int>()
val notify by command<Int, Unit>()
val (a, b) = createTestDevice()
fun observing(device: Transport.Device, nonces: MutableList<ULong>, skipHandshakeReply: Boolean = false) = object : Transport.Device by device {
override suspend fun send(block: UByteArray) {
val bytes = block
when (val decoded = unpack<Transport.Block>(bytes)) {
is Transport.Block.Call -> if (decoded.name != "L0Request")
nonces += unpack<KiloParams.Package>(unpack<UByteArray>(decoded.packedArgs)).nonce
is Transport.Block.Response -> if (!skipHandshakeReply || decoded.forId != 1u)
nonces += unpack<KiloParams.Package>(unpack<UByteArray>(decoded.packedResult)).nonce
else -> Unit
}
device.send(bytes)
}
}
val an = mutableListOf<ULong>(); val bn = mutableListOf<ULong>()
val api = KiloInterface<Unit>().apply { on(echo) { it }; on(notify) {} }
val ready = CompletableDeferred<Unit>()
val server = KiloServerConnection(api, observing(b, bn, true), Unit)
val client = KiloClientConnection(api, observing(a, an), Unit)
val sj = launch { server.run() }
val cj = launch { client.run { if (it) ready.complete(Unit) } }
try {
ready.await()
// Real JVM parallel preparation, requests in both directions, replies and pushes.
withContext(Dispatchers.Default) {
(0 until 32).map { n -> async {
val remote: RemoteInterface = if (n % 2 == 0) client else server
remote.push(notify, n)
assertEquals(n, remote.call(echo, n))
} }.awaitAll()
}
for (nonces in listOf(an, bn)) {
assertEquals(0uL, nonces.first())
assertTrue(nonces.zipWithNext().all { (x, y) -> y > x }, "Equal-priority admission must follow nonce allocation: $nonces")
assertTrue(nonces.size >= 49)
}
} finally { sj.cancelAndJoin(); cj.cancelAndJoin() }
}
}
+21 -2
View File
@@ -1,13 +1,26 @@
/*
* 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.
*/
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import net.sergeych.bipack.BipackEncoder
import net.sergeych.crypto.initCrypto import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.KiloParams
import net.sergeych.kiloparsec.Transport import net.sergeych.kiloparsec.Transport
import net.sergeych.utools.nowToSeconds import net.sergeych.utools.nowToSeconds
import net.sergeych.utools.pack import net.sergeych.utools.pack
import net.sergeych.utools.unpack import net.sergeych.utools.unpack
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.microseconds
import kotlin.time.Instant
class PackTest { class PackTest {
inline fun <reified T>check(x: T?) { inline fun <reified T>check(x: T?) {
@@ -40,4 +53,10 @@ class PackTest {
val b2 = unpack<Transport.Block>(p1) val b2 = unpack<Transport.Block>(p1)
assertEquals(b1,b2) assertEquals(b1,b2)
} }
@Test
fun packPackage() = runTest {
val x = BipackEncoder.encode(KiloParams.Package(1u, ubyteArrayOf()))
assertContentEquals(byteArrayOf(4, 0), x)
}
} }
+49
View File
@@ -0,0 +1,49 @@
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import net.sergeych.crypto2.initCrypto
import net.sergeych.kiloparsec.*
import net.sergeych.utools.unpack
import kotlin.test.*
class PriorityRpcTest {
@Test fun encryptedConcurrentRepliesAndErrorsInheritPriority() = runTest {
initCrypto()
val echo by command<Int, Int>()
val wait by command<Int, Int>()
val fail by command<Int, Int>()
val release = CompletableDeferred<Unit>()
val entered = CompletableDeferred<Unit>()
val ready = CompletableDeferred<Unit>()
val requests = mutableListOf<Transport.Block>()
val replies = mutableListOf<Transport.Block>()
val (a, b) = createTestDevice()
fun recording(device: Transport.Device, blocks: MutableList<Transport.Block>) = object : Transport.Device by device {
override suspend fun send(block: UByteArray) {
blocks += unpack<Transport.Block>(block)
device.send(block)
}
}
val server = KiloServerConnection(KiloInterface<Unit>().apply {
on(echo) { it }
on(wait) { entered.complete(Unit); release.await(); it }
on(fail) { throw RemoteInterface.RemoteException("test-error") }
}, recording(b, replies), Unit)
val client = KiloClientConnection(KiloInterface<Unit>(), recording(a, requests), Unit)
val jobs = listOf(launch { server.run() }, launch { client.run { if (it) ready.complete(Unit) } })
try {
ready.await()
val slow = async { client.call(wait, 1, priority = 7) }
entered.await()
assertEquals(2, client.call(echo, 2, priority = 123))
assertFalse(slow.isCompleted, "A pending reply must not prevent another concurrent call")
assertFailsWith<RemoteInterface.RemoteException> { client.call(fail, 3, priority = 81) }
release.complete(Unit); assertEquals(1, slow.await())
for (priority in listOf(7, 123, 81)) {
val request = requests.filterIsInstance<Transport.Block.Call>().single { it.priority == priority }
val response = replies.filterIsInstance<Transport.Block.Response>().single { it.forId == request.id }
assertEquals(priority, response.priority)
}
} finally { release.complete(Unit); jobs.forEach { it.cancelAndJoin() } }
}
}
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more