Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009d62a155 | ||
|
|
237d404bcd | ||
|
|
88ceeb2ae9 | ||
|
|
a1f1789d7b | ||
|
|
617f22dda1 | ||
|
|
0a52bb5c03 | ||
|
|
271cdd18e3 | ||
|
|
19b349143d | ||
|
|
7bebbf7bd7 | ||
|
|
2829bcfecd | ||
|
|
7cec803174 | ||
|
|
fe4cb2ca21 | ||
|
|
6dc246e68c | ||
|
|
bc5b5b3b3a | ||
|
|
bc46e11158 | ||
|
|
40f4352a31 | ||
|
|
cb696f5c0f | ||
|
|
fe96ac69d7 | ||
|
|
212f82dedd | ||
|
|
59b7310385 | ||
|
|
8837e49248 | ||
|
|
7e1f7ec4aa | ||
|
|
146878629e | ||
|
|
d7fb26c03b |
No files matched your search
@@ -45,3 +45,9 @@ out/
|
|||||||
.kotlin
|
.kotlin
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.gigaide/gigaide.properties
|
/.gigaide/gigaide.properties
|
||||||
|
local.properties
|
||||||
|
/kotlin-js-store/yarn.lock
|
||||||
|
|
||||||
|
# JVM crash diagnostics must never be published
|
||||||
|
*.hprof
|
||||||
|
hs_err_pid*.log
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+24
@@ -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>
|
||||||
|
|||||||
Generated
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<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_17" default="true" project-jdk-name="corretto-17" 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>
|
||||||
+106
@@ -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.
|
||||||
@@ -1,32 +1,36 @@
|
|||||||
# Kiloparsec
|
# Kiloparsec
|
||||||
|
|
||||||
__Recommended version is `0.4.1`: to keep the code compatible with current and further versions we
|
The Kotlin implementation and the native [TypeScript + WASM client](typescript/README.md)
|
||||||
ask to upgrade to `0.4.2` at least.__ Starting from this version some package names are changed for
|
are maintained here together. Current source version **0.8.0** uses
|
||||||
better clarity and fast UDP endpoints are added.
|
**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 "
|
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
|
block device" transport to the same local interface. Out if the box it
|
||||||
provides the following transports:
|
provides the following transports:
|
||||||
|
|
||||||
| name | JVM | JS | native |
|
| name | JVM | JS | wasmJS | native |
|
||||||
|-------------------|--------|----|--------|
|
|-------------------|:----:|:---:|:-------:|:------:|
|
||||||
| TCP/IP server | ✓ | | 0.2.6+ |
|
| TCP/IP server | ✓ | | | ✓ |
|
||||||
| TCP/IP client | ✓ | | 0.2.6+ |
|
| TCP/IP client | ✓ | | | ✓ |
|
||||||
| UDP server | 0.3.2+ | | 0.3.2+ |
|
| UDP server | ✓ | | | ✓ |
|
||||||
| UDP client | 0.3.2+ | | 0.3.2+ |
|
| UDP client | ✓ | | | ✓ |
|
||||||
| Websockets server | ✓ | | |
|
| Websockets server | ✓ | | | |
|
||||||
| Websockets client | ✓ | ✓ | ✓ |
|
| Websockets client | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
### Note on version compatibility
|
### Note on version compatibility
|
||||||
|
|
||||||
Version 0.5.1 could be backward incompatible due to upgrade of the crypto2.
|
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
|
||||||
Protocols >= 0.3.0 are not binary compatible with previous version due to more compact binary
|
must not be assumed compatible with current source. Application command schemas
|
||||||
format. The format from 0.3.0 onwards is supposed to keep compatible.
|
and the Kotlin interface/session programming model remain unchanged.
|
||||||
|
|
||||||
#### ID calculation algorithm is changed since 0.4.1
|
#### ID calculation algorithm is changed since 0.4.1
|
||||||
|
|
||||||
We recommend to upgrade to 0.4+ ASAP as public/shared key id derivation method was changed for even higher security.
|
Historical note: 0.4.1 changed public/shared key ID derivation. Current deployments
|
||||||
|
should target protocol 2; upgrading only to an older 0.4 release does not fix the
|
||||||
|
authenticated correlation issue.
|
||||||
|
|
||||||
### Supported native targets
|
### Supported native targets
|
||||||
|
|
||||||
@@ -41,10 +45,13 @@ We recommend to upgrade to 0.4+ ASAP as public/shared key id derivation method w
|
|||||||
|
|
||||||
## TCP/IP and UDP transports
|
## TCP/IP and UDP transports
|
||||||
|
|
||||||
These are the fastest based on async socket implementation of ktor client. They works everywhere but JS target as
|
These are the fastest based on async socket implementation of a ktor client. They work everywhere but JS target as
|
||||||
there is currently no widely adopted sockets for browser javascript.
|
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 serializes 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.
|
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
|
## Websockets server
|
||||||
|
|
||||||
@@ -61,7 +68,7 @@ It is slower than TCP or UDP, but it works on literally all platforms. See the s
|
|||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
The library should be used as maven dependency, not as source.
|
The library should be used as a maven dependency, not as source.
|
||||||
|
|
||||||
## Adding dependency
|
## Adding dependency
|
||||||
|
|
||||||
@@ -82,12 +89,12 @@ It could be, depending on your project structure, something like:
|
|||||||
```kotlin
|
```kotlin
|
||||||
val commonMain by getting {
|
val commonMain by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
api("net.sergeych:kiloparsec:0.4.1")
|
api("net.sergeych:kiloparsec:0.6.8")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create shared interface for your server and the client
|
## 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
|
It could be a multiplatform library that exports it or just a shared or copied source file declaring structures
|
||||||
and functions available, like:
|
and functions available, like:
|
||||||
@@ -138,10 +145,11 @@ assertEquals(FooArgs("bar", 117), client.call(cmdGetFoo))
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create ktor-based server
|
## Create a ktor-based server
|
||||||
|
|
||||||
Normally server side needs some session. It is convenient and avoid sending repeating data on each request speeding up
|
Normally the server side needs some session. It is convenient and avoids sending repeating data on each request speeding
|
||||||
the protocol. With KILOPARSEC, it is rather basic operation:
|
up
|
||||||
|
the protocol. With KILOPARSEC, it is a rather basic operation:
|
||||||
|
|
||||||
~~~kotlin
|
~~~kotlin
|
||||||
// Our session just keeps Foo for cmd{Get|Set}Foo:
|
// Our session just keeps Foo for cmd{Get|Set}Foo:
|
||||||
@@ -168,11 +176,11 @@ val ns: NettyApplicationEngine = embeddedServer(Netty, port = 8080, host = "0.0.
|
|||||||
|
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
## Create TCP/IP client and server
|
## 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
|
Using plain TCP/IP is even simpler, and it works way faster than websocket one, and is _the same
|
||||||
protected as `wss://` (and `ws://`) variant above due to same kiloparsec encryption in both cases. Still, a TCP/IP
|
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.
|
client is not available in JavaScript browser targets, and custom TCP ports could often be blocked by firewalls.
|
||||||
|
|
||||||
Documentation is available in samples here:
|
Documentation is available in samples here:
|
||||||
|
|
||||||
@@ -180,11 +188,13 @@ Documentation is available in samples here:
|
|||||||
|
|
||||||
- [TCP/IP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/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 implements asynchronous TCP/IP transport on all platforms buy JS:
|
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
|
- [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
|
- [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
|
## UDP client and server
|
||||||
|
|
||||||
@@ -198,35 +208,49 @@ Is very much straightforward, same as with TCP/IP:
|
|||||||
#### Command size
|
#### Command size
|
||||||
|
|
||||||
Each command invocation and result are packed in a separate UDP diagram using effective binary packing.
|
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.
|
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.
|
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.
|
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
|
#### Timeouts
|
||||||
|
|
||||||
As Datagrams do not form protocol itself, kiloparsec issues pings when no data is circulated between parties.
|
As Datagrams do not form protocol itself, kiloparsec issues pings when no data is circulated between parties.
|
||||||
When no pings are received long enough, kiloparsec connection is closed. There are `maxInactivityTimeout` in all
|
When no pings are received long enough, the kiloparsec connection is closed. There are `maxInactivityTimeout` in all
|
||||||
relevant functions and constructors.
|
relevant functions and constructors.
|
||||||
|
|
||||||
Client should not issue pings manually.
|
Client should not issue pings manually.
|
||||||
|
|
||||||
## Reusing code between servers
|
## 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.
|
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 proactive 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.
|
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
|
## Note on the server identification
|
||||||
|
|
||||||
We do not recommend to rely on TLS (HTTPS://, WSS://) host identification solely, in the modern world there is
|
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
|
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.
|
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.
|
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 to use CDN except your own, controlled ones. You generally can't neither detect nor repel [MITM attack] performed from _any single cloudflare 'ray'_.
|
__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:
|
## See also:
|
||||||
|
|
||||||
@@ -235,25 +259,25 @@ __Important note__. The web application could not be completely secured this way
|
|||||||
|
|
||||||
# Details
|
# Details
|
||||||
|
|
||||||
It is not compatible with parsec family and no more based on an Universa crypto library. To better fit
|
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
|
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,
|
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
|
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 a unique new keys. Also, we completely get rid of SHA2.
|
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
|
Kiloparsec also uses a denser binary format [bipack](https://gitea.sergeych.net/SergeychWorks/mp_bintools), no more
|
||||||
key-values,
|
key-values,
|
||||||
which reveals much less on the inner data structure, providing advanced
|
which reveals much less on the inner data structure, providing advanced
|
||||||
typed RPC interfaces with kotlinx.serialization. There is also Rust
|
typed RPC interfaces with kotlinx.serialization. There is also Rust
|
||||||
implementation [bipack_ru](https://gitea.sergeych.net/DiWAN/bipack_ru).
|
implementation [bipack_ru](https://gitea.sergeych.net/DiWAN/bipack_ru).
|
||||||
The architecture allows connecting same functional interfaces to several various type channels at once.
|
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.
|
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.
|
All RPC is performed over the encrypted connection.
|
||||||
|
|
||||||
# Technical description
|
# Technical description
|
||||||
|
|
||||||
Kiloparsec is a full-duplex fully async (coroutine based) Remote Procedure Call protocol with typed parameters
|
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
|
and support for serializing exceptions (e.g. exception thrown while executing remote command will be caught and
|
||||||
rethrown at the caller context).
|
rethrown at the caller context).
|
||||||
|
|
||||||
@@ -262,16 +286,77 @@ Integrated tools to prevent MITM attacks include also non-transferred independen
|
|||||||
independently on the ends and is never transferred with the network. Comparing it somehow (visually, with QR code, etc.)
|
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.
|
could add a very robust guarantee of the connection safety and ingenuity.
|
||||||
|
|
||||||
Kiloparsec has built-in completely asynchronous (coroutine based top-down) transport layer based on TCP (JVM only as for
|
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
|
now) and the same async Websocket-based transport based on KTOR. Websocket client is multiplatform, though the server is
|
||||||
JVM only insofar.
|
JVM only insofar.
|
||||||
|
|
||||||
# Licensing
|
# Licensing
|
||||||
|
|
||||||
This is work in progress, not yet moved to public domain;
|
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.
|
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 will be lifted. We do not support such practices here at 8-rays.dev.
|
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
|
[MITM]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack
|
||||||
|
|
||||||
[Sergey Chernov]: https://t.me/real_sergeych
|
[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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+2
-1
@@ -10,5 +10,6 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
./gradlew dokkaHtml
|
# 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
|
rsync -avz ./build/dokka/* code.sergeych.net:/bigstore/sergeych_pub/code/docs/kiloparsec
|
||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local-only verification; no publishing or deployment.
|
||||||
|
set -euo pipefail
|
||||||
|
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
"$repo_dir/gradlew" -p "$repo_dir" jvmTest jsNodeTest typescriptTest "$@"
|
||||||
|
npm --prefix "$repo_dir/typescript" run test:interop
|
||||||
+98
-56
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
* Copyright (c) 2025-2026. Sergey S. Chernov - All Rights Reserved
|
||||||
*
|
*
|
||||||
* You may use, distribute and modify this code under the
|
* You may use, distribute and modify this code under the
|
||||||
* terms of the private license, which you must obtain from the author
|
* terms of the private license, which you must obtain from the author
|
||||||
@@ -8,17 +8,21 @@
|
|||||||
* real dot sergeych at gmail.
|
* real dot sergeych at gmail.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform") version "2.1.0"
|
kotlin("multiplatform") version "2.2.21"
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
|
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 "1.9.20"
|
id("org.jetbrains.dokka") version "2.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "net.sergeych"
|
group = "net.sergeych"
|
||||||
version = "0.6.3"
|
version = "0.8.0"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
maven("https://maven.universablockchain.com/")
|
maven("https://maven.universablockchain.com/")
|
||||||
@@ -27,40 +31,56 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(21)
|
||||||
jvm()
|
jvm()
|
||||||
js {
|
js {
|
||||||
browser {
|
browser()
|
||||||
|
nodejs {
|
||||||
|
// Replay-window tests deliberately process more than 4096 messages.
|
||||||
|
testTask { useMocha { timeout = "30s" } }
|
||||||
}
|
}
|
||||||
nodejs()
|
|
||||||
}
|
}
|
||||||
// macosArm64()
|
|
||||||
// iosX64()
|
|
||||||
// iosArm64()
|
|
||||||
// iosSimulatorArm64()
|
|
||||||
linuxX64()
|
linuxX64()
|
||||||
linuxArm64()
|
linuxArm64()
|
||||||
// macosX64()
|
|
||||||
// macosX64()
|
|
||||||
mingwX64()
|
|
||||||
// @OptIn(ExperimentalWasmDsl::class)
|
|
||||||
// wasmJs()
|
|
||||||
|
|
||||||
val ktor_version = "3.1.0"
|
macosArm64()
|
||||||
|
macosX64()
|
||||||
|
iosX64()
|
||||||
|
iosArm64()
|
||||||
|
iosSimulatorArm64()
|
||||||
|
|
||||||
|
mingwX64()
|
||||||
|
|
||||||
|
androidTarget {
|
||||||
|
publishLibraryVariants("release")
|
||||||
|
}
|
||||||
|
@OptIn(ExperimentalWasmDsl::class)
|
||||||
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.10.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
api("io.ktor:ktor-client-core:$ktor_version")
|
api("io.ktor:ktor-client-core:$ktor_version")
|
||||||
api("net.sergeych:crypto2:0.7.4-SNAPSHOT")
|
api("net.sergeych:crypto2:0.9.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val androidMain by getting {
|
||||||
|
dependencies {
|
||||||
|
implementation("io.ktor:ktor-client-okhttp:$ktor_version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ktorSocketMain by creating {
|
val ktorSocketMain by creating {
|
||||||
@@ -69,11 +89,12 @@ kotlin {
|
|||||||
implementation("io.ktor:ktor-network:$ktor_version")
|
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.8.1")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ktorSocketTest by creating {
|
val ktorSocketTest by creating {
|
||||||
@@ -84,6 +105,7 @@ kotlin {
|
|||||||
implementation("io.ktor:ktor-server-core:$ktor_version")
|
implementation("io.ktor:ktor-server-core:$ktor_version")
|
||||||
implementation("io.ktor:ktor-server-core-jvm:$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-jvm:$ktor_version")
|
||||||
|
implementation("io.ktor:ktor-server-websockets:${ktor_version}")
|
||||||
implementation("io.ktor:ktor-server-netty:$ktor_version")
|
implementation("io.ktor:ktor-server-netty:$ktor_version")
|
||||||
api("io.ktor:ktor-client-cio:$ktor_version")
|
api("io.ktor:ktor-client-cio:$ktor_version")
|
||||||
}
|
}
|
||||||
@@ -92,34 +114,33 @@ kotlin {
|
|||||||
val jvmTest by getting {
|
val jvmTest by getting {
|
||||||
dependsOn(ktorSocketTest)
|
dependsOn(ktorSocketTest)
|
||||||
}
|
}
|
||||||
|
|
||||||
val jsMain by getting {
|
val jsMain by getting {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("io.ktor:ktor-client-js:$ktor_version")
|
implementation("io.ktor:ktor-client-js:$ktor_version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val jsTest by getting
|
val jsTest by getting
|
||||||
// val macosArm64Main by getting {
|
val macosArm64Main by getting {
|
||||||
// dependsOn(ktorSocketMain)
|
dependsOn(ktorSocketMain)
|
||||||
// }
|
}
|
||||||
// val macosArm64Test by getting {
|
val macosArm64Test by getting {
|
||||||
// dependsOn(ktorSocketTest)
|
dependsOn(ktorSocketTest)
|
||||||
// }
|
}
|
||||||
// val macosX64Main by getting {
|
val macosX64Main by getting {
|
||||||
// dependsOn(ktorSocketMain)
|
dependsOn(ktorSocketMain)
|
||||||
// }
|
}
|
||||||
// val iosX64Main by getting {
|
val iosX64Main by getting {
|
||||||
// dependsOn(ktorSocketMain)
|
dependsOn(ktorSocketMain)
|
||||||
// }
|
}
|
||||||
// val iosX64Test by getting {
|
val iosX64Test by getting {
|
||||||
// dependsOn(ktorSocketTest)
|
dependsOn(ktorSocketTest)
|
||||||
// }
|
}
|
||||||
// val iosArm64Main by getting {
|
val iosArm64Main by getting {
|
||||||
// dependsOn(ktorSocketMain)
|
dependsOn(ktorSocketMain)
|
||||||
// }
|
}
|
||||||
// val iosArm64Test by getting {
|
val iosArm64Test by getting {
|
||||||
// dependsOn(ktorSocketTest)
|
dependsOn(ktorSocketTest)
|
||||||
// }
|
}
|
||||||
val linuxArm64Main by getting {
|
val linuxArm64Main by getting {
|
||||||
dependsOn(ktorSocketMain)
|
dependsOn(ktorSocketMain)
|
||||||
}
|
}
|
||||||
@@ -133,12 +154,14 @@ kotlin {
|
|||||||
dependsOn(ktorSocketTest)
|
dependsOn(ktorSocketTest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
publishing {
|
publishing {
|
||||||
val mavenToken by lazy {
|
val mavenToken by lazy {
|
||||||
File("${System.getProperty("user.home")}/.gitea_token").readText()
|
File("${System.getProperty("user.home")}/.gitea_token").readText()
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
|
if (!providers.gradleProperty("localPublishingOnly").map(String::toBoolean).getOrElse(false)) {
|
||||||
maven {
|
maven {
|
||||||
credentials(HttpHeaderCredentials::class) {
|
credentials(HttpHeaderCredentials::class) {
|
||||||
name = "Authorization"
|
name = "Authorization"
|
||||||
@@ -153,12 +176,31 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.dokkaHtml.configure {
|
|
||||||
outputDirectory.set(buildDir.resolve("dokka"))
|
dokka {
|
||||||
dokkaSourceSets {
|
dokkaPublications.html {
|
||||||
configureEach {
|
outputDirectory.set(layout.buildDirectory.dir("dokka"))
|
||||||
// includes.from("docs/bipack.md")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -10,3 +10,11 @@
|
|||||||
|
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
kotlin.mpp.applyDefaultHierarchyTemplate=false
|
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
|
||||||
|
|
||||||
+1
-1
@@ -10,6 +10,6 @@
|
|||||||
|
|
||||||
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
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||||
*
|
*
|
||||||
* You may use, distribute and modify this code under the
|
* You may use, distribute and modify this code under the
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -15,12 +15,11 @@ 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.Call
|
|
||||||
import net.sergeych.kiloparsec.Command.Companion.unpackCall
|
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.
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -13,6 +13,7 @@ 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.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
|
||||||
@@ -24,14 +25,13 @@ 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],
|
* [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 unnecessary, otherwise it will continue to reconnect.
|
* it manually when it is unnecessary, otherwise it will continue to reconnect.
|
||||||
*
|
*
|
||||||
* ## Usage
|
* ## Usage
|
||||||
@@ -59,9 +59,18 @@ import net.sergeych.mp_tools.globalLaunch
|
|||||||
class KiloClient<S>(
|
class KiloClient<S>(
|
||||||
val localInterface: KiloInterface<S>,
|
val localInterface: KiloInterface<S>,
|
||||||
secretKey: SigningKey? = 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)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,47 +90,66 @@ class KiloClient<S>(
|
|||||||
|
|
||||||
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)
|
||||||
_state.value = it
|
}
|
||||||
|
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
|
||||||
|
resetDeferredClient()
|
||||||
}
|
}
|
||||||
resetDeferredClient()
|
// Short authenticated-but-dead sessions must still exhaust the budget.
|
||||||
debug { "client run finished" }
|
val stableMillis = options.health?.let { 3 * (it.intervalMillis + it.timeoutMillis) } ?: 30_000L
|
||||||
} catch (_: RemoteInterface.ClosedException) {
|
if ((connectedAt?.elapsedNow()?.inWholeMilliseconds ?: 0) > stableMillis) attempts = 0
|
||||||
debug { "remote closed" }
|
if (options.maxAttempts == null || attempts < options.maxAttempts) delay(options.retryDelayMillis)
|
||||||
delay(1000)
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
debug { "cancelled" }
|
|
||||||
break
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
exception { "unexpected exception" to t }
|
|
||||||
delay(1000)
|
|
||||||
}
|
}
|
||||||
|
exhausted.value = true
|
||||||
|
} finally {
|
||||||
_state.value = false
|
_state.value = false
|
||||||
resetDeferredClient()
|
deferredClient.completeExceptionally(RemoteInterface.ClosedException())
|
||||||
// reconnection timeout
|
|
||||||
delay(100)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
|
stopped.value = true
|
||||||
job.cancel()
|
job.cancel()
|
||||||
debug { "client is closed" }
|
debug { "client is closed" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val defMutex = Mutex()
|
|
||||||
private suspend fun resetDeferredClient() {
|
private suspend fun resetDeferredClient() {
|
||||||
defMutex.withLock {
|
defMutex.withLock {
|
||||||
if (!deferredClient.isActive) {
|
if (!deferredClient.isActive && !exhausted.value && !stopped.value) {
|
||||||
deferredClient = CompletableDeferred()
|
deferredClient = CompletableDeferred()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +173,9 @@ class KiloClient<S>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
* it could be used as a nonce to pair MITM and like attacks, be sure that the server is actually
|
* it could be used as a nonce to pair MITM and like attacks, be sure that the server is actually
|
||||||
@@ -172,6 +203,8 @@ class KiloClient<S>(
|
|||||||
}
|
}
|
||||||
private var connectionBuilder: (suspend () -> Transport.Device)? = null
|
private var connectionBuilder: (suspend () -> Transport.Device)? = null
|
||||||
|
|
||||||
|
var reconnectionOptions: ReconnectionOptions = ReconnectionOptions()
|
||||||
|
|
||||||
var secretIdKey: SigningKey? = null
|
var secretIdKey: SigningKey? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -226,7 +259,7 @@ class KiloClient<S>(
|
|||||||
for (ep in errorProviders) i.addErrorProvider(ep)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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
|
||||||
|
|
||||||
@@ -43,70 +44,98 @@ class KiloClientConnection<S>(
|
|||||||
* 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 { SafeKeyExchange() }
|
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))
|
||||||
|
|
||||||
|
debug { "got server HE (2)" }
|
||||||
|
if (serverHe.version != KILO_PROTOCOL_VERSION)
|
||||||
|
throw RemoteInterface.SecurityException("unsupported Kiloparsec protocol")
|
||||||
val sk = pair.clientSessionKey(serverHe.publicKey)
|
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.signature?.let { s ->
|
serverHe.signature?.let { s ->
|
||||||
if (!s.isValid(params.token))
|
if (!s.isValid(serverAuthentication(params.token)))
|
||||||
throw RemoteInterface.SecurityException("wrong signature")
|
throw RemoteInterface.SecurityException("wrong signature")
|
||||||
params = params.copy(remoteIdentity = s.publicKey)
|
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?.verifyingKey,
|
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)
|
||||||
)
|
)
|
||||||
clientInterface.onConnectHandlers.invokeAll(params.scope)
|
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) {
|
||||||
x.printStackTrace()
|
debug { "connection closed/refused by remote" }
|
||||||
info { "connection closed 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)
|
||||||
@@ -117,4 +146,13 @@ class KiloClientConnection<S>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun <S>Collection<KiloHandler<S>>.invokeAll(scope: KiloScope<S>) =
|
internal fun <S>Collection<KiloHandler<S>>.invokeAll(scope: KiloScope<S>) =
|
||||||
forEach { runCatching { scope.it() } }
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,16 +19,17 @@ typealias KiloHandler<S> = KiloScope<S>.()->Unit
|
|||||||
*
|
*
|
||||||
* - It registers common exceptions from [RemoteInterface] and kotlin/java `IllegalArgumentException` and
|
* - It registers common exceptions from [RemoteInterface] and kotlin/java `IllegalArgumentException` and
|
||||||
* `IllegalStateException`
|
* `IllegalStateException`
|
||||||
* - It provides [onConnected] handler
|
* - It provides [onConnected] and [onDisconnected] handlers
|
||||||
*
|
*
|
||||||
* See [KiloServer] for usage sample.
|
* See [KiloServer] for usage sample.
|
||||||
*/
|
*/
|
||||||
open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
||||||
|
|
||||||
internal val onConnectHandlers = mutableListOf<KiloHandler<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 handler list.
|
* 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
|
* @param addFirst if true, [f] will be added to the beginning of the list of handlers
|
||||||
*/
|
*/
|
||||||
@@ -36,6 +37,18 @@ open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
|||||||
if( addFirst ) onConnectHandlers.add(0, f) else onConnectHandlers += f
|
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(it) }
|
registerError { RemoteInterface.UnknownCommand(it) }
|
||||||
registerError { RemoteInterface.InternalError(it) }
|
registerError { RemoteInterface.InternalError(it) }
|
||||||
@@ -47,4 +60,3 @@ open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
|||||||
registerError { IllegalArgumentException(it) }
|
registerError { IllegalArgumentException(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,55 +1,52 @@
|
|||||||
/*
|
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||||
* 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.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import net.sergeych.tools.AtomicCounter
|
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)
|
private val idCounter = AtomicCounter(0)
|
||||||
|
|
||||||
/**
|
/** Authenticates dispatch metadata before looking up or deserializing an application command. */
|
||||||
* This class is not normally used directly. This is a local interface that supports
|
|
||||||
* secure transport command layer (encrypted calls/results) to work with [KiloRemoteInterface].
|
|
||||||
*
|
|
||||||
* It is recommended to use [KiloClientConnection] and [KiloServerConnection] instead.
|
|
||||||
*/
|
|
||||||
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>>,
|
||||||
|
private val allowHandshakeCallbacks: Boolean = false,
|
||||||
) : LocalInterface<Unit>() {
|
) : LocalInterface<Unit>() {
|
||||||
|
|
||||||
override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
|
override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
|
||||||
|
init { addErrorProvider(clientInterface) }
|
||||||
|
// Plaintext transport errors must never use application exception constructors.
|
||||||
|
override fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? = null
|
||||||
|
|
||||||
init {
|
override suspend fun executeTransportBlock(scope: Unit, block: Transport.Block.Call): PreparedPayload {
|
||||||
// local interface uses the same session as a client:
|
if (block.name != L0Call.name) return super.executeTransportBlock(scope, block)
|
||||||
addErrorProvider(clientInterface)
|
if (!allowHandshakeCallbacks && !deferredParams.isCompleted)
|
||||||
|
throw RemoteInterface.SecurityException("application call before authentication")
|
||||||
on(L0Call) {
|
val params = deferredParams.await()
|
||||||
val params = deferredParams.await()
|
val frame: ProtectedMessage = try { params.decryptMessage(unpack(block.packedArgs)) }
|
||||||
val call = Command.unpackCall(params.decrypt(it))
|
catch (e: DiscardPacket) { throw e }
|
||||||
// tricky part: we need to encrypt a result or error: we can't allow default
|
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid authenticated call") }
|
||||||
// error transport (as it is not encrypted), so we re-use transport blocks here:
|
val expected = if (block.id == 0u) ProtectedKind.PUSH else ProtectedKind.CALL
|
||||||
val result: Transport.Block = try {
|
if (frame.kind != expected || frame.correlation != block.id.toULong() || frame.priority != block.priority)
|
||||||
Transport.Block.Response(
|
throw RemoteInterface.SecurityException("authenticated routing mismatch")
|
||||||
0u,
|
val call = try { Command.unpackCall(frame.payload) }
|
||||||
clientInterface.execute(params.scope, call.name, call.serializedArgs)
|
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid command encoding") }
|
||||||
)
|
val result: Transport.Block = try {
|
||||||
} catch(t: BreakConnectionException) {
|
Transport.Block.Response(0u,
|
||||||
throw t
|
if (call.name == connectionPing.name) connectionPing.exec(call.serializedArgs) { it }
|
||||||
}
|
else clientInterface.execute(params.scope, call.name, call.serializedArgs))
|
||||||
catch (t: Throwable) {
|
} catch (e: BreakConnectionException) { throw e }
|
||||||
clientInterface.encodeError(0u, t)
|
catch (e: CancellationException) { throw e }
|
||||||
}
|
catch (e: Throwable) { clientInterface.encodeError(0u, e) }
|
||||||
params.encrypt(pack(result))
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,7 @@ data class KiloParams<S>(
|
|||||||
)
|
)
|
||||||
|
|
||||||
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> {
|
||||||
@@ -66,24 +67,48 @@ data class KiloParams<S>(
|
|||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|
||||||
|
internal fun encode(message: UByteArray, fillFactor: Float = 0f): OutgoingData {
|
||||||
val fill = if (fillFactor > 0f)
|
val fill = if (fillFactor > 0f)
|
||||||
0..(message.size * fillFactor).roundToInt()
|
0..(message.size * fillFactor).roundToInt()
|
||||||
else
|
else
|
||||||
null
|
null
|
||||||
|
|
||||||
val n = protectedOp.invoke { nonce++ }
|
val n = protectedOp.invoke {
|
||||||
|
if (nonce == ULong.MAX_VALUE) throw RemoteInterface.SecurityException("session nonce exhausted")
|
||||||
return pack(
|
nonce++
|
||||||
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray()
|
|
||||||
fun decrypt(encryptedMessage: UByteArray): UByteArray =
|
|
||||||
protectDecryption {
|
|
||||||
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
|
|
||||||
sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return OutgoingData(pack(
|
||||||
|
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
|
||||||
|
), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authenticate and consume one incoming nonce, allowing bounded out-of-order delivery. */
|
||||||
|
fun decrypt(encryptedMessage: UByteArray): UByteArray {
|
||||||
|
val p = protectDecryption { BipackDecoder.decode<Package>(encryptedMessage.toDataSource()) }
|
||||||
|
return protectedOp.invoke {
|
||||||
|
if (received.containsOrStale(p.nonce)) throw DiscardPacket()
|
||||||
|
val clear = protectDecryption {
|
||||||
|
sessionKey.decryptWithNonce(p.encryptedMessage, numericNonce.withULong(p.nonce))
|
||||||
|
}
|
||||||
|
received.record(p.nonce)
|
||||||
|
clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity completion must not reset counters/replay history already used by the handshake. */
|
||||||
|
internal fun authenticated(identity: VerifyingPublicKey): KiloParams<S> = protectedOp.invoke {
|
||||||
|
copy(remoteIdentity = identity).also {
|
||||||
|
it.nonce = nonce
|
||||||
|
it.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,54 +1,63 @@
|
|||||||
/*
|
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||||
* 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.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. */
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a remote interface for interacting with a Kiloparsec service.
|
|
||||||
*
|
|
||||||
* @param S the scope type of the Kiloparsec service which will be used to call local functions remotely
|
|
||||||
* @property deferredParams a [CompletableDeferred] that resolves to the parameters required for making remote calls
|
|
||||||
* @property clientInterface a [LocalInterface] used to communicate with the local client
|
|
||||||
*/
|
|
||||||
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) {
|
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||||
val params = deferredParams.await()
|
val params = deferredParams.await()
|
||||||
params.transport.call(L0Call, params.encrypt(cmd.packCall(args)))
|
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,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
* Copyright (c) 2025-2026. Sergey S. Chernov - All Rights Reserved
|
||||||
*
|
*
|
||||||
* You may use, distribute and modify this code under the
|
* You may use, distribute and modify this code under the
|
||||||
* terms of the private license, which you must obtain from the author
|
* terms of the private license, which you must obtain from the author
|
||||||
@@ -14,7 +14,6 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.sergeych.crypto2.SigningKey
|
import net.sergeych.crypto2.SigningKey
|
||||||
import net.sergeych.kiloparsec.adapter.InetTransportDevice
|
|
||||||
import net.sergeych.mp_logger.LogTag
|
import net.sergeych.mp_logger.LogTag
|
||||||
import net.sergeych.mp_logger.debug
|
import net.sergeych.mp_logger.debug
|
||||||
import net.sergeych.mp_logger.exception
|
import net.sergeych.mp_logger.exception
|
||||||
@@ -113,7 +112,7 @@ private val instances = AtomicCounter()
|
|||||||
*/
|
*/
|
||||||
class KiloServer<S>(
|
class KiloServer<S>(
|
||||||
private val clientInterface: KiloInterface<S>,
|
private val clientInterface: KiloInterface<S>,
|
||||||
private val connections: Flow<InetTransportDevice>,
|
private val connections: Flow<Transport.Device>,
|
||||||
private val serverSecretKey: SigningKey? = null,
|
private val serverSecretKey: SigningKey? = null,
|
||||||
private val sessionBuilder: () -> S,
|
private val sessionBuilder: () -> S,
|
||||||
) : LogTag("KS:${instances.incrementAndGet()}") {
|
) : LogTag("KS:${instances.incrementAndGet()}") {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
package net.sergeych.kiloparsec
|
package net.sergeych.kiloparsec
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import net.sergeych.crypto2.SafeKeyExchange
|
import net.sergeych.crypto2.SafeKeyExchange
|
||||||
import net.sergeych.crypto2.SigningKey
|
import net.sergeych.crypto2.SigningKey
|
||||||
import net.sergeych.mp_logger.LogTag
|
import net.sergeych.mp_logger.LogTag
|
||||||
@@ -53,53 +55,60 @@ 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 = pair.serverSessionKey(it.publicKey)
|
var established = false
|
||||||
|
val pair = SafeKeyExchange()
|
||||||
|
on(L0Request) { request -> handshakeLock.withLock {
|
||||||
|
if (request.version != KILO_PROTOCOL_VERSION || params != null || established)
|
||||||
|
throw RemoteInterface.SecurityException("unsupported or repeated handshake")
|
||||||
|
val sk = pair.serverSessionKey(request.publicKey)
|
||||||
|
params = KiloParams(true, deferredTransport.await(), sk, session, null, this@KiloServerConnection)
|
||||||
|
Handshake(KILO_PROTOCOL_VERSION, pair.publicKey,
|
||||||
|
serverSigningKey?.seal(serverAuthentication(params!!.token)))
|
||||||
|
} }
|
||||||
|
|
||||||
params = KiloParams(
|
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) {
|
||||||
Handshake(1u, pair.publicKey, serverSigningKey?.seal(params!!.token))
|
if (!ci.clientIdKey.verify(ci.signature!!, clientAuthentication(p.token)))
|
||||||
}
|
|
||||||
|
|
||||||
on(L0ClientId) {
|
|
||||||
var p = params ?: throw RemoteInterface.ClosedException("wrong handshake sequence")
|
|
||||||
val ci = unpack<ClientIdentity>(p.decrypt(it))
|
|
||||||
if( ci.clientIdKey != null ) {
|
|
||||||
if( ci.signature == null )
|
|
||||||
throw RemoteInterface.SecurityException("missing signature")
|
|
||||||
if( !ci.clientIdKey.verify(ci.signature, params!!.token))
|
|
||||||
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)
|
||||||
clientInterface.onConnectHandlers.invokeAll(p.scope)
|
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 { "starting 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 = SafeKeyExchange()
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import net.sergeych.mp_logger.exception
|
|||||||
import net.sergeych.mp_logger.info
|
import net.sergeych.mp_logger.info
|
||||||
import net.sergeych.tools.AtomicCounter
|
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
|
||||||
@@ -72,7 +73,8 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
@@ -81,6 +83,20 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
|
|||||||
.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>()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -26,14 +26,13 @@ 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.bipack.Unsigned
|
import net.sergeych.bipack.Unsigned
|
||||||
import net.sergeych.crypto2.toDump
|
import net.sergeych.kiloparsec.adapter.PriorityBlockWriter
|
||||||
import net.sergeych.kiloparsec.Transport.Device
|
|
||||||
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)
|
||||||
*/
|
*/
|
||||||
@@ -69,10 +68,18 @@ class Transport<S>(
|
|||||||
suspend fun close()
|
suspend fun close()
|
||||||
|
|
||||||
suspend fun flush() {}
|
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(
|
data class Call(
|
||||||
@Unsigned
|
@Unsigned
|
||||||
@@ -108,6 +115,9 @@ class Transport<S>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>>()
|
||||||
@@ -119,34 +129,44 @@ class Transport<S>(
|
|||||||
* @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()
|
||||||
// the order is important: first id in use MUST BE >= 1, not zero:
|
// the order is important: first id in use MUST BE >= 1, not zero:
|
||||||
b = Block.Call(++lastId, name, packedArgs)
|
if (lastId == UInt.MAX_VALUE) throw RemoteInterface.ClosedException("RPC ID space exhausted")
|
||||||
calls[b.id] = deferred
|
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 = runCatching { device.output.send(pack(b)) }
|
try {
|
||||||
if (!r.isSuccess) {
|
sendPrepared {
|
||||||
r.exceptionOrNull()?.let {
|
val payload = packedArgs(id)
|
||||||
exception { "failed to send output block" to it }
|
OutgoingData(pack<Block>(Block.Call(id, name, payload.bytes).also { it.priority = priority }), payload.nonce)
|
||||||
} ?: run {
|
|
||||||
error { "It should not happen: empty exception on block send failure" }
|
|
||||||
throw RuntimeException("unexpected failure in sending block")
|
|
||||||
}
|
}
|
||||||
deferred.completeExceptionally(RemoteInterface.ClosedException())
|
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,15 +179,52 @@ class Transport<S>(
|
|||||||
if (isClosed) throw RemoteInterface.ClosedException()
|
if (isClosed) throw RemoteInterface.ClosedException()
|
||||||
|
|
||||||
// All push blocks have the same id == 0:
|
// All push blocks have the same id == 0:
|
||||||
val b = Block.Call(0u, name, packedArgs)
|
val b = Block.Call(0u, name, packedArgs).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
|
||||||
val r = runCatching { device.output.send(pack(b).also { debug { ">>$\n${it.toDump()}" } }) }
|
val r = runCatching { send(b) }
|
||||||
when(val e = r.exceptionOrNull()) {
|
when(val e = r.exceptionOrNull()) {
|
||||||
|
null -> return
|
||||||
is RemoteInterface.ClosedException, is CancellationException, is RemoteInterface.RemoteException
|
is RemoteInterface.ClosedException, is CancellationException, is RemoteInterface.RemoteException
|
||||||
-> throw e
|
-> throw e
|
||||||
else -> throw RemoteInterface.ClosedException()
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call the remote procedure with specified args and return its result
|
* Call the remote procedure with specified args and return its result
|
||||||
*/
|
*/
|
||||||
@@ -189,6 +246,10 @@ 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
|
// todo: rewrite it to close the job with no exceptions at all, always
|
||||||
while (isActive && !isClosed) {
|
while (isActive && !isClosed) {
|
||||||
@@ -197,40 +258,63 @@ class Transport<S>(
|
|||||||
val b = unpack<Block>(packed)
|
val b = unpack<Block>(packed)
|
||||||
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)?.complete(b.packedResult)
|
val pending = calls[b.forId]
|
||||||
?: warning { "wait handle not found for ${b.forId}" }
|
if (pending != null) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Block.Call -> launch {
|
is Block.Call -> launch {
|
||||||
try {
|
try {
|
||||||
if (b.id == 0u)
|
val result = localInterface.executeTransportBlock(commandContext, b)
|
||||||
// Command does not waits return
|
if (b.id != 0u) {
|
||||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
try {
|
||||||
else
|
sendPrepared {
|
||||||
send(
|
val encoded = result.encode()
|
||||||
Block.Response(
|
OutgoingData(pack<Block>(Block.Response(b.id, encoded.bytes).also { it.priority = b.priority }), encoded.nonce)
|
||||||
b.id,
|
}
|
||||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
} catch (failure: CancellationException) { throw failure }
|
||||||
)
|
catch (failure: LocalInterface.BreakConnectionException) { throw failure }
|
||||||
)
|
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) {
|
} catch (x: LocalInterface.BreakConnectionException) {
|
||||||
// handler forced close
|
// handler forced close
|
||||||
warning { "handler requested closing of the connection (${x.flushSendQueue}" }
|
warning { "handler requested closing of the connection (${x.flushSendQueue}" }
|
||||||
isClosed = true
|
isClosed = true
|
||||||
if (x.flushSendQueue) device.flush()
|
if (x.flushSendQueue) { writer.flush(); device.flush() }
|
||||||
device.close()
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +329,7 @@ class Transport<S>(
|
|||||||
} catch (cce: LocalInterface.BreakConnectionException) {
|
} catch (cce: LocalInterface.BreakConnectionException) {
|
||||||
info { "closing connection by local request ($cce)" }
|
info { "closing connection by local request ($cce)" }
|
||||||
device.close()
|
device.close()
|
||||||
} catch (t: RemoteInterface.ClosedException) {
|
} catch (_: RemoteInterface.ClosedException) {
|
||||||
// it is ok: we just exit the coroutine normally
|
// it is ok: we just exit the coroutine normally
|
||||||
// and mark we're closing
|
// and mark we're closing
|
||||||
isClosed = true
|
isClosed = true
|
||||||
@@ -267,16 +351,20 @@ class Transport<S>(
|
|||||||
for (c in calls.values)
|
for (c in calls.values)
|
||||||
c.completeExceptionally(RemoteInterface.ClosedException())
|
c.completeExceptionally(RemoteInterface.ClosedException())
|
||||||
calls.clear()
|
calls.clear()
|
||||||
|
responseReaders.clear()
|
||||||
|
writer.close()
|
||||||
debug { "calls clear has been called" }
|
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) {
|
||||||
try {
|
try {
|
||||||
device.output.send(pack(block))
|
sendPrepared { OutgoingData(pack(block)) }
|
||||||
} catch (_: ClosedSendChannelException) {
|
} catch (_: ClosedSendChannelException) {
|
||||||
throw RemoteInterface.ClosedException()
|
throw RemoteInterface.ClosedException()
|
||||||
}
|
}
|
||||||
@@ -304,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,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() }
|
||||||
|
}
|
||||||
@@ -25,16 +25,26 @@ open class ProxyDevice(
|
|||||||
private val doFlush: (suspend ()->Unit)? = null,
|
private val doFlush: (suspend ()->Unit)? = null,
|
||||||
): Transport.Device {
|
): 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 input: ReceiveChannel<UByteArray> = inputChannel
|
||||||
override val output: SendChannel<UByteArray> = outputChannel
|
override val output: SendChannel<UByteArray> = outputChannel
|
||||||
|
|
||||||
override suspend fun close() {
|
override suspend fun close() {
|
||||||
|
priorityWriter?.close()
|
||||||
kotlin.runCatching { doClose?.invoke() }
|
kotlin.runCatching { doClose?.invoke() }
|
||||||
runCatching { inputChannel.close() }
|
runCatching { inputChannel.close() }
|
||||||
runCatching { outputChannel.close() }
|
runCatching { outputChannel.close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun flush() {
|
override suspend fun flush() {
|
||||||
|
priorityWriter?.flush()
|
||||||
doFlush?.invoke()
|
doFlush?.invoke()
|
||||||
var cnt = 10
|
var cnt = 10
|
||||||
while(!outputChannel.isEmpty) {
|
while(!outputChannel.isEmpty) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
* Copyright (c) 2025-2026. Sergey S. Chernov - All Rights Reserved
|
||||||
*
|
*
|
||||||
* You may use, distribute and modify this code under the
|
* You may use, distribute and modify this code under the
|
||||||
* terms of the private license, which you must obtain from the author
|
* terms of the private license, which you must obtain from the author
|
||||||
@@ -20,12 +20,12 @@ import kotlinx.coroutines.channels.Channel
|
|||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.io.IOException
|
||||||
import net.sergeych.crypto2.SigningKey
|
import net.sergeych.crypto2.SigningKey
|
||||||
import net.sergeych.kiloparsec.*
|
import net.sergeych.kiloparsec.*
|
||||||
import net.sergeych.mp_logger.LogTag
|
import net.sergeych.mp_logger.*
|
||||||
import net.sergeych.mp_logger.exception
|
import net.sergeych.mp_tools.decodeBase64Compact
|
||||||
import net.sergeych.mp_logger.info
|
import net.sergeych.mp_tools.encodeToBase64Compact
|
||||||
import net.sergeych.mp_logger.warning
|
|
||||||
import net.sergeych.mp_tools.globalLaunch
|
import net.sergeych.mp_tools.globalLaunch
|
||||||
import net.sergeych.tools.AtomicCounter
|
import net.sergeych.tools.AtomicCounter
|
||||||
|
|
||||||
@@ -39,98 +39,136 @@ fun <S> websocketClient(
|
|||||||
path: String,
|
path: String,
|
||||||
clientInterface: KiloInterface<S> = KiloInterface(),
|
clientInterface: KiloInterface<S> = KiloInterface(),
|
||||||
secretKey: SigningKey? = null,
|
secretKey: SigningKey? = null,
|
||||||
|
useTextFrames: Boolean = false,
|
||||||
sessionMaker: () -> S = {
|
sessionMaker: () -> S = {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
Unit as S
|
Unit as S
|
||||||
},
|
},
|
||||||
): KiloClient<S> {
|
): KiloClient<S> {
|
||||||
return KiloClient(clientInterface, secretKey) {
|
return KiloClient(clientInterface, secretKey) {
|
||||||
KiloConnectionData(websocketTransportDevice(path), sessionMaker())
|
KiloConnectionData(websocketTransportDevice(path, useTextFrames), sessionMaker())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create kilopaarsec transport over websocket (ws or wss).
|
* Create kilopaarsec transport over websocket (ws or wss).
|
||||||
* @param path websocket path (must start with ws:// or wss:// and contain a path part)
|
* @param path websocket path (must start with ws:// or wss:// and contain a path part)
|
||||||
* @client use default [HttpClient], it installs [WebSockets] plugin
|
* @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(
|
fun websocketTransportDevice(
|
||||||
path: String,
|
path: String,
|
||||||
client: HttpClient = HttpClient { install(WebSockets) },
|
useTextFrames: Boolean = false,
|
||||||
|
client: HttpClient? = null,
|
||||||
): Transport.Device {
|
): Transport.Device {
|
||||||
|
|
||||||
|
val ownsClient = client == null
|
||||||
|
val actualClient = client ?: HttpClient { install(WebSockets) }
|
||||||
|
|
||||||
|
val log = LogTag("WSTD")
|
||||||
var u = Url(path)
|
var u = Url(path)
|
||||||
if (u.encodedPath.length <= 1)
|
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 {
|
u = URLBuilder(u).apply {
|
||||||
encodedPath = "/kp"
|
encodedPath = "/kp"
|
||||||
}.build()
|
}.build()
|
||||||
|
}
|
||||||
|
log.debug { "Url to process is $u" }
|
||||||
|
|
||||||
val input = Channel<UByteArray>()
|
val input = Channel<UByteArray>()
|
||||||
val output = Channel<UByteArray>()
|
val output = Channel<UByteArray>()
|
||||||
|
val writerReady = CompletableDeferred<PriorityBlockWriter>()
|
||||||
val closeHandle = CompletableDeferred<Boolean>()
|
val closeHandle = CompletableDeferred<Boolean>()
|
||||||
globalLaunch {
|
|
||||||
|
val connectionJob = globalLaunch {
|
||||||
val log = LogTag("KC:${counter.incrementAndGet()}")
|
val log = LogTag("KC:${counter.incrementAndGet()}")
|
||||||
client.webSocket({
|
try {
|
||||||
url.protocol = u.protocol
|
actualClient.webSocket({
|
||||||
url.host = u.host
|
url.protocol = u.protocol
|
||||||
url.port = u.port
|
url.host = u.host
|
||||||
url.encodedPath = u.encodedPath
|
url.port = u.port
|
||||||
url.parameters.appendAll(u.parameters)
|
url.encodedPath = u.encodedPath
|
||||||
log.info { "kiloparsec server URL: $url" }
|
url.parameters.appendAll(u.parameters)
|
||||||
}) {
|
log.info { "kiloparsec server URL: $url" }
|
||||||
log.info { "connected to the server" }
|
}) {
|
||||||
|
log.info { "connected to the server" }
|
||||||
// println("SENDING!!!")
|
// println("SENDING!!!")
|
||||||
// send("Helluva")
|
// send("Helluva")
|
||||||
launch {
|
val writer = PriorityBlockWriter(this) { block ->
|
||||||
try {
|
if (useTextFrames) send(Frame.Text(block.asByteArray().encodeToBase64Compact()))
|
||||||
for (block in output) {
|
else send(block.asByteArray())
|
||||||
send(block.toByteArray())
|
flush() // Do not turn the WebSocket outgoing FIFO into an unbounded look-ahead.
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if (closeHandle.isActive) closeHandle.complete(false)
|
writerReady.complete(writer)
|
||||||
}
|
launch {
|
||||||
launch {
|
try {
|
||||||
try {
|
for (block in output) writer.send(block)
|
||||||
for (f in incoming) {
|
log.info { "input is closed, closing the websocket" }
|
||||||
if (f is Frame.Binary) {
|
if (closeHandle.isActive) closeHandle.complete(true)
|
||||||
input.send(f.readBytes().toUByteArray())
|
} catch (_: ClosedSendChannelException) {
|
||||||
} else {
|
log.info { "send channel closed" }
|
||||||
log.warning { "ignoring unexpected frame of type ${f.frameType}" }
|
} 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.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() }
|
||||||
}
|
}
|
||||||
if (!closeHandle.await()) {
|
} catch (x: IOException) {
|
||||||
log.warning { "Client is closing with error" }
|
if ("refused" in x.toString()) log.debug { "connection refused" }
|
||||||
throw RemoteInterface.ClosedException()
|
else log.warning { "unexpected IO error $x" }
|
||||||
}
|
runCatching { output.close() }
|
||||||
output.close()
|
runCatching { input.close() }
|
||||||
input.close()
|
} finally {
|
||||||
|
writerReady.completeExceptionally(RemoteInterface.ClosedException())
|
||||||
|
if (ownsClient) actualClient.close()
|
||||||
}
|
}
|
||||||
log.info { "closing connection" }
|
log.info { "closing connection" }
|
||||||
}
|
}
|
||||||
val device = ProxyDevice(input, output) {
|
// 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
|
// we need to explicitly close the coroutine job, or it can hang for a long time
|
||||||
// leaking resources.
|
// leaking resources.
|
||||||
|
runCatching { output.close() }
|
||||||
|
runCatching { input.close() }
|
||||||
closeHandle.complete(true)
|
closeHandle.complete(true)
|
||||||
|
connectionJob.cancel()
|
||||||
// job.cancel()
|
// job.cancel()
|
||||||
|
})
|
||||||
|
device.prioritizedSend = { block ->
|
||||||
|
val writer = writerReady.await()
|
||||||
|
device.priorityWriter = writer
|
||||||
|
writer.send(block)
|
||||||
}
|
}
|
||||||
return device
|
return device
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,5 +25,5 @@ data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: U
|
|||||||
|
|
||||||
// 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,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.Instant
|
|
||||||
import net.sergeych.bipack.BipackEncoder
|
import net.sergeych.bipack.BipackEncoder
|
||||||
import net.sergeych.crypto2.initCrypto
|
import net.sergeych.crypto2.initCrypto
|
||||||
import net.sergeych.kiloparsec.KiloParams
|
import net.sergeych.kiloparsec.KiloParams
|
||||||
@@ -21,6 +20,7 @@ import kotlin.test.Test
|
|||||||
import kotlin.test.assertContentEquals
|
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?) {
|
||||||
|
|||||||
@@ -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() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import net.sergeych.crypto2.*
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.utools.pack
|
||||||
|
import net.sergeych.utools.unpack
|
||||||
|
import kotlin.test.*
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class ReplayDeliveryTest {
|
||||||
|
@Test fun replayWindowBoundaries() = runTest {
|
||||||
|
initCrypto()
|
||||||
|
val a = SafeKeyExchange(); val b = SafeKeyExchange()
|
||||||
|
val (da, db) = createTestDevice()
|
||||||
|
val ta = Transport(da, LocalInterface<Unit>(), Unit)
|
||||||
|
val tb = Transport(db, LocalInterface<Unit>(), Unit)
|
||||||
|
val sender = KiloParams(false, ta, a.clientSessionKey(b.publicKey), Unit, null, ta)
|
||||||
|
val receiver = KiloParams(true, tb, b.serverSessionKey(a.publicKey), Unit, null, tb)
|
||||||
|
val packets = (0..4096).map { sender.encrypt(pack(it)) }
|
||||||
|
assertEquals(4096, unpack<Int>(receiver.decrypt(packets[4096])))
|
||||||
|
assertEquals(2, unpack<Int>(receiver.decrypt(packets[2])))
|
||||||
|
assertEquals(1, unpack<Int>(receiver.decrypt(packets[1])))
|
||||||
|
assertFails { receiver.decrypt(packets[0]) }
|
||||||
|
assertFails { receiver.decrypt(packets[1]) }
|
||||||
|
da.close(); db.close()
|
||||||
|
}
|
||||||
|
@Test fun localSendFailurePreservesHealthyConnection() = runTest(timeout = 10.seconds) {
|
||||||
|
initCrypto()
|
||||||
|
val echo by command<String,String>()
|
||||||
|
val (a,b) = createTestDevice()
|
||||||
|
val ready = CompletableDeferred<Unit>()
|
||||||
|
var fail = true
|
||||||
|
val localFailure = object: Transport.Device by a {
|
||||||
|
override suspend fun send(block: UByteArray) {
|
||||||
|
val decoded = unpack<Transport.Block>(block)
|
||||||
|
if(fail && decoded is Transport.Block.Call && decoded.name == "L0Call")
|
||||||
|
throw RemoteInterface.Exception("Outgoing block queue is full")
|
||||||
|
a.send(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val server=KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, b, Unit)
|
||||||
|
val client=KiloClientConnection(KiloInterface<Unit>(),localFailure,Unit)
|
||||||
|
val sj=launch { runCatching { server.run() } }
|
||||||
|
val cj=launch { runCatching { client.run { if(it) ready.complete(Unit) } } }
|
||||||
|
try {
|
||||||
|
ready.await()
|
||||||
|
val result=runCatching { client.call(echo,"x") }
|
||||||
|
assertIs<RemoteInterface.Exception>(result.exceptionOrNull())
|
||||||
|
assertFalse(result.exceptionOrNull() is RemoteInterface.SecurityException)
|
||||||
|
assertTrue(cj.isActive); assertTrue(sj.isActive)
|
||||||
|
fail = false
|
||||||
|
assertEquals("recovered", client.call(echo,"recovered"))
|
||||||
|
} finally { sj.cancelAndJoin(); cj.cancelAndJoin() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun adjacentReorderingSurvives() = reorderedPushes(1, false)
|
||||||
|
@Test fun deepReorderingDiscardsWithoutClosing() = reorderedPushes(4096, true)
|
||||||
|
private fun reorderedPushes(overtakers: Int, shouldDiscard: Boolean) = runTest(timeout = 20.seconds) {
|
||||||
|
initCrypto()
|
||||||
|
val notify by command<Int,Unit>()
|
||||||
|
val (a,b)=createTestDevice()
|
||||||
|
val ready=CompletableDeferred<Unit>()
|
||||||
|
val newerReceived=CompletableDeferred<Unit>()
|
||||||
|
val heldReceived=CompletableDeferred<Unit>()
|
||||||
|
var held: UByteArray?=null
|
||||||
|
val reorder=object: Transport.Device by a {
|
||||||
|
override suspend fun send(block: UByteArray) {
|
||||||
|
val decoded=unpack<Transport.Block>(block)
|
||||||
|
if(decoded is Transport.Block.Call && decoded.name=="L0Call" && held==null) held=block
|
||||||
|
else a.send(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val server=KiloServerConnection(KiloInterface<Unit>().apply { on(notify) {
|
||||||
|
if(it==overtakers) newerReceived.complete(Unit)
|
||||||
|
if(it==0) heldReceived.complete(Unit)
|
||||||
|
} }, b, Unit)
|
||||||
|
val client=KiloClientConnection(KiloInterface<Unit>(),reorder,Unit)
|
||||||
|
val sj=launch { runCatching { server.run() } }
|
||||||
|
val cj=launch { runCatching { client.run { if(it) ready.complete(Unit) } } }
|
||||||
|
try {
|
||||||
|
ready.await()
|
||||||
|
for(i in 0..overtakers) client.push(notify,i)
|
||||||
|
newerReceived.await()
|
||||||
|
a.send(held!!)
|
||||||
|
if(shouldDiscard) {
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(sj.isActive); assertTrue(cj.isActive)
|
||||||
|
assertFalse(heldReceived.isCompleted)
|
||||||
|
client.ping()
|
||||||
|
} else {
|
||||||
|
withTimeout(2000) { heldReceived.await() }
|
||||||
|
assertTrue(sj.isActive); assertTrue(cj.isActive)
|
||||||
|
}
|
||||||
|
} finally { sj.cancelAndJoin(); cj.cancelAndJoin() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
package net.sergeych.kiloparsec
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class ReplayWindowTest {
|
||||||
|
@Test fun boundariesWraparoundAndLargeJumpsMatchReference() {
|
||||||
|
val window = ReplayWindow()
|
||||||
|
val seen = mutableSetOf<ULong>()
|
||||||
|
var highest: ULong? = null
|
||||||
|
val random = Random(41)
|
||||||
|
val inputs = mutableListOf(4096uL, 1uL, 2uL, 0uL, 1uL, 8191uL, 4096uL, 8192uL)
|
||||||
|
repeat(15000) { inputs += random.nextInt(0, 30000).toULong() }
|
||||||
|
inputs += listOf(ULong.MAX_VALUE - 1u, ULong.MAX_VALUE, ULong.MAX_VALUE - 4095u, ULong.MAX_VALUE - 4096u)
|
||||||
|
for (n in inputs) {
|
||||||
|
val h = highest
|
||||||
|
val reject = n in seen || (h != null && n <= h && h - n >= 4096u)
|
||||||
|
assertEquals(reject, window.containsOrStale(n), "nonce=$n highest=$h")
|
||||||
|
if (!reject) {
|
||||||
|
window.record(n)
|
||||||
|
seen += n
|
||||||
|
if (h == null || n > h) highest = n
|
||||||
|
assertTrue(window.containsOrStale(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import net.sergeych.crypto2.SigningSecretKey
|
||||||
|
import net.sergeych.crypto2.initCrypto
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.utools.pack
|
||||||
|
import net.sergeych.utools.unpack
|
||||||
|
import kotlin.test.*
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/** Regression checks at the boundary between untrusted outer transport and authenticated RPC. */
|
||||||
|
class SecurityBoundaryTest {
|
||||||
|
@Test fun plaintextRegisteredBusinessErrorCannotReachCallerAsBusinessResult() =
|
||||||
|
rejectPlaintextResponse(registeredError = true)
|
||||||
|
|
||||||
|
@Test fun malformedPlaintextResultClosesConnection() = rejectPlaintextResponse(registeredError = false)
|
||||||
|
|
||||||
|
private fun rejectPlaintextResponse(registeredError: Boolean) = runTest(timeout = 10.seconds) {
|
||||||
|
initCrypto()
|
||||||
|
val echo by command<String, String>()
|
||||||
|
val (clientDevice, serverDevice) = createTestDevice()
|
||||||
|
val ready = CompletableDeferred<Unit>()
|
||||||
|
var armed = false
|
||||||
|
var attacked = false
|
||||||
|
val malicious = object : Transport.Device by serverDevice {
|
||||||
|
override suspend fun send(block: UByteArray) {
|
||||||
|
val decoded = unpack<Transport.Block>(block)
|
||||||
|
if (armed && decoded is Transport.Block.Response) {
|
||||||
|
attacked = true
|
||||||
|
val forged: Transport.Block = if (registeredError)
|
||||||
|
Transport.Block.Error(decoded.forId, "IllegalArgumentException", "forged quota rejection")
|
||||||
|
else decoded.copy(packedResult = ubyteArrayOf())
|
||||||
|
serverDevice.send(pack(forged))
|
||||||
|
} else serverDevice.send(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val server = KiloServerConnection(KiloInterface<Unit>().apply { on(echo) { it } }, malicious, Unit, SigningSecretKey.new())
|
||||||
|
val client = KiloClientConnection(KiloInterface<Unit>(), clientDevice, Unit)
|
||||||
|
val serverJob = launch { runCatching { server.run() } }
|
||||||
|
val clientJob = launch { runCatching { client.run { if (it) ready.complete(Unit) } } }
|
||||||
|
try {
|
||||||
|
ready.await(); armed = true
|
||||||
|
val result = withTimeout(2000) { runCatching { client.call(echo, "payload") } }
|
||||||
|
assertTrue(attacked)
|
||||||
|
assertIs<RemoteInterface.SecurityException>(result.exceptionOrNull(), "Plaintext outer errors/data must never impersonate authenticated business results")
|
||||||
|
withTimeout(2000) { clientJob.join() }
|
||||||
|
} finally { listOf(clientJob, serverJob).forEach { it.cancelAndJoin() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun unestablishedProtectedCallDoesNotKeepDisconnectedServerAlive() = runTest(timeout = 10.seconds) {
|
||||||
|
initCrypto()
|
||||||
|
val (clientDevice, serverDevice) = createTestDevice()
|
||||||
|
val server = KiloServerConnection(KiloInterface<Unit>(), serverDevice, Unit, SigningSecretKey.new())
|
||||||
|
val serverJob = launch { runCatching { server.run() } }
|
||||||
|
try {
|
||||||
|
clientDevice.send(pack<Transport.Block>(Transport.Block.Call(1u, "L0Call", pack(ubyteArrayOf()))))
|
||||||
|
runCurrent()
|
||||||
|
clientDevice.close()
|
||||||
|
withTimeout(2000) { serverJob.join() }
|
||||||
|
} finally { serverJob.cancelAndJoin() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, ExperimentalUnsignedTypes::class)
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import net.sergeych.crypto2.initCrypto
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import kotlin.test.*
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class TransportAdmissionTest {
|
||||||
|
@Test fun readyCallsDoNotAddPollingDelay() = runTest(timeout = 10.seconds) {
|
||||||
|
val ping by command<Int, Int>()
|
||||||
|
val (a, b) = createTestDevice()
|
||||||
|
val local = LocalInterface<Unit>().apply { on(ping) { it } }
|
||||||
|
val client = Transport(a, local, Unit)
|
||||||
|
val server = Transport(b, local, Unit)
|
||||||
|
val jobs = listOf(launch { client.run() }, launch { server.run() })
|
||||||
|
try {
|
||||||
|
val start = currentTime
|
||||||
|
repeat(20) { assertEquals(it, client.call(ping, it)) }
|
||||||
|
assertEquals(0L, currentTime - start, "Ready transports must not impose an artificial per-call delay")
|
||||||
|
} finally { jobs.forEach { it.cancelAndJoin() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun successfulPlainPushReturnsNormally() = runTest(timeout = 10.seconds) {
|
||||||
|
val notify by command<Int, Unit>()
|
||||||
|
val received = CompletableDeferred<Int>()
|
||||||
|
val (a, b) = createTestDevice()
|
||||||
|
val client = Transport(a, LocalInterface<Unit>(), Unit)
|
||||||
|
val server = Transport(b, LocalInterface<Unit>().apply { on(notify) { received.complete(it); Unit } }, Unit)
|
||||||
|
val jobs = listOf(launch { client.run() }, launch { server.run() })
|
||||||
|
try {
|
||||||
|
client.push(notify, 7)
|
||||||
|
assertEquals(7, received.await())
|
||||||
|
} finally { jobs.forEach { it.cancelAndJoin() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun encryptedPushDoesNotWaitForRemoteHandler() = runTest(timeout = 10.seconds) {
|
||||||
|
initCrypto()
|
||||||
|
val notify by command<Int, Unit>()
|
||||||
|
val ping by command<Int, Int>()
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val ready = CompletableDeferred<Unit>()
|
||||||
|
val (a, b) = createTestDevice()
|
||||||
|
val server = KiloServerConnection(KiloInterface<Unit>().apply {
|
||||||
|
on(notify) { entered.complete(Unit); release.await() }
|
||||||
|
on(ping) { it }
|
||||||
|
}, b, Unit)
|
||||||
|
val client = KiloClientConnection(KiloInterface<Unit>(), a, Unit)
|
||||||
|
val jobs = listOf(launch { server.run() }, launch { client.run { if (it) ready.complete(Unit) } })
|
||||||
|
try {
|
||||||
|
ready.await()
|
||||||
|
val notification = async { client.push(notify, 7) }
|
||||||
|
entered.await()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(notification.isCompleted, "Push must return before the remote handler finishes")
|
||||||
|
notification.await()
|
||||||
|
assertEquals(9, client.call(ping, 9), "A pending notification must not block ordinary RPC")
|
||||||
|
} finally {
|
||||||
|
release.complete(Unit)
|
||||||
|
jobs.forEach { it.cancelAndJoin() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,8 @@ class TransportTest {
|
|||||||
"p2: $it"
|
"p2: $it"
|
||||||
}
|
}
|
||||||
on(cmdSlow) {
|
on(cmdSlow) {
|
||||||
delay(100)
|
// the problem: runTest() breaks delays so it is not enough
|
||||||
|
for( i in 0..10000) delay(1)
|
||||||
"done"
|
"done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,6 +170,41 @@ class TransportTest {
|
|||||||
d2.close()
|
d2.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDisconnectedHandlers() = runTest {
|
||||||
|
initCrypto()
|
||||||
|
|
||||||
|
val cmdPing by command<String, String>()
|
||||||
|
val (d1, d2) = createTestDevice()
|
||||||
|
val serverDisconnected = CompletableDeferred<String>()
|
||||||
|
val clientDisconnected = CompletableDeferred<String>()
|
||||||
|
|
||||||
|
val serverInterface = KiloInterface<String>().apply {
|
||||||
|
onDisconnected {
|
||||||
|
serverDisconnected.complete(session)
|
||||||
|
}
|
||||||
|
on(cmdPing) {
|
||||||
|
"pong! [$it]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch { KiloServerConnection(serverInterface, d1, "server session").run() }
|
||||||
|
|
||||||
|
val clientInterface = KiloInterface<String>().apply {
|
||||||
|
onDisconnected {
|
||||||
|
clientDisconnected.complete(session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val client = KiloClientConnection(clientInterface, d2, "client session")
|
||||||
|
launch { client.run() }
|
||||||
|
|
||||||
|
assertEquals("pong! [hello]", client.call(cmdPing, "hello"))
|
||||||
|
d1.close()
|
||||||
|
d2.close()
|
||||||
|
|
||||||
|
assertEquals("server session", withTimeout(1000) { serverDisconnected.await() })
|
||||||
|
assertEquals("client session", withTimeout(1000) { clientDisconnected.await() })
|
||||||
|
}
|
||||||
|
|
||||||
class TestException(text: String) : Exception(text)
|
class TestException(text: String) : Exception(text)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import createTestDevice
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.utools.pack
|
||||||
|
import net.sergeych.utools.unpack
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class OutQueueTest {
|
||||||
|
@Test fun priorityWithinWindowAndOldestProgress() {
|
||||||
|
val queue = OutQueue<ULong>()
|
||||||
|
queue.push(100u, 100u, -100)
|
||||||
|
for (n in 101uL..5000uL) queue.push(n, n, 100)
|
||||||
|
val sent = mutableListOf<ULong>()
|
||||||
|
while (true) sent += queue.pop() ?: break
|
||||||
|
assertEquals((101uL..2830uL).toList(), sent.take(2730))
|
||||||
|
assertEquals(100uL, sent[2730])
|
||||||
|
assertEquals(4901, sent.toSet().size)
|
||||||
|
}
|
||||||
|
@Test fun gapsAndMaximumNonceDoNotOverflow() {
|
||||||
|
val queue = OutQueue<ULong>()
|
||||||
|
queue.push(ULong.MAX_VALUE, ULong.MAX_VALUE, 100)
|
||||||
|
queue.push(ULong.MAX_VALUE - 2731u, ULong.MAX_VALUE - 2731u, -100)
|
||||||
|
assertEquals(ULong.MAX_VALUE - 2731u, queue.pop())
|
||||||
|
assertEquals(ULong.MAX_VALUE, queue.pop())
|
||||||
|
assertNull(queue.pop())
|
||||||
|
}
|
||||||
|
@Test fun writerHandlesCancellationCapacityAndContinuesAfterLocalFailure() = runTest {
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val observed = mutableListOf<Int>()
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope, maxQueuedItems = 2, continueOnWriteFailure = true) {
|
||||||
|
val n = it[0].toInt()
|
||||||
|
if (n == 0) { entered.complete(Unit); gate.await() }
|
||||||
|
if (n == 3) throw IllegalStateException("Local carrier rejection")
|
||||||
|
observed += n
|
||||||
|
}
|
||||||
|
val first = async { writer.enqueue(ubyteArrayOf(0u), 0u).invoke() }
|
||||||
|
entered.await()
|
||||||
|
val cancelled = async { writer.enqueue(ubyteArrayOf(1u), 1u).invoke() }
|
||||||
|
runCurrent()
|
||||||
|
val second = async { writer.enqueue(ubyteArrayOf(2u), 2u).invoke() }
|
||||||
|
runCurrent()
|
||||||
|
assertFailsWith<RemoteInterface.Exception> { writer.enqueue(ubyteArrayOf(9u), 9u) }
|
||||||
|
cancelled.cancelAndJoin()
|
||||||
|
gate.complete(Unit)
|
||||||
|
first.await(); second.await()
|
||||||
|
assertFailsWith<IllegalStateException> { writer.enqueue(ubyteArrayOf(3u), 3u).invoke() }
|
||||||
|
writer.enqueue(ubyteArrayOf(4u), 4u).invoke()
|
||||||
|
assertEquals(listOf(0, 2, 4), observed)
|
||||||
|
writer.close(); runCurrent()
|
||||||
|
assertFailsWith<RemoteInterface.ClosedException> { writer.enqueue(ubyteArrayOf(5u), 5u) }
|
||||||
|
}
|
||||||
|
@Test fun writerUsesNonceBoundBeforePriority() = runTest {
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val order = mutableListOf<Int>()
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope) { bytes ->
|
||||||
|
val n = (unpack<Transport.Block>(bytes) as Transport.Block.Call).id.toInt()
|
||||||
|
if (n == 0) { entered.complete(Unit); gate.await() }
|
||||||
|
order += n
|
||||||
|
}
|
||||||
|
fun frame(n: Int, priority: Int) = pack<Transport.Block>(Transport.Block.Call(n.toUInt(), "x", ubyteArrayOf()).also { it.priority = priority })
|
||||||
|
val first = async { writer.enqueue(frame(0, 0), 0u).invoke() }
|
||||||
|
entered.await()
|
||||||
|
val old = writer.enqueue(frame(1, 0), 1u)
|
||||||
|
val newer = writer.enqueue(frame(2, 100), 2732u)
|
||||||
|
gate.complete(Unit); first.await(); old(); newer()
|
||||||
|
assertEquals(3, order.size)
|
||||||
|
assertEquals(listOf(0, 1, 2), order)
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun encryptedBacklogCrossingWindowStillDeliversEveryMessage() = runTest {
|
||||||
|
net.sergeych.crypto2.initCrypto()
|
||||||
|
val a = net.sergeych.crypto2.SafeKeyExchange()
|
||||||
|
val b = net.sergeych.crypto2.SafeKeyExchange()
|
||||||
|
val (da, db) = createTestDevice()
|
||||||
|
val ta = Transport(da, LocalInterface<Unit>(), Unit)
|
||||||
|
val tb = Transport(db, LocalInterface<Unit>(), Unit)
|
||||||
|
val sender = KiloParams(false, ta, a.clientSessionKey(b.publicKey), Unit, null, ta)
|
||||||
|
val receiver = KiloParams(true, tb, b.serverSessionKey(a.publicKey), Unit, null, tb)
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val order = mutableListOf<Int>()
|
||||||
|
// Larger test capacity reaches the boundary in one batch; production retains 128.
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope, maxQueuedItems = 5000) { bytes ->
|
||||||
|
val frame = unpack<Transport.Block>(bytes) as Transport.Block.Call
|
||||||
|
if (frame.id == 0u) { entered.complete(Unit); gate.await() }
|
||||||
|
order += unpack<Int>(receiver.decrypt(unpack(frame.packedArgs)))
|
||||||
|
}
|
||||||
|
suspend fun admit(n: Int, priority: Int): suspend () -> Unit {
|
||||||
|
val encoded = sender.encode(pack(n))
|
||||||
|
val frame = Transport.Block.Call(n.toUInt(), "L0Call", pack(encoded.bytes)).also { it.priority = priority }
|
||||||
|
return writer.enqueue(pack<Transport.Block>(frame), encoded.nonce)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val first = admit(0, 0)
|
||||||
|
entered.await()
|
||||||
|
val completions = mutableListOf(admit(1, -100))
|
||||||
|
for (n in 2..4097) completions += admit(n, 100)
|
||||||
|
gate.complete(Unit); first()
|
||||||
|
completions.forEach { it() }
|
||||||
|
assertEquals(4098, order.size)
|
||||||
|
assertEquals((0..4097).toSet(), order.toSet())
|
||||||
|
assertEquals(2731, order.indexOf(1))
|
||||||
|
} finally { writer.close(); da.close(); db.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun closingWriterReleasesCurrentAndQueuedSenders() = runTest {
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope) { entered.complete(Unit); awaitCancellation() }
|
||||||
|
val first = async { runCatching { writer.send(ubyteArrayOf(0u)) } }
|
||||||
|
entered.await()
|
||||||
|
val second = async { runCatching { writer.enqueue(ubyteArrayOf(1u), 1u).invoke() } }
|
||||||
|
runCurrent(); writer.close()
|
||||||
|
assertTrue(first.await().isFailure)
|
||||||
|
assertTrue(second.await().isFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import net.sergeych.crypto2.initCrypto
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class PriorityLinkTest {
|
||||||
|
@Test fun saturatedEncryptedLinkKeepsConcurrentControlResponsiveWithoutRateCap() = runTest {
|
||||||
|
initCrypto()
|
||||||
|
val bulk by command<UByteArray, Unit>()
|
||||||
|
val ping by command<Int, Int>()
|
||||||
|
val received = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
val aInput = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
val bInput = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
var sentBytes = 0L
|
||||||
|
val rate = 16_384
|
||||||
|
fun endpoint(input: Channel<UByteArray>, destination: Channel<UByteArray>): Transport.Device {
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope) { block ->
|
||||||
|
delay(ceil(block.size * 1000.0 / rate).toLong())
|
||||||
|
sentBytes += block.size
|
||||||
|
destination.send(block)
|
||||||
|
}
|
||||||
|
return ProxyDevice(input, Channel(), doClose = { writer.close(); input.close() }).also { it.priorityWriter = writer }
|
||||||
|
}
|
||||||
|
val a = endpoint(aInput, bInput)
|
||||||
|
val b = endpoint(bInput, aInput)
|
||||||
|
val ready = CompletableDeferred<Unit>()
|
||||||
|
val api = KiloInterface<Unit>().apply {
|
||||||
|
on(bulk) { received.send(it) }
|
||||||
|
on(ping) { it }
|
||||||
|
}
|
||||||
|
val server = KiloServerConnection(api, b, Unit)
|
||||||
|
val client = KiloClientConnection(api, a, Unit)
|
||||||
|
val jobs = listOf(backgroundScope.launch { server.run() }, backgroundScope.launch { client.run { if (it) ready.complete(Unit) } })
|
||||||
|
try {
|
||||||
|
ready.await()
|
||||||
|
val start = currentTime
|
||||||
|
val initialBytes = sentBytes
|
||||||
|
val producers = listOf<RemoteInterface>(client, server).flatMap { sender ->
|
||||||
|
List(8) { backgroundScope.launch { while (isActive) sender.push(bulk, UByteArray(2000), priority = 0) } }
|
||||||
|
}
|
||||||
|
received.receive()
|
||||||
|
repeat(10) {
|
||||||
|
val began = currentTime
|
||||||
|
assertEquals(it, client.call(ping, it, priority = 100))
|
||||||
|
assertTrue(currentTime - began < 400, "control latency=${currentTime - began}")
|
||||||
|
delay(80)
|
||||||
|
}
|
||||||
|
val elapsed = currentTime - start
|
||||||
|
assertTrue((sentBytes - initialBytes) * 1000 / elapsed > rate * 1.6, "Writer must use available capacity")
|
||||||
|
producers.forEach { it.cancelAndJoin() }
|
||||||
|
} finally { jobs.forEach { it.cancelAndJoin() }; a.close(); b.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.*
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import net.sergeych.bintools.DataSource
|
||||||
|
import net.sergeych.bipack.BipackDecoder
|
||||||
|
import net.sergeych.bipack.BipackEncoder
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.utools.pack
|
||||||
|
import net.sergeych.utools.unpack
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class PriorityTest {
|
||||||
|
private fun block(priority: Int, id: Int = 1): UByteArray = pack<Transport.Block>(
|
||||||
|
Transport.Block.Call(id.toUInt(), "example", ubyteArrayOf(1u, 2u)).also { it.priority = priority })
|
||||||
|
|
||||||
|
@Test fun legacyBodyAndTrailerCompatibility() {
|
||||||
|
for (original in listOf(
|
||||||
|
Transport.Block.Call(123456u, "test", UByteArray(3500) { it.toUByte() }),
|
||||||
|
Transport.Block.Response(123456u, UByteArray(256)),
|
||||||
|
Transport.Block.Error(123456u, "code", "message", UByteArray(180)),
|
||||||
|
Transport.Block.Error(0u, "code", null, null),
|
||||||
|
)) {
|
||||||
|
val legacy = when (original) {
|
||||||
|
is Transport.Block.Call -> ubyteArrayOf(0u) + BipackEncoder.encode(original).asUByteArray()
|
||||||
|
is Transport.Block.Error -> ubyteArrayOf(1u) + BipackEncoder.encode(original).asUByteArray()
|
||||||
|
is Transport.Block.Response -> ubyteArrayOf(2u) + BipackEncoder.encode(original).asUByteArray()
|
||||||
|
}
|
||||||
|
assertContentEquals(legacy, pack<Transport.Block>(original))
|
||||||
|
assertEquals(0, unpack<Transport.Block>(legacy).priority)
|
||||||
|
for (priority in listOf(Int.MIN_VALUE, -1, 0, 1, 100, Int.MAX_VALUE)) {
|
||||||
|
original.priority = priority
|
||||||
|
val packed = pack<Transport.Block>(original)
|
||||||
|
assertEquals(priority, unpack<Transport.Block>(packed).priority)
|
||||||
|
assertEquals(priority, blockPriority(packed))
|
||||||
|
assertContentEquals(legacy, packed.copyOf(legacy.size))
|
||||||
|
// Legacy decoding invokes the original body serializer and ignores trailing bytes.
|
||||||
|
val oldRead = when (original) {
|
||||||
|
is Transport.Block.Call -> BipackDecoder.decode<Transport.Block.Call>(packed.drop(1).toUByteArray())
|
||||||
|
is Transport.Block.Response -> BipackDecoder.decode<Transport.Block.Response>(packed.drop(1).toUByteArray())
|
||||||
|
is Transport.Block.Error -> BipackDecoder.decode<Transport.Block.Error>(packed.drop(1).toUByteArray())
|
||||||
|
}
|
||||||
|
assertEquals(0, oldRead.priority)
|
||||||
|
}
|
||||||
|
assertFailsWith<DataSource.EndOfData> { unpack<Transport.Block>(legacy + ubyteArrayOf(1u)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun numericOrderingStableTiesAndCancellation() = runTest {
|
||||||
|
val release = CompletableDeferred<Unit>()
|
||||||
|
val started = CompletableDeferred<Unit>()
|
||||||
|
val sent = mutableListOf<Pair<Int, UInt>>()
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope) { bytes ->
|
||||||
|
val b = unpack<Transport.Block>(bytes) as Transport.Block.Call
|
||||||
|
if (b.id == 1u) { started.complete(Unit); release.await() }
|
||||||
|
sent += b.priority to b.id
|
||||||
|
}
|
||||||
|
val first = async { writer.send(block(0, 1)) }
|
||||||
|
started.await()
|
||||||
|
val queued = listOf(0 to 2, 100 to 3, 2 to 4, 100 to 5, -10 to 6).map { (p, id) ->
|
||||||
|
async { writer.send(block(p, id)) }
|
||||||
|
}
|
||||||
|
val cancelled = async { writer.send(block(Int.MAX_VALUE, 7)) }
|
||||||
|
runCurrent(); cancelled.cancelAndJoin()
|
||||||
|
release.complete(Unit)
|
||||||
|
first.await(); queued.awaitAll()
|
||||||
|
assertEquals(listOf(0 to 1u, 100 to 3u, 100 to 5u, 2 to 4u, 0 to 2u, -10 to 6u), sent)
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun boundedAdmissionAndCloseReleaseWaiters() = runTest {
|
||||||
|
val started = CompletableDeferred<Unit>()
|
||||||
|
val writer = PriorityBlockWriter(backgroundScope, maxQueuedItems = 1) { started.complete(Unit); awaitCancellation() }
|
||||||
|
val sending = async { runCatching { writer.send(block(0)) } }
|
||||||
|
started.await()
|
||||||
|
val queued = async { runCatching { writer.send(block(2)) } }
|
||||||
|
runCurrent()
|
||||||
|
assertFailsWith<RemoteInterface.Exception> { writer.send(block(100)) }
|
||||||
|
writer.close()
|
||||||
|
assertIs<CancellationException>(sending.await().exceptionOrNull())
|
||||||
|
assertIs<RemoteInterface.ClosedException>(queued.await().exceptionOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import net.sergeych.kiloparsec.KiloInterface
|
|||||||
import net.sergeych.kiloparsec.KiloServerConnection
|
import net.sergeych.kiloparsec.KiloServerConnection
|
||||||
import net.sergeych.kiloparsec.RemoteInterface
|
import net.sergeych.kiloparsec.RemoteInterface
|
||||||
import net.sergeych.mp_logger.*
|
import net.sergeych.mp_logger.*
|
||||||
|
import net.sergeych.mp_tools.decodeBase64Compact
|
||||||
|
import net.sergeych.mp_tools.encodeToBase64Compact
|
||||||
import net.sergeych.tools.AtomicCounter
|
import net.sergeych.tools.AtomicCounter
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@@ -52,12 +54,22 @@ fun <S> Application.setupWebsocketServer(
|
|||||||
path: String = "/kp",
|
path: String = "/kp",
|
||||||
serverKey: SigningKey? = null,
|
serverKey: SigningKey? = null,
|
||||||
createSession: () -> S,
|
createSession: () -> S,
|
||||||
|
) = setupWebsocketServer(localInterface, path, serverKey, {}, createSession)
|
||||||
|
|
||||||
|
/** Configure transport keepalive without changing the legacy defaults for other applications. */
|
||||||
|
fun <S> Application.setupWebsocketServer(
|
||||||
|
localInterface: KiloInterface<S>,
|
||||||
|
path: String = "/kp",
|
||||||
|
serverKey: SigningKey? = null,
|
||||||
|
configureWebSockets: WebSockets.WebSocketOptions.() -> Unit,
|
||||||
|
createSession: () -> S,
|
||||||
) {
|
) {
|
||||||
install(WebSockets) {
|
install(WebSockets) {
|
||||||
pingPeriod = 60.seconds //Duration.ofSeconds(15)
|
pingPeriod = 60.seconds //Duration.ofSeconds(15)
|
||||||
timeout = 45.seconds
|
timeout = 45.seconds
|
||||||
maxFrameSize = Long.MAX_VALUE
|
maxFrameSize = Long.MAX_VALUE
|
||||||
masking = false
|
masking = false
|
||||||
|
configureWebSockets()
|
||||||
}
|
}
|
||||||
val counter = AtomicCounter()
|
val counter = AtomicCounter()
|
||||||
routing {
|
routing {
|
||||||
@@ -65,23 +77,20 @@ fun <S> Application.setupWebsocketServer(
|
|||||||
val log = LogTag("KWS:${counter.incrementAndGet()}")
|
val log = LogTag("KWS:${counter.incrementAndGet()}")
|
||||||
log.debug { "opening the connection" }
|
log.debug { "opening the connection" }
|
||||||
val input = Channel<UByteArray>(256)
|
val input = Channel<UByteArray>(256)
|
||||||
val output = Channel<UByteArray>(256)
|
val output = Channel<UByteArray>()
|
||||||
|
var useBinary: Boolean? = null
|
||||||
|
val writer = PriorityBlockWriter(this) { block ->
|
||||||
|
if (useBinary == false) send(block.asByteArray().encodeToBase64Compact())
|
||||||
|
else send(block.asByteArray())
|
||||||
|
flush()
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
log.debug { "starting output pump" }
|
try { for (block in output) writer.send(block) }
|
||||||
while (isActive) {
|
finally { writer.close() }
|
||||||
try {
|
|
||||||
send(output.receive().toByteArray())
|
|
||||||
}
|
|
||||||
catch(_: ClosedReceiveChannelException) {
|
|
||||||
log.debug { "closing output pump as output channel is closed" }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.debug { "closing output pump" }
|
|
||||||
}
|
}
|
||||||
val server = KiloServerConnection(
|
val server = KiloServerConnection(
|
||||||
localInterface,
|
localInterface,
|
||||||
ProxyDevice(input, output),
|
ProxyDevice(input, output).also { it.priorityWriter = writer },
|
||||||
createSession(),
|
createSession(),
|
||||||
serverKey
|
serverKey
|
||||||
)
|
)
|
||||||
@@ -91,21 +100,38 @@ fun <S> Application.setupWebsocketServer(
|
|||||||
}
|
}
|
||||||
log.debug { "KSC started, looking for incoming frames" }
|
log.debug { "KSC started, looking for incoming frames" }
|
||||||
for (f in incoming) {
|
for (f in incoming) {
|
||||||
if (f is Frame.Binary)
|
try {
|
||||||
try {
|
when (f) {
|
||||||
input.send(f.readBytes().toUByteArray())
|
is Frame.Binary -> {
|
||||||
} catch (_: RemoteInterface.ClosedException) {
|
if (useBinary == null) {
|
||||||
log.warning { "caught local closed exception (strange!), closing" }
|
log.debug { "Setting binary frame mode ------------------------------------" }
|
||||||
break
|
useBinary = true
|
||||||
} catch (_: ClosedReceiveChannelException) {
|
}
|
||||||
log.info { "receive channel is closed, closing connection" }
|
input.send(f.readBytes().toUByteArray())
|
||||||
break
|
}
|
||||||
} catch (t: Throwable) {
|
|
||||||
log.exception { "unexpected exception, server connection will close" to t }
|
is Frame.Text -> {
|
||||||
break
|
if (useBinary == null) {
|
||||||
|
log.debug { "Setting text frame mode -----------------------------------" }
|
||||||
|
useBinary = false
|
||||||
|
}
|
||||||
|
input.send(f.readText().decodeBase64Compact().asUByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
log.warning { "unexpected frame type ${f.frameType}, ignoring" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
} catch (_: RemoteInterface.ClosedException) {
|
||||||
log.warning { "unknown frame type ${f.frameType}, ignoring" }
|
log.warning { "caught local closed exception (strange!), closing" }
|
||||||
|
break
|
||||||
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
|
log.info { "receive channel is closed, closing connection" }
|
||||||
|
break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
log.exception { "unexpected exception, server connection will close" to t }
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.debug { "closing the server" }
|
log.debug { "closing the server" }
|
||||||
close()
|
close()
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import io.ktor.server.engine.*
|
|||||||
import io.ktor.server.netty.*
|
import io.ktor.server.netty.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import net.sergeych.crypto2.SigningSecretKey
|
||||||
import net.sergeych.crypto2.initCrypto
|
import net.sergeych.crypto2.initCrypto
|
||||||
import net.sergeych.kiloparsec.adapter.setupWebsocketServer
|
import net.sergeych.kiloparsec.adapter.setupWebsocketServer
|
||||||
import net.sergeych.kiloparsec.adapter.websocketClient
|
import net.sergeych.kiloparsec.adapter.websocketClient
|
||||||
|
import net.sergeych.kiloparsec.adapter.websocketTransportDevice
|
||||||
import net.sergeych.mp_logger.Log
|
import net.sergeych.mp_logger.Log
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
@@ -39,17 +42,18 @@ class ClientTest {
|
|||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun webSocketTest() = runTest {
|
fun webSocketTest1() = runTest {
|
||||||
initCrypto()
|
initCrypto()
|
||||||
// fun Application.
|
// fun Application.
|
||||||
val cmdClose by command<Unit,Unit>()
|
val cmdClose by command<Unit, Unit>()
|
||||||
val cmdGetFoo by command<Unit,String>()
|
val cmdGetFoo by command<Unit, String>()
|
||||||
val cmdSetFoo by command<String,Unit>()
|
val cmdSetFoo by command<String, Unit>()
|
||||||
val cmdCheckConnected by command<Unit,Boolean>()
|
val cmdCheckConnected by command<Unit, Boolean>()
|
||||||
|
|
||||||
Log.connectConsole(Log.Level.DEBUG)
|
Log.connectConsole(Log.Level.DEBUG)
|
||||||
|
|
||||||
data class Session(var foo: String="not set")
|
data class Session(var foo: String = "not set")
|
||||||
|
|
||||||
var closeCounter = 0
|
var closeCounter = 0
|
||||||
val serverInterface = KiloInterface<Session>().apply {
|
val serverInterface = KiloInterface<Session>().apply {
|
||||||
var connectedCalled = false
|
var connectedCalled = false
|
||||||
@@ -62,7 +66,7 @@ class ClientTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val port = Random.nextInt(8080,9090)
|
val port = Random.nextInt(8080, 9090)
|
||||||
val ns = embeddedServer(Netty, port = port, host = "0.0.0.0", module = {
|
val ns = embeddedServer(Netty, port = port, host = "0.0.0.0", module = {
|
||||||
setupWebsocketServer(serverInterface) { Session() }
|
setupWebsocketServer(serverInterface) { Session() }
|
||||||
}).start(wait = false)
|
}).start(wait = false)
|
||||||
@@ -73,7 +77,9 @@ class ClientTest {
|
|||||||
client.connectedStateFlow.collect {
|
client.connectedStateFlow.collect {
|
||||||
println("got: $closeCounter/$it")
|
println("got: $closeCounter/$it")
|
||||||
states += it
|
states += it
|
||||||
if( !it) { closeCounter++ }
|
if (!it) {
|
||||||
|
closeCounter++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assertEquals(true, client.call(cmdCheckConnected))
|
assertEquals(true, client.call(cmdCheckConnected))
|
||||||
@@ -94,7 +100,7 @@ class ClientTest {
|
|||||||
assertFalse { client.connectedStateFlow.value }
|
assertFalse { client.connectedStateFlow.value }
|
||||||
|
|
||||||
// this should be run on automatically reopen connection
|
// this should be run on automatically reopen connection
|
||||||
client.call(cmdSetFoo,"superbar")
|
client.call(cmdSetFoo, "superbar")
|
||||||
assertTrue { client.connectedStateFlow.value }
|
assertTrue { client.connectedStateFlow.value }
|
||||||
assertEquals("superbar", client.call(cmdGetFoo))
|
assertEquals("superbar", client.call(cmdGetFoo))
|
||||||
client.close()
|
client.close()
|
||||||
@@ -104,4 +110,101 @@ class ClientTest {
|
|||||||
// println("stopped server")
|
// println("stopped server")
|
||||||
// println("closed client")
|
// println("closed client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun webSocketTest2() = runTest {
|
||||||
|
initCrypto()
|
||||||
|
// fun Application.
|
||||||
|
val cmdClose by command<Unit, Unit>()
|
||||||
|
val cmdGetFoo by command<Unit, String>()
|
||||||
|
val cmdSetFoo by command<String, Unit>()
|
||||||
|
val cmdCheckConnected by command<Unit, Boolean>()
|
||||||
|
|
||||||
|
Log.connectConsole(Log.Level.DEBUG)
|
||||||
|
|
||||||
|
data class Session(var foo: String = "not set")
|
||||||
|
|
||||||
|
var closeCounter = 0
|
||||||
|
val serverInterface = KiloInterface<Session>().apply {
|
||||||
|
var connectedCalled = false
|
||||||
|
onConnected { connectedCalled = true }
|
||||||
|
on(cmdGetFoo) { session.foo }
|
||||||
|
on(cmdSetFoo) { session.foo = it }
|
||||||
|
on(cmdCheckConnected) { connectedCalled }
|
||||||
|
on(cmdClose) {
|
||||||
|
throw LocalInterface.BreakConnectionException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val port = Random.nextInt(8080, 9090)
|
||||||
|
val ns = embeddedServer(Netty, port = port, host = "0.0.0.0", module = {
|
||||||
|
setupWebsocketServer(serverInterface) { Session() }
|
||||||
|
}).start(wait = false)
|
||||||
|
|
||||||
|
val client = websocketClient<Unit>("ws://localhost:$port/kp", useTextFrames = true)
|
||||||
|
val states = mutableListOf<Boolean>()
|
||||||
|
val collector = launch {
|
||||||
|
client.connectedStateFlow.collect {
|
||||||
|
println("got: $closeCounter/$it")
|
||||||
|
states += it
|
||||||
|
if (!it) {
|
||||||
|
closeCounter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(true, client.call(cmdCheckConnected))
|
||||||
|
assertTrue { client.connectedStateFlow.value }
|
||||||
|
assertEquals("not set", client.call(cmdGetFoo))
|
||||||
|
client.call(cmdSetFoo, "foo")
|
||||||
|
assertEquals("foo", client.call(cmdGetFoo))
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
ns.stop()
|
||||||
|
collector.cancel()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun webSocketWaitForConnectTest() = runBlocking {
|
||||||
|
initCrypto()
|
||||||
|
// fun Application.
|
||||||
|
Log.connectConsole(Log.Level.DEBUG)
|
||||||
|
|
||||||
|
val clientInterface = KiloInterface<Unit>().apply {}
|
||||||
|
|
||||||
|
val port = Random.nextInt(8080, 9090)
|
||||||
|
var clientConnectCalls = 0
|
||||||
|
// It should repeatedly reconnect, and we will count:
|
||||||
|
KiloClient(clientInterface, SigningSecretKey.new()) {
|
||||||
|
clientConnectCalls++
|
||||||
|
KiloConnectionData(websocketTransportDevice("ws://localhost:$port/kp", true), Unit)
|
||||||
|
}
|
||||||
|
delay(1200)
|
||||||
|
// and check:
|
||||||
|
// println("connection attemtps: $clientConnectCalls")
|
||||||
|
assertTrue { clientConnectCalls > 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun webSocketWaitForConnectTest2() = runBlocking {
|
||||||
|
initCrypto()
|
||||||
|
// fun Application.
|
||||||
|
Log.connectConsole(Log.Level.DEBUG)
|
||||||
|
|
||||||
|
val clientInterface = KiloInterface<Unit>().apply {}
|
||||||
|
|
||||||
|
val port = Random.nextInt(8080, 9090)
|
||||||
|
var clientConnectCalls = 0
|
||||||
|
// It should repeatedly reconnect, and we will count:
|
||||||
|
KiloClient(clientInterface, SigningSecretKey.new()) {
|
||||||
|
clientConnectCalls++
|
||||||
|
KiloConnectionData(websocketTransportDevice("ws://localhost:$port/kp", false), Unit)
|
||||||
|
}
|
||||||
|
delay(1200)
|
||||||
|
// and check:
|
||||||
|
// println("connection attemtps: $clientConnectCalls")
|
||||||
|
assertTrue { clientConnectCalls > 1 }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package net.sergeych.kiloparsec
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import net.sergeych.crypto2.initCrypto
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class ConnectionHealthTest {
|
||||||
|
@Test(timeout = 15_000)
|
||||||
|
fun silentHandshakesExhaustBudgetAndReleaseWaitingCalls() = runBlocking {
|
||||||
|
initCrypto()
|
||||||
|
val attempts = AtomicInteger()
|
||||||
|
val closes = AtomicInteger()
|
||||||
|
val client = KiloClient(KiloInterface<Unit>(), options = ReconnectionOptions(
|
||||||
|
health = ConnectionHealth(50, 50), maxAttempts = 3,
|
||||||
|
connectTimeoutMillis = 100, retryDelayMillis = 10,
|
||||||
|
)) {
|
||||||
|
attempts.incrementAndGet()
|
||||||
|
KiloConnectionData(object : Transport.Device {
|
||||||
|
val closed = java.util.concurrent.atomic.AtomicBoolean()
|
||||||
|
override val input = Channel<UByteArray>(16)
|
||||||
|
override val output = Channel<UByteArray>(16)
|
||||||
|
override suspend fun close() { if (closed.compareAndSet(false, true)) closes.incrementAndGet(); input.cancel(); output.cancel() }
|
||||||
|
}, Unit)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val waiter = async { runCatching { client.token() } }
|
||||||
|
withTimeout(10_000) { client.reconnectExhausted.first { it } }
|
||||||
|
assertEquals(3, attempts.get())
|
||||||
|
assertEquals(3, closes.get())
|
||||||
|
assertFalse(client.connectedStateFlow.value)
|
||||||
|
assertTrue(withTimeout(1_000) { waiter.await() }.isFailure)
|
||||||
|
val missing by command<Unit, Unit>()
|
||||||
|
repeat(2) {
|
||||||
|
withTimeout(1_000) {
|
||||||
|
assertFailsWith<RemoteInterface.ClosedException> { client.call(missing, Unit) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(200)
|
||||||
|
assertEquals(3, attempts.get(), "An exhausted client must not reconnect forever")
|
||||||
|
} finally { client.close() }
|
||||||
|
}
|
||||||
|
@Test(timeout = 15_000)
|
||||||
|
fun establishedSilentLossTriggersHeartbeatAndBoundedRecovery() = runBlocking {
|
||||||
|
initCrypto()
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
val blocked = java.util.concurrent.atomic.AtomicBoolean()
|
||||||
|
val attempts = AtomicInteger()
|
||||||
|
val api = object : KiloInterface<Unit>() {
|
||||||
|
override suspend fun execute(scope: KiloScope<Unit>, name: String, packedArgs: UByteArray): UByteArray {
|
||||||
|
error("Heartbeat must bypass application command dispatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val client = KiloClient(KiloInterface<Unit>(), options = ReconnectionOptions(
|
||||||
|
health = ConnectionHealth(100, 100), maxAttempts = 3,
|
||||||
|
connectTimeoutMillis = 300, retryDelayMillis = 10,
|
||||||
|
)) {
|
||||||
|
attempts.incrementAndGet()
|
||||||
|
val requests = Channel<UByteArray>(16)
|
||||||
|
val forwarded = Channel<UByteArray>(16)
|
||||||
|
val responses = Channel<UByteArray>(16)
|
||||||
|
val pump = scope.launch { for (block in requests) if (!blocked.get()) forwarded.send(block) }
|
||||||
|
suspend fun retire() { pump.cancel(); requests.cancel(); forwarded.cancel(); responses.cancel() }
|
||||||
|
val serverDevice = object : Transport.Device {
|
||||||
|
override val input = forwarded
|
||||||
|
override val output = responses
|
||||||
|
override suspend fun close() { retire() }
|
||||||
|
}
|
||||||
|
scope.launch { runCatching { KiloServerConnection(api, serverDevice, Unit).run() } }
|
||||||
|
KiloConnectionData(object : Transport.Device {
|
||||||
|
override val input = responses
|
||||||
|
override val output = requests
|
||||||
|
override suspend fun close() { retire() }
|
||||||
|
}, Unit)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
withTimeout(5_000) { client.connectedStateFlow.first { it } }
|
||||||
|
// Several successful native encrypted heartbeats; no application RPC is exported.
|
||||||
|
client.ping()
|
||||||
|
delay(800)
|
||||||
|
assertTrue(client.connectedStateFlow.value)
|
||||||
|
assertEquals(1, attempts.get())
|
||||||
|
blocked.set(true)
|
||||||
|
withTimeout(5_000) { client.reconnectExhausted.first { it } }
|
||||||
|
// The healthy session earns a new budget: three failed recovery connections.
|
||||||
|
assertEquals(4, attempts.get())
|
||||||
|
assertFalse(client.connectedStateFlow.value)
|
||||||
|
delay(400)
|
||||||
|
assertEquals(4, attempts.get())
|
||||||
|
} finally { client.close(); scope.coroutineContext[Job]!!.cancelAndJoin() }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import net.sergeych.crypto2.SigningSecretKey
|
||||||
|
import net.sergeych.crypto2.initCrypto
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.utools.unpack
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
/** Real loopback carriers; impairment occurs before writing actual datagrams. */
|
||||||
|
class CarrierReleaseTest {
|
||||||
|
@Test fun udpDuplicatesReorderingAndConcurrentDuplex() = carrier(udp = true)
|
||||||
|
@Test fun tcpConcurrentDuplexAndPriority() = carrier(udp = false)
|
||||||
|
|
||||||
|
private fun carrier(udp: Boolean) = runBlocking {
|
||||||
|
withTimeout(20_000) {
|
||||||
|
initCrypto()
|
||||||
|
val tcp = if (!udp) TcpServer(0, "127.0.0.1") else null
|
||||||
|
val datagrams = if (udp) UdpServer(0, "127.0.0.1") else null
|
||||||
|
val received = Channel<Int>(Channel.UNLIMITED)
|
||||||
|
val echo by command<Int, Int>()
|
||||||
|
val notify by command<Int, Unit>()
|
||||||
|
val api = KiloInterface<Unit>().apply {
|
||||||
|
on(echo) { it }
|
||||||
|
on(notify) { received.send(it) }
|
||||||
|
}
|
||||||
|
val serverReady = CompletableDeferred<KiloServerConnection<Unit>>()
|
||||||
|
val serverKey = SigningSecretKey.new()
|
||||||
|
val collector = launch {
|
||||||
|
(tcp?.transportFlow ?: datagrams!!.transportFlow).collect { device ->
|
||||||
|
val server = KiloServerConnection(api, device, Unit, serverKey)
|
||||||
|
serverReady.complete(server)
|
||||||
|
launch { server.run() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val port = tcp?.boundPort() ?: datagrams!!.boundPort()
|
||||||
|
val raw = if (udp) connectUdpDevice(NetworkAddress("127.0.0.1", port))
|
||||||
|
else connectTcpDevice(NetworkAddress("127.0.0.1", port))
|
||||||
|
var held: UByteArray? = null
|
||||||
|
var impaired = false
|
||||||
|
val device = object : Transport.Device by raw {
|
||||||
|
override suspend fun send(block: UByteArray) {
|
||||||
|
val decoded = unpack<Transport.Block>(block)
|
||||||
|
if (udp && !impaired && decoded is Transport.Block.Call && decoded.name == "L0Call" && decoded.id == 0u) {
|
||||||
|
val first = held
|
||||||
|
if (first == null) held = block
|
||||||
|
else {
|
||||||
|
raw.send(block)
|
||||||
|
raw.send(first)
|
||||||
|
raw.send(first) // exact duplicate over the real UDP socket
|
||||||
|
impaired = true
|
||||||
|
}
|
||||||
|
} else raw.send(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ready = CompletableDeferred<Unit>()
|
||||||
|
val clientApi = KiloInterface<Unit>().apply {
|
||||||
|
onConnected { if (remoteIdentity != serverKey.verifyingKey) throw RemoteInterface.SecurityException() }
|
||||||
|
on(echo) { it }
|
||||||
|
}
|
||||||
|
val client = KiloClientConnection(clientApi, device, Unit)
|
||||||
|
val clientJob = launch { client.run { if (it) ready.complete(Unit) } }
|
||||||
|
try {
|
||||||
|
ready.await()
|
||||||
|
val server = serverReady.await()
|
||||||
|
client.push(notify, 1)
|
||||||
|
client.push(notify, 2)
|
||||||
|
val notices = setOf(received.receive(), received.receive())
|
||||||
|
assertEquals(setOf(1, 2), notices)
|
||||||
|
coroutineScope {
|
||||||
|
(0 until 32).map { n -> async {
|
||||||
|
val peer: RemoteInterface = if (n % 2 == 0) client else server
|
||||||
|
assertEquals(n, peer.call(echo, n, priority = if (n % 3 == 0) 100 else 0))
|
||||||
|
} }.awaitAll()
|
||||||
|
}
|
||||||
|
client.ping()
|
||||||
|
assertNull(withTimeoutOrNull(100) { received.receive() }, "Duplicate must not execute again")
|
||||||
|
assertTrue(clientJob.isActive)
|
||||||
|
if (udp) assertTrue(impaired)
|
||||||
|
} finally {
|
||||||
|
clientJob.cancelAndJoin()
|
||||||
|
raw.close()
|
||||||
|
collector.cancelAndJoin()
|
||||||
|
tcp?.close(); datagrams?.close()
|
||||||
|
received.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.InetAddress
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class SocketLifecycleTest {
|
||||||
|
@Test fun tcpRoundTripAndRebind() = runBlocking {
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = TcpServer(0, "127.0.0.1")
|
||||||
|
val port = server.boundPort()
|
||||||
|
val accepted = Channel<InetTransportDevice>(1)
|
||||||
|
val collector = launch { runCatching { server.transportFlow.collect { accepted.send(it) } } }
|
||||||
|
val client = connectTcpDevice(NetworkAddress("127.0.0.1", port))
|
||||||
|
val peer = accepted.receive()
|
||||||
|
try {
|
||||||
|
val payload = ubyteArrayOf(2u, 3u, 4u)
|
||||||
|
client.output.send(payload)
|
||||||
|
assertContentEquals(payload, peer.input.receive())
|
||||||
|
peer.output.send(payload)
|
||||||
|
assertContentEquals(payload, client.input.receive())
|
||||||
|
} finally { client.close(); server.close(); collector.cancelAndJoin() }
|
||||||
|
val replacement = TcpServer(port, "127.0.0.1")
|
||||||
|
try { assertEquals(port, replacement.boundPort()) } finally { replacement.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun udpMalformedPacketDoesNotStopListenerAndRebind() = runBlocking {
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val server = UdpServer(0, "127.0.0.1")
|
||||||
|
val port = server.boundPort()
|
||||||
|
val accepted = Channel<InetTransportDevice>(1)
|
||||||
|
val collector = launch { server.transportFlow.collect { accepted.send(it) } }
|
||||||
|
DatagramSocket().use { socket ->
|
||||||
|
socket.send(DatagramPacket(byteArrayOf(1), 1, InetAddress.getLoopbackAddress(), port))
|
||||||
|
}
|
||||||
|
val client = connectUdpDevice(NetworkAddress("127.0.0.1", port))
|
||||||
|
try {
|
||||||
|
val payload = ubyteArrayOf(2u, 3u, 4u)
|
||||||
|
client.output.send(payload)
|
||||||
|
val peer = accepted.receive()
|
||||||
|
assertContentEquals(payload, peer.input.receive())
|
||||||
|
peer.output.send(payload)
|
||||||
|
assertContentEquals(payload, client.input.receive())
|
||||||
|
} finally { client.close(); server.close(); collector.cancelAndJoin() }
|
||||||
|
val replacement = UdpServer(port, "127.0.0.1")
|
||||||
|
try { assertEquals(port, replacement.boundPort()) } finally { replacement.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun deviceCloseWorksInCancelledContext() = runBlocking {
|
||||||
|
withTimeout(10_000) {
|
||||||
|
val tcp = TcpServer(0, "127.0.0.1")
|
||||||
|
val udp = UdpServer(0, "127.0.0.1")
|
||||||
|
val peers = Channel<InetTransportDevice>(2)
|
||||||
|
val tcpCollector = launch { runCatching { tcp.transportFlow.collect { peers.send(it) } } }
|
||||||
|
val udpCollector = launch { udp.transportFlow.collect { peers.send(it) } }
|
||||||
|
val tcpClient = connectTcpDevice(NetworkAddress("127.0.0.1", tcp.boundPort()))
|
||||||
|
val udpClient = connectUdpDevice(NetworkAddress("127.0.0.1", udp.boundPort()))
|
||||||
|
udpClient.output.send(ubyteArrayOf(2u, 3u))
|
||||||
|
val firstPeer = peers.receive()
|
||||||
|
val secondPeer = peers.receive()
|
||||||
|
try {
|
||||||
|
val closer = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
currentCoroutineContext().cancel()
|
||||||
|
tcpClient.close()
|
||||||
|
udpClient.close()
|
||||||
|
}
|
||||||
|
closer.join()
|
||||||
|
// EOF reaches both peers even when caller cancellation precedes close.
|
||||||
|
for (peer in listOf(firstPeer, secondPeer)) {
|
||||||
|
while (peer.input.receiveCatching().isSuccess) { }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
tcpClient.close(); udpClient.close(); tcp.close(); udp.close()
|
||||||
|
tcpCollector.cancelAndJoin(); udpCollector.cancelAndJoin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun bindFailuresAreReported() = runBlocking {
|
||||||
|
val tcp = TcpServer(0, "127.0.0.1")
|
||||||
|
val udp = UdpServer(0, "127.0.0.1")
|
||||||
|
try {
|
||||||
|
val duplicateTcp = TcpServer(tcp.boundPort(), "127.0.0.1")
|
||||||
|
try { assertFails { duplicateTcp.boundPort() } } finally { duplicateTcp.close() }
|
||||||
|
val duplicateUdp = UdpServer(udp.boundPort(), "127.0.0.1")
|
||||||
|
try { assertFails { duplicateUdp.boundPort() } } finally { duplicateUdp.close() }
|
||||||
|
} finally { tcp.close(); udp.close() }
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class WebsocketClientResourceTest {
|
||||||
|
@Test
|
||||||
|
fun failedReconnectsDoNotLeakFileDescriptors() = runBlocking {
|
||||||
|
val descriptors = Path.of("/proc/self/fd")
|
||||||
|
if (!Files.isDirectory(descriptors)) return@runBlocking
|
||||||
|
|
||||||
|
val client = websocketClient<Unit>("ws://127.0.0.1:1/kp")
|
||||||
|
try {
|
||||||
|
delay(2_500)
|
||||||
|
val before = descriptorCount(descriptors)
|
||||||
|
delay(7_000)
|
||||||
|
val after = descriptorCount(descriptors)
|
||||||
|
assertTrue(after <= before + 3, "file descriptors grew from $before to $after")
|
||||||
|
} finally {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun descriptorCount(path: Path): Long = Files.list(path).use { it.count() }
|
||||||
|
}
|
||||||
@@ -22,11 +22,11 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import net.sergeych.kiloparsec.*
|
import net.sergeych.kiloparsec.*
|
||||||
import net.sergeych.mp_logger.*
|
import net.sergeych.mp_logger.*
|
||||||
import net.sergeych.mp_tools.globalLaunch
|
import net.sergeych.mp_tools.globalLaunch
|
||||||
import net.sergeych.tools.AtomicCounter
|
import net.sergeych.tools.AtomicCounter
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
private val logCounter = AtomicCounter(0)
|
private val logCounter = AtomicCounter(0)
|
||||||
@@ -40,28 +40,66 @@ internal val PING_INACTIVITY_TIME = 30.seconds
|
|||||||
* Listen for incoming TCP/IP connections on all local interfaces and the specified [port]
|
* Listen for incoming TCP/IP connections on all local interfaces and the specified [port]
|
||||||
* anc create flow of [InetTransportDevice] suitable for [KiloClient].
|
* anc create flow of [InetTransportDevice] suitable for [KiloClient].
|
||||||
*/
|
*/
|
||||||
fun acceptTcpDevice(port: Int, localInterface: String = "0.0.0.0"): Flow<InetTransportDevice> {
|
fun acceptTcpDevice(port: Int, localInterface: String = "0.0.0.0"): Flow<InetTransportDevice> = flow {
|
||||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
val server = TcpServer(port, localInterface)
|
||||||
return flow {
|
try { server.transportFlow.collect { emit(it) } }
|
||||||
val serverSocket = aSocket(selectorManager).tcp().bind(localInterface, port)
|
finally { withContext(NonCancellable) { server.close() } }
|
||||||
while (true) {
|
|
||||||
serverSocket.accept().let { sock ->
|
|
||||||
emit(inetTransportDevice(sock, "srv"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectTcpDevice(address: String) = connectTcpDevice(address.toNetworkAddress())
|
/**
|
||||||
|
* Connect to the TCP/IP server (see [KiloServer]) at the specified address and provide th compatible
|
||||||
|
* [InetTransportDevice] to use with [KiloClient]. [hostPortAddress] is a string in the form of `host:port`.
|
||||||
|
*/
|
||||||
|
suspend fun connectTcpDevice(hostPortAddress: String): InetTransportDevice =
|
||||||
|
connectTcpDevice(hostPortAddress.toNetworkAddress())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to the TCP/IP server (see [KiloServer]) at the specified address and provide th compatible
|
* Connect to the TCP/IP server (see [KiloServer]) at the specified address and provide th compatible
|
||||||
* [InetTransportDevice] to use with [KiloClient].
|
* [InetTransportDevice] to use with [KiloClient].
|
||||||
*/
|
*/
|
||||||
suspend fun connectTcpDevice(address: NetworkAddress): InetTransportDevice {
|
suspend fun connectTcpDevice(address: NetworkAddress): InetTransportDevice = withContext(Dispatchers.IO) {
|
||||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
val selectorManager = SelectorManager(Dispatchers.IO)
|
||||||
val socket = aSocket(selectorManager).tcp().connect(address.host, address.port)
|
try {
|
||||||
return inetTransportDevice(socket)
|
val socket = aSocket(selectorManager).tcp().connect(address.host, address.port)
|
||||||
|
return@withContext inetTransportDevice(socket, onClose = { selectorManager.close() })
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
selectorManager.close()
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Establish the existing TCP carrier through a caller-selected HTTP CONNECT relay. */
|
||||||
|
suspend fun connectTcpDeviceThroughHttpProxy(
|
||||||
|
address: NetworkAddress,
|
||||||
|
proxy: NetworkAddress,
|
||||||
|
authorization: String,
|
||||||
|
): InetTransportDevice = withContext(Dispatchers.IO) {
|
||||||
|
require(!authorization.contains('\r') && !authorization.contains('\n'))
|
||||||
|
require(!address.host.any { it.isWhitespace() || it == '\r' || it == '\n' })
|
||||||
|
val selector = SelectorManager(Dispatchers.IO)
|
||||||
|
var socket: Socket? = null
|
||||||
|
try {
|
||||||
|
socket = aSocket(selector).tcp().connect(proxy.host, proxy.port)
|
||||||
|
val input = socket.openReadChannel()
|
||||||
|
val output = socket.openWriteChannel()
|
||||||
|
val target = "${address.host}:${address.port}"
|
||||||
|
output.writeStringUtf8("CONNECT $target HTTP/1.1\r\nHost: $target\r\nProxy-Authorization: $authorization\r\n\r\n")
|
||||||
|
output.flush()
|
||||||
|
val status = input.readUTF8Line(4096) ?: error("Proxy closed before response")
|
||||||
|
require(status.startsWith("HTTP/1.1 200 ") || status.startsWith("HTTP/1.0 200 ")) { "CONNECT refused" }
|
||||||
|
var headerBytes = status.length
|
||||||
|
while (true) {
|
||||||
|
val line = input.readUTF8Line(4096) ?: error("Incomplete proxy response")
|
||||||
|
headerBytes += line.length
|
||||||
|
require(headerBytes <= 16384) { "Proxy headers too large" }
|
||||||
|
if (line.isEmpty()) break
|
||||||
|
}
|
||||||
|
return@withContext inetTransportDevice(socket, onClose = { selector.close() }, providedInput = input, providedOutput = output)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
socket?.close()
|
||||||
|
selector.close()
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,147 +110,84 @@ fun String.toNetworkAddress(): NetworkAddress {
|
|||||||
return NetworkAddress(host, port.toInt())
|
return NetworkAddress(host, port.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun inetTransportDevice(
|
internal fun inetTransportDevice(
|
||||||
sock: Socket,
|
sock: Socket,
|
||||||
suffix: String = "cli",
|
suffix: String = "cli",
|
||||||
|
providedInput: ByteReadChannel? = null,
|
||||||
|
providedOutput: ByteWriteChannel? = null,
|
||||||
|
onClose: suspend () -> Unit = {},
|
||||||
): InetTransportDevice {
|
): InetTransportDevice {
|
||||||
val networkAddress = (sock.remoteAddress as InetSocketAddress).let { NetworkAddress(it.hostname, it.port) }
|
val networkAddress = (sock.remoteAddress as InetSocketAddress).let { NetworkAddress(it.hostname, it.port) }
|
||||||
val inputBlocks = Channel<UByteArray>(4096)
|
val inputBlocks = Channel<UByteArray>(4096)
|
||||||
val outputBlocks = Channel<UByteArray>(4096)
|
// A push must backpressure on the writer, not build thousands of blocks ahead
|
||||||
|
// of latency-sensitive calls sharing this carrier.
|
||||||
val log = LogTag("TCPT${logCounter.incrementAndGet()}:$suffix:$networkAddress")
|
val outputBlocks = Channel<UByteArray>(Channel.RENDEZVOUS)
|
||||||
val job = AtomicAsyncValue<Job?>(null)
|
val sockOutput = providedOutput ?: sock.openWriteChannel()
|
||||||
|
val sockInput = providedInput ?: sock.openReadChannel()
|
||||||
val sockOutput = sock.openWriteChannel()
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
val sockInput = runCatching { sock.openReadChannel() }.getOrElse {
|
val closeAccess = Mutex()
|
||||||
log.warning { "failed to open read channel $it" }
|
var closed = false
|
||||||
throw IllegalStateException("failed to open read channel")
|
suspend fun stop() = withContext(NonCancellable) {
|
||||||
}
|
closeAccess.withLock {
|
||||||
|
if (!closed) {
|
||||||
suspend fun stop() {
|
closed = true
|
||||||
job.mutate {
|
inputBlocks.close()
|
||||||
if (it != null) {
|
outputBlocks.close()
|
||||||
log.debug { "stopping" }
|
sockInput.cancel()
|
||||||
runCatching { inputBlocks.close() }
|
sock.close()
|
||||||
runCatching { outputBlocks.close() }
|
scope.cancel()
|
||||||
// The problem: on mac platofrms closing the socket does not close its input
|
onClose()
|
||||||
// and output channels!
|
}
|
||||||
runCatching { sockInput.cancel() }
|
|
||||||
runCatching { sockOutput.flushAndClose() }
|
|
||||||
if (!sock.isClosed)
|
|
||||||
runCatching {
|
|
||||||
log.debug { "closing socket by stop" }
|
|
||||||
sock.close()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
log.debug { "socket is already closed when stop is called" }
|
|
||||||
it.cancel()
|
|
||||||
log.debug { "implementation job cancel called" }
|
|
||||||
} else
|
|
||||||
log.debug { "already stopped" }
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastActiveAt = Clock.System.now()
|
var lastActiveAt = Clock.System.now()
|
||||||
globalLaunch {
|
val writeAccess = Mutex()
|
||||||
job.reset(globalLaunch {
|
scope.launch {
|
||||||
launch {
|
try {
|
||||||
|
while (isActive) {
|
||||||
log.debug { "opening read channel" }
|
val size = AsyncVarint.decodeUnsigned(sockInput)
|
||||||
|
if (size > MAX_TCP_BLOCK_SIZE.toULong())
|
||||||
while (isActive && sock.isActive) {
|
throw ProtocolException("Illegal block size: $size")
|
||||||
try {
|
lastActiveAt = Clock.System.now()
|
||||||
val size = AsyncVarint.decodeUnsigned(sockInput).toInt()
|
if (size != 0uL) {
|
||||||
if (size > MAX_TCP_BLOCK_SIZE) // 16M is a max command block
|
val data = ByteArray(size.toInt())
|
||||||
throw ProtocolException("Illegal block size: $size should be < $MAX_TCP_BLOCK_SIZE")
|
sockInput.readFully(data, 0, data.size)
|
||||||
val data = ByteArray(size)
|
inputBlocks.send(data.toUByteArray())
|
||||||
if (size == 0) {
|
|
||||||
log.debug { "ping received" }
|
|
||||||
lastActiveAt = Clock.System.now()
|
|
||||||
} else {
|
|
||||||
sockInput.readFully(data, 0, size)
|
|
||||||
inputBlocks.send(data.toUByteArray())
|
|
||||||
}
|
|
||||||
} catch (e: ClosedReceiveChannelException) {
|
|
||||||
log.error { "closed receive channel " }
|
|
||||||
stop()
|
|
||||||
break
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
log.error { "cancellation exception " }
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.exception { "unexpected exception in TCP socket read" to e }
|
|
||||||
stop()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
} catch (_: Exception) {
|
||||||
launch {
|
// EOF, cancellation, or invalid framing ends only this connection.
|
||||||
val outAccess = Mutex()
|
} finally { stop() }
|
||||||
var lastSentAt = Clock.System.now()
|
|
||||||
launch {
|
|
||||||
while (isActive && sock.isActive) {
|
|
||||||
delay(500)
|
|
||||||
val activityTime = if (lastSentAt > lastActiveAt) lastSentAt else lastActiveAt
|
|
||||||
if (Clock.System.now() - activityTime > PING_INACTIVITY_TIME) {
|
|
||||||
log.debug { "pinging for inactivity" }
|
|
||||||
val repeat = outAccess.withLock {
|
|
||||||
try {
|
|
||||||
sockOutput.writeByte(0)
|
|
||||||
sockOutput.flush()
|
|
||||||
lastSentAt = Clock.System.now()
|
|
||||||
true
|
|
||||||
} catch (e: ClosedReceiveChannelException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
stop()
|
|
||||||
false
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
false
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
e.printStackTrace()
|
|
||||||
stop()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!repeat) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (isActive && sock.isActive) {
|
|
||||||
try {
|
|
||||||
val block = outputBlocks.receive()
|
|
||||||
outAccess.withLock {
|
|
||||||
AsyncVarint.encodeUnsigned(block.size.toULong(), sockOutput)
|
|
||||||
sockOutput.writeFully(block.toByteArray(), 0, block.size)
|
|
||||||
sockOutput.flush()
|
|
||||||
lastSentAt = Clock.System.now()
|
|
||||||
}
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
log.debug { "cancellation exception on output" }
|
|
||||||
stop()
|
|
||||||
break
|
|
||||||
} catch (_: LocalInterface.BreakConnectionException) {
|
|
||||||
log.debug { "requested connection break" }
|
|
||||||
stop()
|
|
||||||
break
|
|
||||||
} catch (_: ClosedReceiveChannelException) {
|
|
||||||
log.debug { "receive block channel closed, closing the socket" }
|
|
||||||
stop()
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.exception { "unexpected exception. closing." to e }
|
|
||||||
stop()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val device = InetTransportDevice(inputBlocks, outputBlocks, networkAddress, {
|
val priorityWriter = PriorityBlockWriter(scope) { block ->
|
||||||
stop()
|
try {
|
||||||
})
|
writeAccess.withLock {
|
||||||
log.debug { "Transport ready" }
|
AsyncVarint.encodeUnsigned(block.size.toULong(), sockOutput)
|
||||||
return device
|
sockOutput.writeFully(block.asByteArray(), 0, block.size)
|
||||||
|
sockOutput.flush()
|
||||||
|
lastActiveAt = Clock.System.now()
|
||||||
|
}
|
||||||
|
} catch (failure: Throwable) { stop(); throw failure }
|
||||||
|
}
|
||||||
|
// Preserve the public legacy output channel; current transports use send directly.
|
||||||
|
scope.launch {
|
||||||
|
try { for (block in outputBlocks) priorityWriter.send(block) }
|
||||||
|
finally { priorityWriter.close(); stop() }
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
while (isActive) {
|
||||||
|
delay(500)
|
||||||
|
writeAccess.withLock {
|
||||||
|
if (Clock.System.now() - lastActiveAt > PING_INACTIVITY_TIME) {
|
||||||
|
sockOutput.writeByte(0)
|
||||||
|
sockOutput.flush()
|
||||||
|
lastActiveAt = Clock.System.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
} finally { stop() }
|
||||||
|
}
|
||||||
|
return InetTransportDevice(inputBlocks, outputBlocks, networkAddress, { stop() }).also { it.priorityWriter = priorityWriter }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package net.sergeych.kiloparsec.adapter
|
||||||
|
|
||||||
|
import io.ktor.network.selector.SelectorManager
|
||||||
|
import io.ktor.network.sockets.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/** Managed listener using the same framing as [connectTcpDevice]. Collect once. */
|
||||||
|
class TcpServer(val port: Int, localInterface: String = "0.0.0.0") {
|
||||||
|
private val selector = SelectorManager(Dispatchers.IO)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val socket = scope.async { aSocket(selector).tcp().bind(localInterface, port) }
|
||||||
|
private val access = Mutex()
|
||||||
|
private val connections = mutableSetOf<InetTransportDevice>()
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
/** Wait for binding, propagating bind errors; supports port zero. */
|
||||||
|
suspend fun boundPort(): Int = (socket.await().localAddress as InetSocketAddress).port
|
||||||
|
|
||||||
|
val transportFlow = flow {
|
||||||
|
try {
|
||||||
|
val listener = socket.await()
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val accepted = listener.accept()
|
||||||
|
val device = access.withLock {
|
||||||
|
if (closed) { accepted.close(); null }
|
||||||
|
else {
|
||||||
|
lateinit var device: InetTransportDevice
|
||||||
|
device = inetTransportDevice(accepted, "srv") {
|
||||||
|
access.withLock { connections.remove(device) }
|
||||||
|
}
|
||||||
|
connections.add(device)
|
||||||
|
device
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (device != null) emit(device)
|
||||||
|
}
|
||||||
|
} finally { withContext(NonCancellable) { close() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun close() = withContext(NonCancellable) {
|
||||||
|
val pending = access.withLock {
|
||||||
|
if (closed) return@withContext
|
||||||
|
closed = true
|
||||||
|
connections.toList().also { connections.clear() }
|
||||||
|
}
|
||||||
|
runCatching { socket.await().close() }
|
||||||
|
pending.forEach { runCatching { it.close() } }
|
||||||
|
scope.cancel()
|
||||||
|
selector.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,17 +122,20 @@ suspend fun connectUdpDevice(
|
|||||||
maxInactivityTimeout: Duration = 2.minutes,
|
maxInactivityTimeout: Duration = 2.minutes,
|
||||||
): InetTransportDevice {
|
): InetTransportDevice {
|
||||||
val selectorManager = SelectorManager(Dispatchers.IO)
|
val selectorManager = SelectorManager(Dispatchers.IO)
|
||||||
val remoteAddress = InetSocketAddress(addr.host, addr.port)
|
val remoteAddress = withContext(Dispatchers.IO) { InetSocketAddress(addr.host, addr.port) }
|
||||||
|
|
||||||
val done = CompletableDeferred<Unit>()
|
val done = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
val socket = aSocket(selectorManager).udp().connect(remoteAddress)
|
val socket = try { aSocket(selectorManager).udp().connect(remoteAddress) }
|
||||||
|
catch (failure: Throwable) { selectorManager.close(); throw failure }
|
||||||
val transport = UdpSocketTransport(object : UdpConnector {
|
val transport = UdpSocketTransport(object : UdpConnector {
|
||||||
override suspend fun sendBlock(block: UdpBlock, toAddress: SocketAddress) {
|
override suspend fun sendBlock(block: UdpBlock, toAddress: SocketAddress) {
|
||||||
socket.send(block.toDatagram(remoteAddress))
|
socket.send(block.toDatagram(remoteAddress))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun disconnectClient(address: SocketAddress) {
|
override suspend fun disconnectClient(address: SocketAddress) {
|
||||||
|
socket.close()
|
||||||
|
selectorManager.close()
|
||||||
done.complete(Unit)
|
done.complete(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +154,8 @@ suspend fun connectUdpDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
done.await()
|
try { done.await() }
|
||||||
|
finally { withContext(NonCancellable) { transport.close() }; coroutineContext.cancelChildren() }
|
||||||
}
|
}
|
||||||
|
|
||||||
return transport.transportDevice
|
return transport.transportDevice
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ package net.sergeych.kiloparsec.adapter
|
|||||||
|
|
||||||
import io.ktor.network.selector.*
|
import io.ktor.network.selector.*
|
||||||
import io.ktor.network.sockets.*
|
import io.ktor.network.sockets.*
|
||||||
import io.ktor.utils.io.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
@@ -60,12 +60,16 @@ class UdpServer(val port: Int, localInterface: String = "0.0.0.0", maxInactivity
|
|||||||
|
|
||||||
private val sessions = mutableMapOf<SocketAddress, UdpSocketTransport>()
|
private val sessions = mutableMapOf<SocketAddress, UdpSocketTransport>()
|
||||||
private val access = Mutex()
|
private val access = Mutex()
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
private val selectorManager = SelectorManager(Dispatchers.IO)
|
private val selectorManager = SelectorManager(Dispatchers.IO)
|
||||||
private val serverSocket = globalDefer {
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val serverSocket = scope.async {
|
||||||
aSocket(selectorManager).udp().bind(InetSocketAddress(localInterface, port))
|
aSocket(selectorManager).udp().bind(InetSocketAddress(localInterface, port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun boundPort(): Int = (serverSocket.await().localAddress as InetSocketAddress).port
|
||||||
|
|
||||||
override suspend fun disconnectClient(address: SocketAddress) {
|
override suspend fun disconnectClient(address: SocketAddress) {
|
||||||
access.withLock { sessions.remove(address) }
|
access.withLock { sessions.remove(address) }
|
||||||
}
|
}
|
||||||
@@ -76,13 +80,16 @@ class UdpServer(val port: Int, localInterface: String = "0.0.0.0", maxInactivity
|
|||||||
*/
|
*/
|
||||||
val transportFlow by lazy {
|
val transportFlow by lazy {
|
||||||
flow {
|
flow {
|
||||||
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
val datagram = serverSocket.await().receive()
|
val datagram = serverSocket.await().receive()
|
||||||
val block = UdpBlock.decode(datagram)
|
val block = try { UdpBlock.decode(datagram) }
|
||||||
|
catch (_: Exception) { continue } // Malformed traffic must not stop the listener.
|
||||||
val remoteAddress = datagram.address
|
val remoteAddress = datagram.address
|
||||||
|
|
||||||
access.withLock {
|
access.withLock {
|
||||||
|
if (closed) return@withLock
|
||||||
if (block == UdpBlock.Cancel) {
|
if (block == UdpBlock.Cancel) {
|
||||||
// if the cancel comes to already closed transport, do nothing
|
// if the cancel comes to already closed transport, do nothing
|
||||||
sessions.remove(remoteAddress)?.processIncoming(block)
|
sessions.remove(remoteAddress)?.processIncoming(block)
|
||||||
@@ -106,6 +113,7 @@ class UdpServer(val port: Int, localInterface: String = "0.0.0.0", maxInactivity
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally { withContext(NonCancellable) { close() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,17 +132,14 @@ class UdpServer(val port: Int, localInterface: String = "0.0.0.0", maxInactivity
|
|||||||
*
|
*
|
||||||
* Call suspends until socket and all sessions are closed. Later calls do nothing.
|
* Call suspends until socket and all sessions are closed. Later calls do nothing.
|
||||||
*/
|
*/
|
||||||
suspend fun close() {
|
suspend fun close() = withContext(NonCancellable) {
|
||||||
access.withLock {
|
runCatching { serverSocket.await().close() }
|
||||||
if (!isClosed()) {
|
val pending = access.withLock {
|
||||||
runCatching { serverSocket.await().close() }
|
closed = true
|
||||||
}
|
sessions.values.toList().also { sessions.clear() }
|
||||||
}
|
|
||||||
while (sessions.isNotEmpty()) {
|
|
||||||
runCatching {
|
|
||||||
access.withLock { sessions.values.firstOrNull() }
|
|
||||||
?.close()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
pending.forEach { runCatching { it.close() } }
|
||||||
|
scope.cancel()
|
||||||
|
selectorManager.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,16 +16,19 @@ import kotlinx.coroutines.channels.BufferOverflow
|
|||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import net.sergeych.kiloparsec.SyncValue
|
import net.sergeych.kiloparsec.SyncValue
|
||||||
import net.sergeych.mp_logger.Log
|
import net.sergeych.mp_logger.Log
|
||||||
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_logger.exception
|
||||||
import net.sergeych.mp_tools.globalLaunch
|
import net.sergeych.mp_tools.globalLaunch
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,10 +57,13 @@ internal class UdpSocketTransport(
|
|||||||
private val pingMinTimeout = pingTimeout * 2 / 3
|
private val pingMinTimeout = pingTimeout * 2 / 3
|
||||||
|
|
||||||
val inputDataBlocks = Channel<UByteArray>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
val inputDataBlocks = Channel<UByteArray>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
val outputDataBlocks = Channel<UByteArray>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
val outputDataBlocks = Channel<UByteArray>()
|
||||||
|
private val writerReady = CompletableDeferred<PriorityBlockWriter>()
|
||||||
|
|
||||||
val inputUdpBlocks = Channel<UdpBlock>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
val inputUdpBlocks = Channel<UdpBlock>(256, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
|
||||||
|
private val closedFlag = SyncValue(false)
|
||||||
|
|
||||||
private val job = globalLaunch {
|
private val job = globalLaunch {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch { convertOutput() }
|
launch { convertOutput() }
|
||||||
@@ -75,20 +81,22 @@ internal class UdpSocketTransport(
|
|||||||
|
|
||||||
|
|
||||||
val transportDevice: InetTransportDevice by lazy {
|
val transportDevice: InetTransportDevice by lazy {
|
||||||
InetTransportDevice(inputDataBlocks, outputDataBlocks, address, { close() }, {})
|
InetTransportDevice(inputDataBlocks, outputDataBlocks, address, { close() }, {}).also { device ->
|
||||||
|
device.prioritizedSend = { writerReady.await().send(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val closedFlag = SyncValue(false)
|
val isClosed: Boolean get() = closedFlag.value
|
||||||
|
|
||||||
val isClosed: Boolean = closedFlag.value
|
suspend fun close(): Unit = withContext(NonCancellable) {
|
||||||
|
|
||||||
suspend fun close() {
|
|
||||||
closedFlag.mutate {
|
closedFlag.mutate {
|
||||||
if (!it) {
|
if (!it) {
|
||||||
runCatching { server.sendBlock(UdpBlock.Cancel, socketAddress) }
|
runCatching { server.sendBlock(UdpBlock.Cancel, socketAddress) }
|
||||||
server.disconnectClient(socketAddress)
|
server.disconnectClient(socketAddress)
|
||||||
runCatching { inputDataBlocks.close() }
|
runCatching { inputDataBlocks.close() }
|
||||||
runCatching { outputDataBlocks.close() }
|
runCatching { outputDataBlocks.close() }
|
||||||
|
runCatching { inputUdpBlocks.close() }
|
||||||
|
writerReady.completeExceptionally(net.sergeych.kiloparsec.RemoteInterface.ClosedException())
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
@@ -167,23 +175,27 @@ internal class UdpSocketTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun convertOutput() {
|
private suspend fun convertOutput(): Unit = coroutineScope {
|
||||||
while (!isClosed) {
|
val writer = PriorityBlockWriter(this) { server.sendBlock(UdpBlock.Data(it), socketAddress) }
|
||||||
try {
|
writerReady.complete(writer)
|
||||||
server.sendBlock(UdpBlock.Data(outputDataBlocks.receive()), socketAddress)
|
try {
|
||||||
} catch (e: CancellationException) {
|
while (!isClosed) {
|
||||||
// this is ok
|
try {
|
||||||
break
|
writer.send(outputDataBlocks.receive())
|
||||||
} catch (e: ClosedReceiveChannelException) {
|
} catch (_: CancellationException) {
|
||||||
debug { "input channel is closed, closing" }
|
// this is ok
|
||||||
close()
|
break
|
||||||
break
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
} catch (e: Exception) {
|
debug { "input channel is closed, closing" }
|
||||||
exception { "unexpected exception in convertOutput" to e }
|
close()
|
||||||
close()
|
break
|
||||||
break
|
} catch (e: Exception) {
|
||||||
|
exception { "unexpected exception in convertOutput" to e }
|
||||||
|
close()
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} finally { writer.close() }
|
||||||
debug { "exiting convertOutput" }
|
debug { "exiting convertOutput" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tgz
|
||||||
|
measurement/
|
||||||
|
evaluation/.gradle/
|
||||||
|
evaluation/.kotlin/
|
||||||
|
evaluation/build/
|
||||||
|
evaluation/kotlin-js-store/
|
||||||
|
|
||||||
|
# Generated size/build measurement reports
|
||||||
|
/evaluation/artifacts.json
|
||||||
|
/evaluation/measurements*.json
|
||||||
|
/evaluation/wasm-measurements.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Copyright (c) 2026 Sergey S. Chernov. All rights reserved.
|
||||||
|
Use, distribution and modification require the author's private license,
|
||||||
|
consistent with the original Kiloparsec and crypto2 libraries.
|
||||||
|
Contact: real dot sergeych at gmail.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Kiloparsec TypeScript + WASM
|
||||||
|
|
||||||
|
Full-protocol TypeScript client for the existing Kotlin Kiloparsec server, backed
|
||||||
|
by libsodium WASM. No Kotlin runtime, no light handshake, no TLS-only identity
|
||||||
|
fallback. This is an evaluated implementation candidate, **not a production release**.
|
||||||
|
|
||||||
|
This module is maintained alongside the Kotlin implementation in this repository.
|
||||||
|
Both peers use the hardened 0.8 protocol: encrypted packets authenticate their
|
||||||
|
RPC envelope as well as their payload. Legacy peers are deliberately rejected;
|
||||||
|
there is no automatic downgrade. See [security and migration](docs/SECURITY.md).
|
||||||
|
|
||||||
|
The [assessment](docs/ASSESSMENT.md) records the original implementation choice
|
||||||
|
and historical size measurements. Re-run the measurement script for this revision;
|
||||||
|
those recorded measurements describe the earlier published protocol baseline.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
Client, LocalInterface, command, string, generateSigningKey,
|
||||||
|
} from '@sergeych/kiloparsec';
|
||||||
|
|
||||||
|
const echo = command('echo', string, string);
|
||||||
|
const clientEcho = command('clientEcho', string, string);
|
||||||
|
const local = new LocalInterface<{ requests: number }>()
|
||||||
|
.on(clientEcho, (scope, text) => {
|
||||||
|
scope.session.requests++;
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
const identity = await generateSigningKey();
|
||||||
|
// Persist identity.exportSecret() in your secure store before registering it.
|
||||||
|
// Register identity.publicKey with the application separately.
|
||||||
|
const client = await Client.connect('wss://your-service.example/kp', {
|
||||||
|
expectedServerKey, // trusted raw 32-byte Ed25519 key, provisioned independently
|
||||||
|
identity,
|
||||||
|
local,
|
||||||
|
session: { requests: 0 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const text = await client.call(echo, 'Hello');
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
identity.destroy();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`expectedServerKey` is mandatory. A signature by an arbitrary key supplied by the
|
||||||
|
peer is insufficient. Anonymous *client* sessions are available by omitting
|
||||||
|
identity; their permissions remain a server application decision. `restoreSigningKey`
|
||||||
|
restores raw 64-byte secret exports; generic Kiloparsec uses bytes, while an SDK
|
||||||
|
such as GrandMobile may wrap them in its own opaque versioned format.
|
||||||
|
|
||||||
|
`command(name, argsCodec, resultCodec)` mirrors Kotlin `Command<A,R>`.
|
||||||
|
`LocalInterface.on` registers typed callbacks before connection; each connection
|
||||||
|
gets a snapshot of the registry and a session object. A callback can call its
|
||||||
|
peer and await the result without blocking packet dispatch. Methods on `Client`:
|
||||||
|
|
||||||
|
- `call(command, args, {signal, timeoutMs, priority})`: typed authenticated payload
|
||||||
|
or `RemoteError`. `ConnectionError` describes local transport failure.
|
||||||
|
- `push(command, args)`: sends an id=0 notification; successful return means local
|
||||||
|
enqueue only, not acknowledged remote execution.
|
||||||
|
- `ping()`: encrypted liveness check. Automatic heartbeat defaults to 30 seconds;
|
||||||
|
set `heartbeatMs: 0` to disable it.
|
||||||
|
- `close()` and `closed`: dispose the connection and observe local disposal.
|
||||||
|
|
||||||
|
Timeout/abort stops waiting, not remote execution. No transparent replay or
|
||||||
|
reconnect: create a fresh connection and let the application resolve uncertain
|
||||||
|
business outcomes using its durable operation IDs. Transport IDs are unrelated.
|
||||||
|
Local handler failures are sanitized unless explicitly returned as `RemoteError`.
|
||||||
|
|
||||||
|
Node >=22 uses its standard WebSocket. Other runtimes can provide `socketFactory`.
|
||||||
|
Both ws and wss carry the same encrypted protocol; deployment policy chooses the
|
||||||
|
URL. Browser type/build compatibility is present but browser runtime tests have
|
||||||
|
not been performed. Do not treat Node interoperability as browser qualification.
|
||||||
|
|
||||||
|
## Serialization compatibility
|
||||||
|
|
||||||
|
Interop tests build the Kotlin server from this checkout through a Gradle
|
||||||
|
composite build; no published Kiloparsec snapshot is needed.
|
||||||
|
`src/bipack.ts` implements explicit codecs; it does not dynamically recreate all
|
||||||
|
Kotlin serialization. Exported codecs cover strings, bytes, bool, Unit, integers,
|
||||||
|
nullable values and lists. Compose records with `codec`, `BipackReader` and
|
||||||
|
`BipackWriter` in the same field order as Kotlin; no field names go on the wire.
|
||||||
|
|
||||||
|
`uint32`/`uint64` encode unsigned Smartint (`@Unsigned` semantics). Published
|
||||||
|
mp_bintools 0.3.2 serializes unannotated Kotlin UInt through its signed backing
|
||||||
|
Int: the handshake codec preserves this behavior. Newer source 0.4.0 changes that
|
||||||
|
behavior. Do not upgrade either peer silently. Signed Smartint cannot represent
|
||||||
|
Long.MIN_VALUE correctly in this baseline; TS rejects it explicitly.
|
||||||
|
|
||||||
|
Packet sizes, concurrent incoming work, pending calls and send buffers are bounded.
|
||||||
|
Receive replay tracking permits out-of-order counters within 4096 positions and
|
||||||
|
silently discards duplicates and packets outside the window without closing the session. Authenticated envelopes additionally bind packet kind, RPC ID and priority
|
||||||
|
to the encrypted payload. Priority trailers
|
||||||
|
are preserved, but this first client uses WebSocket FIFO rather than a priority
|
||||||
|
scheduler. No TCP/UDP adapters, carrier cycling, automatic reconnection, or code
|
||||||
|
generator are included in this version.
|
||||||
|
|
||||||
|
## Build and verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm test # build + local codec/crypto/RPC regression tests
|
||||||
|
npm run test:interop # build current Kotlin fixture + cross-language tests
|
||||||
|
npm run test:all # both suites
|
||||||
|
# Optional optimized Kotlin comparison baseline:
|
||||||
|
npm run baseline:build
|
||||||
|
npm ci --prefix evaluation --ignore-scripts
|
||||||
|
npm run measure
|
||||||
|
```
|
||||||
|
|
||||||
|
JDK 21 and the Android SDK configuration required by the parent Kotlin build must
|
||||||
|
be available for interoperability and baseline builds. `test:interop` starts and
|
||||||
|
stops its own disposable localhost JVM fixture. Generated outputs and dependencies
|
||||||
|
are not source-controlled. Package publication remains disabled pending release
|
||||||
|
qualification. From the parent repository, `bin/test-all.sh` runs Kotlin and TS
|
||||||
|
unit tests followed by interoperability tests sequentially.
|
||||||
|
|
||||||
|
## Follow-up: reusable utility exports
|
||||||
|
|
||||||
|
After the GrandMobile game API, complete the supported public utility surface:
|
||||||
|
BiPack, key generation/restoration, signing/verification, standalone encryption
|
||||||
|
and hashes. BiPack and signing identities are already exported from index.ts;
|
||||||
|
hash and session-crypto implementations currently remain internal package modules.
|
||||||
|
Define documented stable entry points, formats and usage examples before exposing
|
||||||
|
additional utilities. This is deferred work, not a prerequisite for the game API.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Оценка: полноценный Kiloparsec на TypeScript + WASM
|
||||||
|
|
||||||
|
Историческая оценка протокола 1, 18 октября 2026. После оценки библиотека перенесена
|
||||||
|
в модуль typescript основного репозитория Kiloparsec; протокол 2 исправляет найденные
|
||||||
|
KP-01/KP-02. Текущее состояние и регрессии: [SECURITY.md](SECURITY.md).
|
||||||
|
Числа ниже относятся к первому совместимому v1-варианту, не к новой v2-сборке.
|
||||||
|
|
||||||
|
Решение: **продолжать отдельную TS/WASM-библиотеку**.
|
||||||
|
Вариант light исключён. Kotlin/JS-обёртка оставлена как измеряемый эталон, а не
|
||||||
|
вторая продуктовая реализация. Сервер и исходный Kiloparsec не изменялись.
|
||||||
|
|
||||||
|
## Что сравнивали
|
||||||
|
|
||||||
|
Контрольные суммы использованных JVM-артефактов: [artifacts.json](../evaluation/artifacts.json).
|
||||||
|
|
||||||
|
Опубликованная Kotlin-библиотека поставляется как `.klib`, а не готовый TS API.
|
||||||
|
В исходниках нет публичного `@JsExport`-фасада. Поэтому сравнивали не размер klib,
|
||||||
|
а готовые минимизированные подключаемые пакеты:
|
||||||
|
|
||||||
|
- TS: все экспорты новой библиотеки, полный DH, подписи и шифрование, RPC,
|
||||||
|
локальные интерфейсы и сессии, кодеки, ключи, WebSocket-клиент;
|
||||||
|
- Kotlin: отдельная рабочая экспортируемая обёртка над опубликованным Kiloparsec,
|
||||||
|
блочный адаптер без намеренного включения Ktor WebSocket-клиента, DH, pinning,
|
||||||
|
идентификация и echo. Production DCE Kotlin, затем та же минимизация esbuild.
|
||||||
|
|
||||||
|
Оба пакета включают одну версию libsodium-wrappers-sumo 0.7.15 и её WASM payload.
|
||||||
|
Сравнение благоприятно для Kotlin: его фасад существенно уже по API, чем TS.
|
||||||
|
Это не сравнение полного равенства возможностей или двух готовых релизов.
|
||||||
|
|
||||||
|
| Измерение | TS/WASM | Kotlin/JS baseline |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Минимизированный пакет | 1 124 612 байт | 1 982 755 байт |
|
||||||
|
| gzip, уровень 9 | 330 101 байт | 559 003 байта |
|
||||||
|
| Инициализация + создание Ed25519, медиана 5 процессов | 88,59 мс | 166,50 мс |
|
||||||
|
| RSS процесса после инициализации, медиана | 62,9 МБ | 70,6 МБ |
|
||||||
|
| Прирост RSS, медиана | 19,3 МБ | 27,0 МБ |
|
||||||
|
|
||||||
|
Выигрыш размера около **43%**, по gzip — **41%**. Время/RSS — локальное наблюдение
|
||||||
|
на одном хосте, не SLA; время не включает запуск Node. Числа воспроизводятся через
|
||||||
|
`scripts/measure.mjs`, исходные измерения сохранены в
|
||||||
|
[measurement record](../evaluation/measurements.json). Готовые измеряемые пакеты
|
||||||
|
проверены загрузкой; Kotlin bundle выполнил реальный authenticated echo.
|
||||||
|
Большая часть TS-пакета — готовая криптобиблиотека; специальная урезанная сборка
|
||||||
|
sodium здесь не создавалась и в выигрыш не заложена.
|
||||||
|
|
||||||
|
## Реально выполнено
|
||||||
|
|
||||||
|
TS-клиент работает с опубликованным JVM Kiloparsec 0.7.2-SNAPSHOT без его правок:
|
||||||
|
DH, проверка закреплённого серверного ключа, идентификация клиента, строки/числа/
|
||||||
|
списки, callback с вложенным обратным RPC, push, удалённые ошибки, 64 параллельных
|
||||||
|
вызова, большой строковый аргумент, ping и новое подключение после закрытия.
|
||||||
|
Для воспроизведения `npm run test:interop` запускает собственный локальный сервер.
|
||||||
|
|
||||||
|
Проверены unit-тесты кодирования, криптографические векторы, таймаут/abort/close,
|
||||||
|
полный дуплекс, pinning, подмена/истечение подписи, повреждение/повтор шифротекста.
|
||||||
|
Один тест намеренно воспроизводит обнаруженный изъян корреляции ответа.
|
||||||
|
Ни тест производительности реального сервиса, ни аудит всей библиотеки, ни
|
||||||
|
браузерные runtime-тесты этим не подменяются.
|
||||||
|
|
||||||
|
## Что выяснилось при переносе
|
||||||
|
|
||||||
|
1. Kotlin bridge требует корректного порядка загрузки sodium: импорт Kotlin до
|
||||||
|
sodium.ready ломает опубликованный binding. Обёртка должна это скрывать.
|
||||||
|
2. Неаннотированный UInt кодируется по-разному в опубликованном BiPack 0.3.2 и
|
||||||
|
более новом исходнике 0.4.0. Перенос проверен именно с опубликованными артефактами;
|
||||||
|
версии/контрольные суммы нужно закреплять, а не ориентироваться только на master.
|
||||||
|
3. Исходный signed Smartint не сохраняет Long.MIN_VALUE. TS явно отвергает это
|
||||||
|
значение, чтобы не выдавать тихо повреждённые данные.
|
||||||
|
4. **Существующий протокол позволяет переставлять ответы между RPC**, изменив
|
||||||
|
только внешние forId. Изъян воспроизведён. Есть также неподписанное подтверждение
|
||||||
|
клиентской идентификации. Подробности и предложения — [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Граница результата и следующее решение
|
||||||
|
|
||||||
|
Оценка закончена, путь выбран, рабочий совместимый клиент реализован отдельно в
|
||||||
|
`~/dev/kiloparsec/typescript`. Это кандидат библиотеки, не готовая поставка в биржу.
|
||||||
|
Первоначальный совместимый перенос не требовал изменения сервера, но унаследовал
|
||||||
|
KP-01/KP-02. По последующему решению пользователя обе стороны переведены на
|
||||||
|
протокол 2: ID/тип/приоритет аутентифицированы, завершение идентификации зашифровано.
|
||||||
|
Старая версия автоматически не выбирается. Технический разбор и доказательства
|
||||||
|
исправления находятся в SECURITY.md. Изменение сервера здесь относится к исходникам
|
||||||
|
библиотеки и локальному тестовому стенду; развёртывания сервисов не выполнялись.
|
||||||
|
|
||||||
|
Нет автоматического reconnect/повтора бизнес-команд, TCP/UDP/carrier cycling,
|
||||||
|
генератора всех Kotlin DTO или квалификации браузеров. Приоритет пакета передаётся
|
||||||
|
совместимо, но первый TS-клиент обслуживает выход FIFO. Эти ограничения явно
|
||||||
|
перечислены в README; они не скрыты обещанием полного совпадения API Kotlin.
|
||||||
|
|
||||||
|
Ни сервис GrandMobile, ни SDK Gold, ни сайт биржи этой работой не развёрнуты и
|
||||||
|
не переключены на новый транспорт. Модуль сопровождается вместе с Kotlin в одном репозитории; публикация не выполнялась.
|
||||||
|
|
||||||
|
## Повторный замер исправленного протокола 2
|
||||||
|
|
||||||
|
Текущий модуль 0.8.0: TS — 1 126 081 байт, gzip 330 527 байт; Kotlin/JS-обёртка
|
||||||
|
над текущими исходниками — 1 993 070 байт, gzip 561 965 байт. Выигрыш сохраняется:
|
||||||
|
примерно 43,5% по размеру и 41,2% по gzip. Медиана инициализации с генерацией
|
||||||
|
Ed25519: 87,0 мс против 169,6 мс на этом хосте. Полные исходные данные:
|
||||||
|
[measurements-v2.json](../evaluation/measurements-v2.json). Старые данные выше
|
||||||
|
сохранены как основание исходного выбора, а не выданы за текущий релиз.
|
||||||
|
|
||||||
|
## Сравнение с Kotlin/Wasm, протокол 2
|
||||||
|
|
||||||
|
Проверили также **настоящую оптимизированную Kotlin/Wasm-сборку**, а не `.klib`.
|
||||||
|
Модуль `evaluation/wasm-baseline` использует тот же исходник Kiloparsec через
|
||||||
|
Gradle composite build. Минимальная обёртка сохраняет DH, проверку серверного
|
||||||
|
ключа, идентификацию клиента и echo RPC — как Kotlin/JS baseline выше.
|
||||||
|
|
||||||
|
| Поставка | Размер, байт | gzip level 9, байт | Инициализация + Ed25519, медиана |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| TS + sodium WASM | 1 126 081 | 330 527 | 87,0 мс |
|
||||||
|
| Kotlin/JS + sodium WASM | 1 993 070 | 561 965 | 169,6 мс |
|
||||||
|
| Kotlin/Wasm + JS loader + sodium WASM | 2 083 682 | 616 011 | 116,1 мс |
|
||||||
|
|
||||||
|
По сравнению с Kotlin/Wasm TS меньше на **46,0% по исходному размеру** и на
|
||||||
|
**46,3% после gzip**. По сравнению с Kotlin/JS выигрыш составляет 43,5% и 41,2%.
|
||||||
|
|
||||||
|
В Kotlin/Wasm посчитаны два реально необходимых файла: оптимизированный Binaryen
|
||||||
|
исполняемый `.wasm` — 764 584 байта (gzip 247 073), минимизированный ESM loader
|
||||||
|
с зависимостями sodium/joda — 1 319 098 байт (gzip 368 938). Gzip применяется
|
||||||
|
**к каждому файлу отдельно**, после чего размеры складываются. Sodium WASM уже
|
||||||
|
встроен в loader и учитывается один раз; обе crypto-привязки используют один
|
||||||
|
CommonJS-модуль sodium. Карты исходников, неоптимизированный `.wasm`, `.klib`,
|
||||||
|
тестовый runtime и компилятор в поставку не включены.
|
||||||
|
|
||||||
|
Получившаяся поставка успешно загрузилась на Node 24.19.0, создала Ed25519-ключ,
|
||||||
|
выполнила DH, проверку закреплённого серверного ключа и authenticated echo с
|
||||||
|
локальным JVM-сервером протокола 2. Временный сервер после проверки остановлен.
|
||||||
|
Браузерные runtime-проверки этим не подменяются. Время/RSS остаются наблюдением
|
||||||
|
на одном Linux x64 хосте, а не обещанием производительности на других платформах.
|
||||||
|
|
||||||
|
[Переносимый протокол измерения](../evaluation/wasm-measurements.json) содержит
|
||||||
|
состав поставки, относительные пути, SHA-256 каждого файла и пять исходных замеров.
|
||||||
|
Для воспроизведения из `typescript/`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run wasm:build
|
||||||
|
npm ci --prefix evaluation --ignore-scripts
|
||||||
|
npm run measure:wasm
|
||||||
|
npm run fixture:build
|
||||||
|
npm run test:wasm-baseline
|
||||||
|
```
|
||||||
|
|
||||||
|
Инструкции и ограничения упаковки приведены в
|
||||||
|
[evaluation/README.md](../evaluation/README.md#optimized-kotlinwasm-comparison).
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Authenticated RPC and handshake: protocol 2
|
||||||
|
|
||||||
|
Protocol 2, implemented with library version 0.8.0, fixes two weaknesses found
|
||||||
|
while building the TypeScript client. Both Kotlin and TypeScript use this profile
|
||||||
|
by default and reject protocol 1. Deploy compatible peers together; there is no
|
||||||
|
automatic fallback to the vulnerable envelope.
|
||||||
|
|
||||||
|
## KP-01 — outer RPC response IDs were unauthenticated
|
||||||
|
|
||||||
|
The old Kotlin `KiloL0Interface` encrypted a result whose inner `forId` was always
|
||||||
|
zero. The actual pending RPC ID was in an unencrypted outer Transport.Response.
|
||||||
|
An active intermediary could exchange two outer IDs without modifying either
|
||||||
|
ciphertext. With compatible result codecs, request A received B's valid answer.
|
||||||
|
This was reproduced before the fix; passive decryption was not required.
|
||||||
|
|
||||||
|
Protocol 2 seals this record inside the existing session encryption:
|
||||||
|
|
||||||
|
```
|
||||||
|
ProtectedMessage(kind, correlation, priority, payload)
|
||||||
|
kind = call | push | response | error | server_finished
|
||||||
|
```
|
||||||
|
|
||||||
|
A call's correlation is its actual nonzero outer Transport ID; a push has zero.
|
||||||
|
Before application lookup/deserialization, the receiver validates the protected
|
||||||
|
kind, ID and priority against the outer dispatch metadata. Responses/errors echo
|
||||||
|
the authenticated request ID and priority. The caller checks these before decoding
|
||||||
|
or delivering a result. The inner response/error Block remains forId=0, but no
|
||||||
|
longer supplies the request correlation. Rewriting a response's outer forId now
|
||||||
|
closes the connection instead of resolving the wrong call. Rewriting call/push or
|
||||||
|
priority likewise fails before application dispatch. An unencrypted outer error
|
||||||
|
is not accepted as an authenticated application result.
|
||||||
|
|
||||||
|
Session nonces are replay-checked on both peers, including pushes. A bounded
|
||||||
|
4096-position window supports responses completed out of order and rejects replay
|
||||||
|
or excessively stale traffic by silently discarding those packets, without closing
|
||||||
|
the session. A discarded response leaves the pending call waiting for a valid reply
|
||||||
|
or its timeout/cancellation. Replay state uses a circular bitmap; unauthenticated
|
||||||
|
packets never advance it. Kotlin bounds local priority overtaking to 2730 nonce
|
||||||
|
positions and serializes nonce allocation, sealing and queue admission. Send nonces and transport IDs do not wrap. Nonce
|
||||||
|
state used during identity exchange is preserved when the identity is completed.
|
||||||
|
|
||||||
|
The routing envelope still allows denial of service by an active carrier: packets
|
||||||
|
can be dropped, delayed or redirected to unknown IDs. Channel encryption cannot
|
||||||
|
force an adversarial carrier to deliver data. Public outer priority may influence
|
||||||
|
transport scheduling before the receiver authenticates it; it cannot authorize
|
||||||
|
an altered application call.
|
||||||
|
|
||||||
|
## KP-02 — identity acknowledgement was plaintext
|
||||||
|
|
||||||
|
The old `L0ClientId` returned a plaintext Unit. A relay could forge successful
|
||||||
|
completion without the server accepting the client's identity.
|
||||||
|
|
||||||
|
The v2 reply is an encrypted `server_finished` frame with correlation/priority zero
|
||||||
|
and payload equal to the exact ClientIdentity plaintext the server accepted.
|
||||||
|
The client validates all of these before returning a connected client or invoking
|
||||||
|
its connected handlers. A plaintext Unit or an altered identity is rejected.
|
||||||
|
Server-side per-operation authorization remains mandatory.
|
||||||
|
|
||||||
|
Server and client proofs use different signing domains:
|
||||||
|
|
||||||
|
```
|
||||||
|
UTF8("kiloparsec/v2/server\0") || sessionToken
|
||||||
|
UTF8("kiloparsec/v2/client\0") || sessionToken
|
||||||
|
```
|
||||||
|
|
||||||
|
Here `\0` denotes one NUL byte, not a backslash and zero on the wire. This binds
|
||||||
|
the proof to the role and version. A legacy signature of the token alone is invalid.
|
||||||
|
The handshake version is exactly 2 at both peers; version changes are rejected.
|
||||||
|
The server also rejects a repeated handshake/identity exchange within a connection
|
||||||
|
and creates a fresh exchange keypair per connection.
|
||||||
|
|
||||||
|
## Identity trust
|
||||||
|
|
||||||
|
The TypeScript client requires a preconfigured Ed25519 server key and validates its
|
||||||
|
Seal before signing the client proof. Kotlin retains its existing application
|
||||||
|
policy for optional remote identity: a valid Seal establishes possession of its
|
||||||
|
key, not external trust in that key. Kotlin applications must check the expected
|
||||||
|
remoteIdentity as part of their connection policy; anonymous sessions should be
|
||||||
|
used only where explicitly authorized. Version 2 fixes RPC binding; it does not
|
||||||
|
silently invent a key provisioning or trust-on-first-use policy.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `test/security.test.mjs`: swapped responses reject; wrong pin, version/role/token
|
||||||
|
signature substitution, forged/mismatched finished, expired/tampered server Seal,
|
||||||
|
damaged packets and altered call IDs/kinds/priorities fail closed; duplicates
|
||||||
|
and stale packets are discarded without executing again or closing the session.
|
||||||
|
- Kotlin `AuthenticatedEnvelopeTest`: normal signed duplex and session identities,
|
||||||
|
response-ID swap, call-to-push rewrite, priority rewrite, replay, forged Unit
|
||||||
|
acknowledgement and protocol downgrade.
|
||||||
|
- The interoperability fixture consumes this checkout, rather than an old Maven
|
||||||
|
snapshot, and runs TS ↔ JVM full-duplex calls, errors, pushes, concurrency and
|
||||||
|
reconnect to a fresh session.
|
||||||
|
|
||||||
|
These regressions exercise the identified flaws. They are not a general independent
|
||||||
|
cryptographic audit. Browser runtime and sustained load remain separate release
|
||||||
|
qualification work. Secret export is explicit; wiping library-owned byte buffers
|
||||||
|
does not promise erasure of copies made by a host VM or device.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
|
**/build/
|
||||||
|
node_modules/
|
||||||
|
baseline-browser.min.js
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Kotlin compatibility fixture and size baseline
|
||||||
|
|
||||||
|
This fixture uses a Gradle composite build to consume Kiloparsec source from
|
||||||
|
the repository root (`../..`), with explicit dependency substitution. The JVM fixture uses
|
||||||
|
`setupWebsocketServer` and `KiloInterface` unchanged.
|
||||||
|
|
||||||
|
## Build and start the reference server
|
||||||
|
|
||||||
|
From the TypeScript module directory (requires JDK 21 and the parent build's Android SDK configuration):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run fixture:build
|
||||||
|
npm run baseline:build
|
||||||
|
JAVA_HOME=/path/to/jdk21 evaluation/server/build/install/server/bin/server
|
||||||
|
```
|
||||||
|
|
||||||
|
Default endpoint: `ws://127.0.0.1:18732/kp`; optional first argument overrides port.
|
||||||
|
The server binds localhost only. Its deterministic Ed25519 **TEST-ONLY** seed is
|
||||||
|
bytes 0 through 31. Public key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands use ordinary Kotlin serialization/BiPack:
|
||||||
|
|
||||||
|
| Command | Arguments | Result / behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `echo` | String | same String |
|
||||||
|
| `sum` | List<Int> | Int sum |
|
||||||
|
| `identity` | Unit | authenticated remote public key raw lowercase hex, or `anonymous` |
|
||||||
|
| `callback` | String | server calls `clientEcho(String): String`, returns its result |
|
||||||
|
| `push` | String | server calls `notice(String): Unit`, waits for acknowledgment |
|
||||||
|
| `fail` | String | throws registered `IllegalArgumentException` with that message |
|
||||||
|
|
||||||
|
The fixture supports anonymous clients to allow explicit coverage of identity
|
||||||
|
presence/absence; a production application's authorization policy must decide
|
||||||
|
which identities may invoke its methods.
|
||||||
|
|
||||||
|
## Optimized Kotlin/JS baseline
|
||||||
|
|
||||||
|
`baseline` exports a usable `BaselineClient` accepting binary block callbacks.
|
||||||
|
It retains full DH, signed identity, expected server identity checking and RPC,
|
||||||
|
without retaining the Ktor WebSocket client intentionally. This is the smaller
|
||||||
|
bridge option rather than a deliberately inflated full library bundle.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci --prefix evaluation --ignore-scripts
|
||||||
|
node evaluation/baseline-smoke.cjs
|
||||||
|
node_modules/.bin/esbuild evaluation/baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js --bundle --minify --platform=browser --external:fs --external:path --external:crypto --outfile=evaluation/baseline-browser.min.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`fs`, `path`, `crypto` are libsodium's conditional Node imports, unused in browser
|
||||||
|
execution. Bundle includes libsodium-sumo WASM payload. Source maps are excluded.
|
||||||
|
Kotlin production compiler performs dead-code elimination, followed by esbuild
|
||||||
|
minification. npm explicitly pins sodium wrapper 0.7.15 for comparison with TS.
|
||||||
|
|
||||||
|
For the common comparison, use the same **Node CJS** platform for both libraries:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
node scripts/measure.mjs
|
||||||
|
node evaluation/baseline-smoke.cjs --bundled
|
||||||
|
```
|
||||||
|
|
||||||
|
Results are saved in `evaluation/build/measurement/results.json`. A recorded run
|
||||||
|
is preserved in [measurements.json](measurements.json). The script performs five
|
||||||
|
fresh subprocess measurements, from immediately before module loading through
|
||||||
|
WASM initialization and generation of one Ed25519 identity. It does not include
|
||||||
|
process startup. RSS includes Node; RSS delta is resident growth during workload.
|
||||||
|
|
||||||
|
Historical measurements of the pre-hardening published 0.7.2 baseline,
|
||||||
|
2026-10-18 on Node 24.19.0, Linux x64 (not regenerated for current source):
|
||||||
|
|
||||||
|
| Measure | TS full exports | Kotlin minimal bridge |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Minified self-contained Node bundle, bytes | 1,124,612 | 1,982,755 |
|
||||||
|
| Gzip level 9, bytes | 330,101 | 559,003 |
|
||||||
|
| Cold init + identity median, ms | 88.59 | 166.50 |
|
||||||
|
| Resident memory median, bytes | 62,898,176 | 70,574,080 |
|
||||||
|
| Resident growth median, bytes | 19,300,352 | 26,976,256 |
|
||||||
|
|
||||||
|
The Kotlin baseline exposes only echo and the bridge plus the measurement identity
|
||||||
|
helper; TS includes all exported API. This is not a claim of full feature parity.
|
||||||
|
Both include sodium's WASM payload. Both actual standalone bundles initialize
|
||||||
|
successfully; the measured Kotlin bundle additionally passed authenticated,
|
||||||
|
pinned-server echo to the JVM fixture. Measurements vary by host and revision.
|
||||||
|
|
||||||
|
The Kotlin sodium bindings eagerly capture imported functions: await
|
||||||
|
`require('libsodium-wrappers-sumo').ready` **before loading the compiled bridge**,
|
||||||
|
as shown in the smoke script. Loading the bridge first produces `_sodium_init is
|
||||||
|
not a function` with these published dependencies. That observation concerns the historical published baseline.
|
||||||
|
The browser bundle's size was measured; browser execution is not claimed by the
|
||||||
|
Node smoke check. The bridge is an evaluation fixture, not a polished production
|
||||||
|
wrapper (bounded buffering, lifecycle error propagation, and key ownership would
|
||||||
|
need finishing if this alternative were selected).
|
||||||
|
|
||||||
|
## Protocol 2 validation and measurements
|
||||||
|
|
||||||
|
Current-source protocol 2 results are in [measurements-v2.json](measurements-v2.json).
|
||||||
|
TS: 1,126,081 bytes / gzip330,527; Kotlin baseline: 1,993,070 / gzip561,965.
|
||||||
|
Both measured bundles completed pinned-server echo against the local protocol2
|
||||||
|
fixture. Historical measurements above remain unchanged for traceability.
|
||||||
|
The full root verification command passed 43 JVM tests, 28 Kotlin/JS Node tests,
|
||||||
|
35 TS unit/security tests, and the real TS/JVM interoperability scenario.
|
||||||
|
|
||||||
|
## Optimized Kotlin/Wasm comparison
|
||||||
|
|
||||||
|
`wasm-baseline` retains the same full handshake, server key pin, fresh client
|
||||||
|
identity and echo RPC as the JS baseline. It exports a small string/callback
|
||||||
|
bridge, avoiding non-exportable Kotlin objects at the JS/Wasm boundary.
|
||||||
|
|
||||||
|
From the TypeScript module directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run wasm:build
|
||||||
|
npm ci --prefix evaluation --ignore-scripts
|
||||||
|
npm run measure:wasm
|
||||||
|
npm run fixture:build
|
||||||
|
npm run test:wasm-baseline
|
||||||
|
```
|
||||||
|
|
||||||
|
The measurement uses the **optimized production executable** `.wasm` emitted by
|
||||||
|
Binaryen, together with an esbuild-minified ESM loader containing sodium and joda.
|
||||||
|
The raw `.klib`, unoptimized compiler output, maps and test runtime are excluded.
|
||||||
|
Both the raw and wrapper sodium imports share the same CommonJS module instance,
|
||||||
|
so the crypto payload is counted once. The wrapper is pinned to 0.7.15; its locked
|
||||||
|
transitive raw sodium dependency is 0.7.16, as in the JS/TS comparison.
|
||||||
|
|
||||||
|
The Kotlin loader must receive the sodium modules after `sodium.ready`. The small
|
||||||
|
`wasm-entry.mjs` adapter does this, supplies the expected default/namespace shapes,
|
||||||
|
and includes Node CommonJS helpers for the bundled sodium dependency. It does not
|
||||||
|
change the protocol or cryptography. The optimized asset stays beside `client.mjs`.
|
||||||
|
|
||||||
|
Recorded Node/Linux results are in [wasm-measurements.json](wasm-measurements.json).
|
||||||
|
The runnable distribution consists of two assets:
|
||||||
|
|
||||||
|
| Asset | Bytes | Gzip level 9 bytes |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Minified loader, sodium and joda | 1,319,098 | 368,938 |
|
||||||
|
| Optimized Kotlin executable | 764,584 | 247,073 |
|
||||||
|
| Total | 2,083,682 | 616,011 |
|
||||||
|
|
||||||
|
Gzip is applied separately to each transferred asset and the sizes are summed.
|
||||||
|
The distribution passed initialization, Ed25519 identity generation, full DH,
|
||||||
|
server pinning and authenticated echo against the local protocol-2 JVM fixture.
|
||||||
|
This qualifies the comparison on Node; no browser runtime result is claimed.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Kotlin binding imports must execute after sodium resolves its WASM functions.
|
||||||
|
exports.load = async function load() {
|
||||||
|
await require('libsodium-wrappers-sumo').ready;
|
||||||
|
return require('./baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js');
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Check that the measured baseline is a usable full client, not dead-code-only output.
|
||||||
|
const { WebSocket } = require('ws');
|
||||||
|
async function main() {
|
||||||
|
if (!process.argv.includes('--bundled')) await require('libsodium-wrappers-sumo').ready;
|
||||||
|
const { BaselineClient } = process.argv.includes('--bundled')
|
||||||
|
? await require('./build/measurement/kotlin.cjs').load()
|
||||||
|
: require('./baseline/build/compileSync/js/main/productionExecutable/kotlin/kiloparsec-evaluation-baseline.js');
|
||||||
|
const socket = new WebSocket(process.env.KILOPARSEC_TEST_URL ?? 'ws://127.0.0.1:18732/kp');
|
||||||
|
const client = new BaselineClient(bytes => socket.send(Buffer.from(bytes)));
|
||||||
|
socket.on('message', bytes => client.receiveBlock(new Int8Array(bytes)));
|
||||||
|
socket.on('open', async () => {
|
||||||
|
try {
|
||||||
|
await client.connect('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8');
|
||||||
|
const result = await client.echo('Kotlin baseline ✓');
|
||||||
|
if (result !== 'Kotlin baseline ✓') throw Error('Wrong echo');
|
||||||
|
console.log('BASELINE_AUTHENTICATED_ECHO_OK');
|
||||||
|
} catch (error) { console.error(error); process.exitCode = 1; }
|
||||||
|
finally { client.close(); socket.close(); }
|
||||||
|
});
|
||||||
|
socket.on('error', error => { console.error(error); process.exitCode = 1; });
|
||||||
|
setTimeout(() => { console.error('Baseline timeout'); process.exit(1); }, 10000).unref();
|
||||||
|
|
||||||
|
}
|
||||||
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
plugins { kotlin("multiplatform"); kotlin("plugin.serialization") }
|
||||||
|
kotlin {
|
||||||
|
js { nodejs(); binaries.executable(); generateTypeScriptDefinitions() }
|
||||||
|
sourceSets { jsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0") } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@file:OptIn(ExperimentalJsExport::class, kotlin.ExperimentalUnsignedTypes::class)
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import net.sergeych.crypto2.*
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.kiloparsec.adapter.ProxyDevice
|
||||||
|
import kotlin.js.Promise
|
||||||
|
|
||||||
|
/** Native JS WebSocket bridge: retains the full authenticated protocol without Ktor client. */
|
||||||
|
@JsExport
|
||||||
|
class BaselineClient(private val sendBlock: (ByteArray) -> Unit) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val input = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
private val output = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
private var client: KiloClientConnection<Unit>? = null
|
||||||
|
|
||||||
|
fun receiveBlock(bytes: ByteArray) { check(input.trySend(bytes.toUByteArray()).isSuccess) }
|
||||||
|
fun connect(expectedPublicKeyHex: String): Promise<Unit> = scope.promise {
|
||||||
|
initCrypto()
|
||||||
|
val local = KiloInterface<Unit>()
|
||||||
|
val connection = KiloClientConnection(local, ProxyDevice(input, output), Unit, SigningSecretKey.new())
|
||||||
|
client = connection
|
||||||
|
scope.launch { for (block in output) sendBlock(block.asByteArray()) }
|
||||||
|
scope.launch { connection.run() }
|
||||||
|
val actual = connection.remoteId()?.keyBytes?.joinToString("") { it.toString(16).padStart(2, '0') }
|
||||||
|
if (actual != expectedPublicKeyHex) { close(); error("Unexpected server identity") }
|
||||||
|
}
|
||||||
|
fun echo(text: String): Promise<String> = scope.promise {
|
||||||
|
val echo by command<String, String>()
|
||||||
|
requireNotNull(client).call(echo, text)
|
||||||
|
}
|
||||||
|
fun close() { scope.cancel() }
|
||||||
|
}
|
||||||
|
fun main() {}
|
||||||
|
|
||||||
|
/** Evaluation-only cold startup workload: initialize sodium and generate one identity. */
|
||||||
|
@JsExport
|
||||||
|
fun initializeIdentity(): Promise<String> = CoroutineScope(Dispatchers.Default).promise {
|
||||||
|
initCrypto()
|
||||||
|
SigningSecretKey.new().verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm") version "2.2.21" apply false
|
||||||
|
kotlin("multiplatform") version "2.2.21" apply false
|
||||||
|
kotlin("plugin.serialization") version "2.2.21" apply false
|
||||||
|
}
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
mavenLocal(); mavenCentral()
|
||||||
|
maven("https://maven.universablockchain.com/")
|
||||||
|
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||||
|
maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven")
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+63
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "evaluation",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@js-joda/core": "3.2.0",
|
||||||
|
"format-util": "1.0.5",
|
||||||
|
"libsodium-wrappers-sumo": "0.7.15",
|
||||||
|
"ws": "8.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@js-joda/core": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@js-joda/core/-/core-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/format-util": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/libsodium-sumo": {
|
||||||
|
"version": "0.7.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
|
||||||
|
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/libsodium-wrappers-sumo": {
|
||||||
|
"version": "0.7.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz",
|
||||||
|
"integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-sumo": "^0.7.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"private":true,"dependencies":{"@js-joda/core":"3.2.0","libsodium-wrappers-sumo":"0.7.15","format-util":"1.0.5","ws":"8.18.0"}}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
plugins { kotlin("jvm"); kotlin("plugin.serialization"); application }
|
||||||
|
kotlin { jvmToolchain(21); compilerOptions { optIn.add("kotlin.ExperimentalUnsignedTypes") } }
|
||||||
|
dependencies {
|
||||||
|
implementation("net.sergeych:kiloparsec:0.8.0")
|
||||||
|
implementation("io.ktor:ktor-server-netty:3.1.1")
|
||||||
|
implementation("org.slf4j:slf4j-simple:2.0.17")
|
||||||
|
}
|
||||||
|
application { mainClass.set("InteropServerKt") }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import io.ktor.server.engine.*
|
||||||
|
import io.ktor.server.netty.*
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import net.sergeych.crypto2.*
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.kiloparsec.adapter.setupWebsocketServer
|
||||||
|
|
||||||
|
// Public reproducible TEST KEY ONLY. Never use this identity outside tests.
|
||||||
|
fun main(args: Array<String>) = runBlocking {
|
||||||
|
initCrypto()
|
||||||
|
val key = SigningSecretKey(("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
|
||||||
|
"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8").chunked(2).map { it.toUByte(16) }.toUByteArray())
|
||||||
|
val echo by command<String, String>()
|
||||||
|
val sum by command<List<Int>, Int>()
|
||||||
|
val identity by command<Unit, String>()
|
||||||
|
val callback by command<String, String>()
|
||||||
|
val clientEcho by command<String, String>()
|
||||||
|
val push by command<String, Unit>()
|
||||||
|
val notice by command<String, Unit>()
|
||||||
|
val fail by command<String, String>()
|
||||||
|
val api = KiloInterface<Unit>().apply {
|
||||||
|
on(echo) { it }
|
||||||
|
on(sum) { it.sum() }
|
||||||
|
on(identity) { remoteIdentity?.keyBytes?.joinToString("") { b -> b.toString(16).padStart(2, '0') } ?: "anonymous" }
|
||||||
|
on(callback) { remote.call(clientEcho, it) }
|
||||||
|
on(push) { remote.call(notice, it) }
|
||||||
|
on(fail) { throw IllegalArgumentException(it) }
|
||||||
|
}
|
||||||
|
val port = args.firstOrNull()?.toInt() ?: 18732
|
||||||
|
println("TEST_SERVER_PUBLIC_KEY_HEX=" + key.verifyingKey.keyBytes.joinToString("") { it.toString(16).padStart(2, '0') })
|
||||||
|
println("TEST_SERVER_URL=ws://127.0.0.1:$port/kp")
|
||||||
|
embeddedServer(Netty, port = port, host = "127.0.0.1") {
|
||||||
|
setupWebsocketServer(api, serverKey = key) { Unit }
|
||||||
|
}.start(wait = true)
|
||||||
|
Unit
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } }
|
||||||
|
rootProject.name = "kiloparsec-evaluation"
|
||||||
|
include("server", "baseline", "wasm-baseline")
|
||||||
|
|
||||||
|
// Both fixtures resolve this checkout, never a stale published snapshot.
|
||||||
|
includeBuild("../..") {
|
||||||
|
dependencySubstitution {
|
||||||
|
substitute(module("net.sergeych:kiloparsec")).using(project(":"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
|
||||||
|
plugins { kotlin("multiplatform"); kotlin("plugin.serialization") }
|
||||||
|
kotlin {
|
||||||
|
@OptIn(ExperimentalWasmDsl::class)
|
||||||
|
wasmJs { nodejs(); binaries.executable() }
|
||||||
|
sourceSets { wasmJsMain.dependencies { implementation("net.sergeych:kiloparsec:0.8.0") } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@file:OptIn(kotlin.js.ExperimentalJsExport::class, kotlin.ExperimentalUnsignedTypes::class)
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import net.sergeych.crypto2.*
|
||||||
|
import net.sergeych.kiloparsec.*
|
||||||
|
import net.sergeych.kiloparsec.adapter.ProxyDevice
|
||||||
|
import kotlin.js.JsExport
|
||||||
|
|
||||||
|
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private var input = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
private var client: KiloClientConnection<Unit>? = null
|
||||||
|
private fun UByteArray.hex() = joinToString("") { it.toString(16).padStart(2, '0') }
|
||||||
|
private fun String.bytes() = chunked(2).map { it.toUByte(16) }.toUByteArray()
|
||||||
|
|
||||||
|
/** Full-protocol evaluation bridge. Hex avoids exposing Kotlin objects to JS. */
|
||||||
|
@JsExport
|
||||||
|
fun start(expectedPublicKeyHex: String, sendBlock: (String) -> Unit, onReady: () -> Unit, onError: (String) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
initCrypto()
|
||||||
|
val output = Channel<UByteArray>(Channel.UNLIMITED)
|
||||||
|
val connection = KiloClientConnection(KiloInterface<Unit>(), ProxyDevice(input, output), Unit, SigningSecretKey.new())
|
||||||
|
client = connection
|
||||||
|
scope.launch { for (block in output) sendBlock(block.hex()) }
|
||||||
|
scope.launch { connection.run() }
|
||||||
|
if (connection.remoteId()?.keyBytes?.hex() != expectedPublicKeyHex) error("Unexpected server identity")
|
||||||
|
onReady()
|
||||||
|
} catch (e: Throwable) { onError(e.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsExport
|
||||||
|
fun receiveBlock(hex: String) { check(input.trySend(hex.bytes()).isSuccess) }
|
||||||
|
|
||||||
|
@JsExport
|
||||||
|
fun echo(text: String, onResult: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val echo by command<String, String>()
|
||||||
|
onResult(requireNotNull(client).call(echo, text))
|
||||||
|
} catch (e: Throwable) { onError(e.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsExport
|
||||||
|
fun close() { scope.cancel() }
|
||||||
|
|
||||||
|
/** Same startup workload as the JS and TS comparison: sodium plus one identity. */
|
||||||
|
@JsExport
|
||||||
|
fun initializeIdentity(onResult: (String) -> Unit, onError: (String) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try { initCrypto(); onResult(SigningSecretKey.new().verifyingKey.keyBytes.hex()) }
|
||||||
|
catch (e: Throwable) { onError(e.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun main() {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Sodium must be initialized before instantiating Kotlin/Wasm bindings.
|
||||||
|
import sodiumModules from './wasm-sodium.cjs';
|
||||||
|
const { sodium, rawSodium } = sodiumModules;
|
||||||
|
import * as joda from '@js-joda/core';
|
||||||
|
import { instantiate } from './wasm-baseline/build/compileSync/wasmJs/main/productionExecutable/kotlin/kiloparsec-evaluation-wasm-baseline.uninstantiated.mjs';
|
||||||
|
await sodium.ready;
|
||||||
|
const { exports: api } = await instantiate({
|
||||||
|
'@js-joda/core': joda,
|
||||||
|
'libsodium-sumo': rawSodium,
|
||||||
|
'libsodium-wrappers-sumo': { default: sodium },
|
||||||
|
});
|
||||||
|
export { api };
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Disposable localhost interop of the measured optimized Wasm distribution.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { setTimeout as pause } from 'node:timers/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
const listener = net.createServer();
|
||||||
|
await new Promise((resolve,reject)=>listener.once('error',reject).listen(0,'127.0.0.1',resolve));
|
||||||
|
const port=listener.address().port;
|
||||||
|
await new Promise(resolve=>listener.close(resolve));
|
||||||
|
const server=spawn(fileURLToPath(new URL('./server/build/install/server/bin/server',import.meta.url)),[String(port)],{stdio:['ignore','pipe','pipe']});
|
||||||
|
let diagnostics=''; let exited=false; let socket; let api;
|
||||||
|
server.stdout.on('data',b=>{ diagnostics=(diagnostics+b).slice(-8000); });
|
||||||
|
server.stderr.on('data',b=>{ diagnostics=(diagnostics+b).slice(-8000); });
|
||||||
|
server.on('exit',()=>{exited=true;});
|
||||||
|
try {
|
||||||
|
let ready=false;
|
||||||
|
for(let n=0;n<100&&!exited;n++) {
|
||||||
|
ready=await new Promise(resolve=>{const s=net.connect(port,'127.0.0.1');s.once('connect',()=>{s.destroy();resolve(true);});s.once('error',()=>resolve(false));});
|
||||||
|
if(ready) break;
|
||||||
|
await pause(100);
|
||||||
|
}
|
||||||
|
if(!ready) throw Error(`Fixture did not start: ${diagnostics}`);
|
||||||
|
({api}=await import('./build/measurement/wasm/client.mjs'));
|
||||||
|
socket=new WebSocket(`ws://127.0.0.1:${port}/kp`);
|
||||||
|
socket.on('message',bytes=>api.receiveBlock(Buffer.from(bytes).toString('hex')));
|
||||||
|
await new Promise((resolve,reject)=>{
|
||||||
|
const timeout=setTimeout(()=>reject(Error('Wasm handshake timeout')),10000);
|
||||||
|
socket.once('error',reject);
|
||||||
|
socket.once('open',()=>api.start('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8',
|
||||||
|
hex=>socket.send(Buffer.from(hex,'hex')),()=>{clearTimeout(timeout);resolve();},message=>{clearTimeout(timeout);reject(Error(message));}));
|
||||||
|
});
|
||||||
|
const result=await new Promise((resolve,reject)=>{
|
||||||
|
const timeout=setTimeout(()=>reject(Error('Wasm echo timeout')),10000);
|
||||||
|
api.echo('Kotlin Wasm baseline ✓',value=>{clearTimeout(timeout);resolve(value);},message=>{clearTimeout(timeout);reject(Error(message));});
|
||||||
|
});
|
||||||
|
if(result!=='Kotlin Wasm baseline ✓') throw Error('Wrong echo');
|
||||||
|
console.log('WASM_BASELINE_AUTHENTICATED_PINNED_ECHO_OK');
|
||||||
|
} catch(e) { console.error(String(e));process.exitCode=1; }
|
||||||
|
finally {
|
||||||
|
api?.close(); socket?.terminate(); server.kill('SIGTERM');
|
||||||
|
if(!exited){const timer=setTimeout(()=>server.kill('SIGKILL'),3000);await new Promise(resolve=>server.once('exit',resolve));clearTimeout(timer);}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Match the wrapper's CommonJS import path to retain a single sodium instance.
|
||||||
|
exports.sodium = require('libsodium-wrappers-sumo');
|
||||||
|
exports.rawSodium = require('libsodium-sumo');
|
||||||
Generated
+577
@@ -0,0 +1,577 @@
|
|||||||
|
{
|
||||||
|
"name": "@sergeych/kiloparsec",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@sergeych/kiloparsec",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-wrappers-sumo": "0.7.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/libsodium-wrappers-sumo": "0.7.8",
|
||||||
|
"esbuild": "0.25.12",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"ws": "8.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/libsodium-wrappers": {
|
||||||
|
"version": "0.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz",
|
||||||
|
"integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/libsodium-wrappers-sumo": {
|
||||||
|
"version": "0.7.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.8.tgz",
|
||||||
|
"integrity": "sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/libsodium-wrappers": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libsodium-sumo": {
|
||||||
|
"version": "0.7.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.16.tgz",
|
||||||
|
"integrity": "sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/libsodium-wrappers-sumo": {
|
||||||
|
"version": "0.7.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz",
|
||||||
|
"integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-sumo": "^0.7.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "@sergeych/kiloparsec",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "TypeScript client for the full Kiloparsec protocol with libsodium WASM",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE",
|
||||||
|
"docs",
|
||||||
|
"evaluation/measurements.json",
|
||||||
|
"evaluation/artifacts.json",
|
||||||
|
"evaluation/measurements-v2.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "npm run build && node --test test/*.test.mjs",
|
||||||
|
"test:interop": "npm run build && npm run fixture:build && node scripts/interop.mjs",
|
||||||
|
"measure": "npm run build && node scripts/measure.mjs",
|
||||||
|
"fixture:build": "../gradlew -p evaluation :server:installDist",
|
||||||
|
"test:all": "npm test && npm run test:interop",
|
||||||
|
"baseline:build": "../gradlew -p evaluation :baseline:jsProductionExecutableCompileSync",
|
||||||
|
"wasm:build": "../gradlew -p evaluation :wasm-baseline:wasmJsProductionExecutableCompileSync",
|
||||||
|
"measure:wasm": "node scripts/measure-wasm.mjs",
|
||||||
|
"test:wasm-baseline": "node evaluation/wasm-smoke.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"dependencies": {
|
||||||
|
"libsodium-wrappers-sumo": "0.7.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"esbuild": "0.25.12",
|
||||||
|
"@types/libsodium-wrappers-sumo": "0.7.8",
|
||||||
|
"ws": "8.18.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Starts only the disposable localhost fixture; never connects to deployment hosts.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { setTimeout as pause } from 'node:timers/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
const executable = new URL('../evaluation/server/build/install/server/bin/server', import.meta.url).pathname;
|
||||||
|
if (!existsSync(executable)) throw Error('Build the fixture first: npm run fixture:build');
|
||||||
|
const listener = net.createServer();
|
||||||
|
await new Promise((resolve, reject) => listener.once('error', reject).listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = listener.address().port;
|
||||||
|
await new Promise(resolve => listener.close(resolve));
|
||||||
|
const server = spawn(executable, [String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
let diagnostics = ''; let exited = false;
|
||||||
|
server.stdout.on('data', b => { diagnostics = (diagnostics + b).slice(-8000); });
|
||||||
|
server.stderr.on('data', b => { diagnostics = (diagnostics + b).slice(-8000); });
|
||||||
|
server.on('exit', () => { exited = true; });
|
||||||
|
server.on('error', e => { diagnostics += e.message; exited = true; });
|
||||||
|
try {
|
||||||
|
let ready = false;
|
||||||
|
for (let n = 0; n < 100 && !exited; n++) {
|
||||||
|
ready = await new Promise(resolve => {
|
||||||
|
const socket = net.connect(port, '127.0.0.1');
|
||||||
|
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
||||||
|
socket.once('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
if (ready) break;
|
||||||
|
await pause(100);
|
||||||
|
}
|
||||||
|
if (!ready) throw Error(`Local JVM fixture did not start:\n${diagnostics}`);
|
||||||
|
const run = spawn(process.execPath, ['--test', 'test/interop.test.mjs'], { stdio: 'inherit', env: { ...process.env, KILOPARSEC_TEST_URL: `ws://127.0.0.1:${port}/kp` } });
|
||||||
|
process.exitCode = await new Promise(resolve => run.once('exit', code => resolve(code ?? 1)));
|
||||||
|
} finally {
|
||||||
|
server.kill('SIGTERM');
|
||||||
|
if (!exited) {
|
||||||
|
const hardStop = setTimeout(() => server.kill('SIGKILL'), 3000);
|
||||||
|
await new Promise(resolve => { if (exited) resolve(); else server.once('exit', resolve); });
|
||||||
|
clearTimeout(hardStop);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/** Optimized Kotlin/Wasm baseline; includes all required JS and sodium WASM. */
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, copyFile, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const output = path.join(root, 'evaluation/build/measurement/wasm');
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
await build({
|
||||||
|
absWorkingDir: root, entryPoints: ['evaluation/wasm-entry.mjs'],
|
||||||
|
outfile: path.join(output, 'client.mjs'), bundle: true, minify: true,
|
||||||
|
platform: 'node', format: 'esm', sourcemap: false,
|
||||||
|
banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const __filename = import.meta.filename; const __dirname = import.meta.dirname;" },
|
||||||
|
});
|
||||||
|
const wasmName = 'kiloparsec-evaluation-wasm-baseline.wasm';
|
||||||
|
await copyFile(path.join(root, 'evaluation/wasm-baseline/build/compileSync/wasmJs/main/productionExecutable/optimized', wasmName), path.join(output, wasmName));
|
||||||
|
const assets = [];
|
||||||
|
for (const name of ['client.mjs', wasmName]) {
|
||||||
|
const bytes = await readFile(path.join(output, name));
|
||||||
|
assets.push({ path: `evaluation/build/measurement/wasm/${name}`, sha256: createHash('sha256').update(bytes).digest('hex'), rawBytes: bytes.length, gzipBytes: gzipSync(bytes, { level: 9 }).length });
|
||||||
|
}
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const program = `const before=process.memoryUsage().rss; const start=performance.now();
|
||||||
|
try { const {api}=await import(${JSON.stringify(pathToFileURL(path.join(output, 'client.mjs')).href)});
|
||||||
|
await new Promise((resolve,reject)=>api.initializeIdentity(resolve,reject)); api.close();
|
||||||
|
console.log(JSON.stringify({milliseconds:performance.now()-start,rss:process.memoryUsage().rss,rssDelta:process.memoryUsage().rss-before}));
|
||||||
|
} catch(e) { console.error(String(e)); process.exitCode=1; }`;
|
||||||
|
const child = spawnSync(process.execPath, ['--input-type=module', '-e', program], { encoding: 'utf8', timeout: 15000 });
|
||||||
|
if (child.status !== 0) throw Error(child.stderr || String(child.error));
|
||||||
|
samples.push(JSON.parse(child.stdout.trim().split('\n').at(-1)));
|
||||||
|
}
|
||||||
|
const median = key => samples.map(s => s[key]).sort((a,b)=>a-b)[2];
|
||||||
|
const report = {
|
||||||
|
measuredAt: new Date().toISOString(), node: process.version, platform: process.platform,
|
||||||
|
architecture: process.arch, kotlin: '2.2.21', sodiumWrapper: '0.7.15', rawSodium: '0.7.16', sourceVersion: '0.8.0-SNAPSHOT', protocol: 2,
|
||||||
|
notes: [
|
||||||
|
'Actual optimized production Kotlin/Wasm executable, not klib or test runtime.',
|
||||||
|
'Minified Node ESM loader includes all sodium and joda dependencies; Kotlin wasm is a separate asset.',
|
||||||
|
'Sodium WASM is embedded in client.mjs. Gzip level9 is applied independently to each transferred asset and summed.',
|
||||||
|
'Full DH, expected server identity pin, client identity and echo RPC retained. Export bridge uses strings/callbacks.',
|
||||||
|
'Cold fresh Node process per sample, module loading plus sodium init plus one new Ed25519 identity; process startup excluded.',
|
||||||
|
], assets,
|
||||||
|
rawBytes: assets.reduce((n,a)=>n+a.rawBytes,0), gzipBytes: assets.reduce((n,a)=>n+a.gzipBytes,0),
|
||||||
|
medianMilliseconds: median('milliseconds'), medianRssBytes: median('rss'), medianRssDeltaBytes: median('rssDelta'), samples,
|
||||||
|
};
|
||||||
|
await writeFile(path.join(output, 'results.json'), JSON.stringify(report,null,2)+'\n');
|
||||||
|
console.log(JSON.stringify(report,null,2));
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/** Same-platform bundled measurements; run `npm run build` and Kotlin production build first. */
|
||||||
|
import { build } from 'esbuild';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { gzipSync } from 'node:zlib';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const output = path.join(root, 'evaluation/build/measurement');
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
const entries = { ts: 'dist/index.js', kotlin: 'evaluation/baseline-entry.cjs' };
|
||||||
|
const report = { protocol: 2, sourceVersion: '0.8.0-SNAPSHOT', measuredAt: new Date().toISOString(), node: process.version, platform: process.platform, architecture: process.arch, sodiumWrapper: '0.7.15', notes: [
|
||||||
|
'Node CJS bundles, same esbuild settings: bundle/minify/platform=node; gzip level9, no source maps.',
|
||||||
|
'Both include sodium WASM. TS includes all exported API; Kotlin retains only baseline raw bridge, identity init and echo API.',
|
||||||
|
'Cold fresh Node subprocess per sample; elapsed begins immediately before module require, includes WASM init and one new Ed25519 identity.',
|
||||||
|
'RSS includes Node VM; delta measures process resident growth after workload. Five samples, use medians; no process startup time included.',
|
||||||
|
'These figures compare retained subsets, not full feature equivalence or proof of browser runtime compatibility.'
|
||||||
|
], artifacts: {} };
|
||||||
|
for (const [name, entry] of Object.entries(entries)) {
|
||||||
|
const outfile = path.join(output, `${name}.cjs`);
|
||||||
|
await build({ absWorkingDir: root, entryPoints: [entry], outfile, bundle: true, minify: true, platform: 'node', format: 'cjs', sourcemap: false, logLevel: 'warning' });
|
||||||
|
const data = await readFile(outfile);
|
||||||
|
const samples = [];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const program = `const before=process.memoryUsage().rss; const start=performance.now();
|
||||||
|
(async()=>{const m=require(${JSON.stringify(outfile)}); ${name === 'ts' ? 'await m.generateSigningKey();' : 'const api=await m.load(); await api.initializeIdentity();'}
|
||||||
|
console.log(JSON.stringify({milliseconds:performance.now()-start,rss:process.memoryUsage().rss,rssDelta:process.memoryUsage().rss-before}));})().catch(e=>{console.error(e);process.exitCode=1});`;
|
||||||
|
const child = spawnSync(process.execPath, ['-e', program], { encoding: 'utf8', timeout: 15000 });
|
||||||
|
if (child.status !== 0) throw Error(`${name}: ${child.stderr || child.error}`);
|
||||||
|
samples.push(JSON.parse(child.stdout.trim().split('\n').at(-1)));
|
||||||
|
}
|
||||||
|
const median = key => samples.map(s => s[key]).sort((a,b)=>a-b)[2];
|
||||||
|
report.artifacts[name] = { rawBytes: data.length, gzipBytes: gzipSync(data, { level: 9 }).length, medianMilliseconds: median('milliseconds'), medianRssBytes: median('rss'), medianRssDeltaBytes: median('rssDelta'), samples };
|
||||||
|
}
|
||||||
|
await writeFile(path.join(output, 'results.json'), JSON.stringify(report, null, 2) + '\n');
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/** Minimal bounded BiPack primitives, compatible with mp_bintools Smartint.
|
||||||
|
* Signed numbers use sign-magnitude, not protobuf zigzag. No Kotlin runtime is needed.
|
||||||
|
*/
|
||||||
|
export class BipackError extends Error {}
|
||||||
|
export const DEFAULT_MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
const U64_MAX = (1n << 64n) - 1n;
|
||||||
|
const I64_MAX = (1n << 63n) - 1n;
|
||||||
|
const utf8 = new TextEncoder();
|
||||||
|
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
||||||
|
|
||||||
|
/** Writer with a total output limit. Byte arrays and strings have unsigned Smartint lengths. */
|
||||||
|
export class BipackWriter {
|
||||||
|
private data: number[] = [];
|
||||||
|
constructor(readonly maxBytes = DEFAULT_MAX_BYTES) {
|
||||||
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new BipackError('Invalid size limit');
|
||||||
|
}
|
||||||
|
byte(value: number): this {
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > 255) throw new BipackError('Invalid byte');
|
||||||
|
if (this.data.length >= this.maxBytes) throw new BipackError('Output limit exceeded');
|
||||||
|
this.data.push(value); return this;
|
||||||
|
}
|
||||||
|
u64(value: bigint): this {
|
||||||
|
if (value < 0n || value > U64_MAX) throw new BipackError('Unsigned 64-bit overflow');
|
||||||
|
const type = value < 64n ? 0 : value < 16384n ? 1 : value < 4194304n ? 2 : 3;
|
||||||
|
this.byte(Number((value & 63n) << 2n) | type);
|
||||||
|
if (type >= 1) this.byte(Number((value >> 6n) & 255n));
|
||||||
|
if (type >= 2) this.byte(Number((value >> 14n) & 255n));
|
||||||
|
if (type === 3) {
|
||||||
|
let tail = value >> 22n;
|
||||||
|
do { const b = Number(tail & 127n); tail >>= 7n; this.byte(b | (tail > 0n ? 128 : 0)); } while (tail > 0n);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
i64(value: bigint): this {
|
||||||
|
// Long.MIN_VALUE is not representable in Kotlin's sign-magnitude codec: reject instead of losing data.
|
||||||
|
if (value < -I64_MAX || value > I64_MAX) throw new BipackError('Signed BiPack 64-bit overflow');
|
||||||
|
return this.u64(value < 0n ? ((-value) << 1n) | 1n : value << 1n);
|
||||||
|
}
|
||||||
|
u32(value: number): this {
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new BipackError('Unsigned 32-bit overflow');
|
||||||
|
return this.u64(BigInt(value));
|
||||||
|
}
|
||||||
|
i32(value: number): this {
|
||||||
|
if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) throw new BipackError('Signed 32-bit overflow');
|
||||||
|
return this.i64(BigInt(value));
|
||||||
|
}
|
||||||
|
bool(value: boolean): this { return this.byte(value ? 1 : 0); }
|
||||||
|
bytes(value: Uint8Array): this {
|
||||||
|
this.u32(value.length);
|
||||||
|
if (value.length > this.maxBytes - this.data.length) throw new BipackError('Output limit exceeded');
|
||||||
|
for (const b of value) this.data.push(b);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
string(value: string): this {
|
||||||
|
if (value.length > this.maxBytes) throw new BipackError('String limit exceeded');
|
||||||
|
return this.bytes(utf8.encode(value));
|
||||||
|
}
|
||||||
|
nullable<T>(value: T | null, write: (writer: BipackWriter, value: T) => void): this {
|
||||||
|
this.bool(value !== null); if (value !== null) write(this, value); return this;
|
||||||
|
}
|
||||||
|
finish(): Uint8Array { return Uint8Array.from(this.data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict reader: rejects truncation, overflow, invalid UTF-8, noncanonical integers and trailing bytes. */
|
||||||
|
export class BipackReader {
|
||||||
|
private offset = 0;
|
||||||
|
constructor(private readonly data: Uint8Array, readonly maxBytes = DEFAULT_MAX_BYTES) {
|
||||||
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || data.length > maxBytes) throw new BipackError('Input limit exceeded');
|
||||||
|
}
|
||||||
|
get remaining(): number { return this.data.length - this.offset; }
|
||||||
|
byte(): number {
|
||||||
|
if (this.offset >= this.data.length) throw new BipackError('Truncated BiPack');
|
||||||
|
return this.data[this.offset++]!;
|
||||||
|
}
|
||||||
|
u64(): bigint {
|
||||||
|
const first = this.byte(), type = first & 3;
|
||||||
|
let result = BigInt(first >> 2);
|
||||||
|
if (type >= 1) result |= BigInt(this.byte()) << 6n;
|
||||||
|
if (type >= 2) result |= BigInt(this.byte()) << 14n;
|
||||||
|
if (type === 3) {
|
||||||
|
let shift = 22n;
|
||||||
|
for (;;) {
|
||||||
|
const b = this.byte();
|
||||||
|
if (shift >= 64n || (shift === 57n && (b & 128) !== 0)) throw new BipackError('Unsigned 64-bit overflow');
|
||||||
|
result |= BigInt(b & 127) << shift;
|
||||||
|
if ((b & 128) === 0) {
|
||||||
|
if (shift > 22n && (b & 127) === 0) throw new BipackError('Noncanonical integer');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
shift += 7n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((type === 1 && result < 64n) || (type === 2 && result < 16384n) || (type === 3 && result < 4194304n)) throw new BipackError('Noncanonical integer');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
i64(): bigint {
|
||||||
|
const value = this.u64();
|
||||||
|
if (value === 1n) throw new BipackError('Negative zero is not canonical');
|
||||||
|
return (value & 1n) !== 0n ? -(value >> 1n) : value >> 1n;
|
||||||
|
}
|
||||||
|
u32(): number { const v = this.u64(); if (v > 0xffffffffn) throw new BipackError('Unsigned 32-bit overflow'); return Number(v); }
|
||||||
|
i32(): number { const v = this.i64(); if (v < -2147483648n || v > 2147483647n) throw new BipackError('Signed 32-bit overflow'); return Number(v); }
|
||||||
|
bool(): boolean { const b = this.byte(); if (b > 1) throw new BipackError('Invalid boolean'); return b === 1; }
|
||||||
|
bytes(): Uint8Array {
|
||||||
|
const size = this.u32();
|
||||||
|
if (size > this.remaining) throw new BipackError('Truncated byte array');
|
||||||
|
const value = this.data.slice(this.offset, this.offset + size); this.offset += size; return value;
|
||||||
|
}
|
||||||
|
string(): string { try { return utf8Decoder.decode(this.bytes()); } catch (e) { if (e instanceof BipackError) throw e; throw new BipackError('Invalid UTF-8'); } }
|
||||||
|
nullable<T>(read: (reader: BipackReader) => T): T | null { return this.bool() ? read(this) : null; }
|
||||||
|
end(): void { if (this.remaining !== 0) throw new BipackError('Trailing BiPack bytes'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit schema boundary for a command argument or result. */
|
||||||
|
export interface Codec<T> { encode(value: T): Uint8Array; decode(data: Uint8Array): T; }
|
||||||
|
/** Build a codec from field-order readers/writers. Decoding requires consumption of the entire value. */
|
||||||
|
export interface ValueCodec<T> extends Codec<T> { write(w: BipackWriter, value: T): void; read(r: BipackReader): T; }
|
||||||
|
export function codec<T>(write: (w: BipackWriter, v: T) => void, read: (r: BipackReader) => T): ValueCodec<T> {
|
||||||
|
return { write, read, encode(v) { const w = new BipackWriter(); write(w, v); return w.finish(); }, decode(data) { const r = new BipackReader(data); const v = read(r); r.end(); return v; } };
|
||||||
|
}
|
||||||
|
export const uint32 = codec<number>((w,v) => { w.u32(v); }, r => r.u32());
|
||||||
|
export const uint64 = codec<bigint>((w,v) => { w.u64(v); }, r => r.u64());
|
||||||
|
export const int32 = codec<number>((w,v) => { w.i32(v); }, r => r.i32());
|
||||||
|
export const int64 = codec<bigint>((w,v) => { w.i64(v); }, r => r.i64());
|
||||||
|
export const bytes = codec<Uint8Array>((w,v) => { w.bytes(v); }, r => r.bytes());
|
||||||
|
export const string = codec<string>((w,v) => { w.string(v); }, r => r.string());
|
||||||
|
export const boolean = codec<boolean>((w,v) => { w.bool(v); }, r => r.bool());
|
||||||
|
export const unit = codec<void>(() => {}, () => undefined);
|
||||||
|
|
||||||
|
/** Kotlin List<T>/Array<T> field layout. Bounds also limit arrays of zero-byte Unit values. */
|
||||||
|
export function array<T>(element: ValueCodec<T>, maxItems = 100_000): ValueCodec<T[]> {
|
||||||
|
if (!Number.isSafeInteger(maxItems) || maxItems < 0) throw new BipackError('Invalid array limit');
|
||||||
|
return codec((w, values) => {
|
||||||
|
if (values.length > maxItems) throw new BipackError('Array limit exceeded');
|
||||||
|
w.u32(values.length); for (const value of values) element.write(w, value);
|
||||||
|
}, r => {
|
||||||
|
const count = r.u32(); if (count > maxItems) throw new BipackError('Array limit exceeded');
|
||||||
|
const values: T[] = []; for (let i = 0; i < count; i++) values.push(element.read(r)); return values;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** Kotlin nullable field layout: one presence byte followed by the value without another frame. */
|
||||||
|
export function nullable<T>(element: ValueCodec<T>): ValueCodec<T | null> {
|
||||||
|
return codec((w,v) => { w.nullable(v, element.write); }, r => r.nullable(element.read));
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { ReplayWindow, DiscardPacket } from './replay.js';
|
||||||
|
import { bytes, int64, type Codec } from './bipack.js';
|
||||||
|
import { blockCodec, handshakeCodec, clientIdentityCodec, encryptedPackageCodec, commandCallCodec, sealedDataCodec, protectedMessageCodec, authenticationMessage, type Block } from './wire.js';
|
||||||
|
import { createKeyExchange, verifySignature, type SessionCrypto } from './crypto.js';
|
||||||
|
|
||||||
|
/** A named, typed command. Codecs must match the Kotlin serializers exactly. */
|
||||||
|
export interface Command<A, R> { readonly name: string; readonly args: Codec<A>; readonly result: Codec<R> }
|
||||||
|
export function command<A, R>(name: string, args: Codec<A>, result: Codec<R>): Command<A, R> {
|
||||||
|
if (!name || name.startsWith('L0')) throw new TypeError('Invalid application command name');
|
||||||
|
return Object.freeze({ name, args, result });
|
||||||
|
}
|
||||||
|
/** Local signing device; only public material is sent to the peer. */
|
||||||
|
export interface Identity { readonly publicKey: Uint8Array; sign(message: Uint8Array): Uint8Array | Promise<Uint8Array> }
|
||||||
|
export class ConnectionError extends Error {
|
||||||
|
constructor(readonly code: 'closed' | 'timeout' | 'aborted' | 'protocol' | 'authentication' | 'overloaded', message = code as string) { super(message); this.name = 'ConnectionError'; }
|
||||||
|
}
|
||||||
|
/** An authenticated remote error. The code and optional extra bytes come from the peer. */
|
||||||
|
export class RemoteError extends Error {
|
||||||
|
constructor(readonly code: string, message = code, readonly extra: Uint8Array | null = null) { super(message); this.name = 'RemoteError'; }
|
||||||
|
}
|
||||||
|
export interface CallOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly priority?: number }
|
||||||
|
export interface Scope<S> {
|
||||||
|
readonly session: S;
|
||||||
|
readonly remote: Client<S>;
|
||||||
|
readonly remoteIdentity: Uint8Array;
|
||||||
|
readonly sessionToken: Uint8Array;
|
||||||
|
}
|
||||||
|
type Handler<S> = (scope: Scope<S>, payload: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
/** Declare callbacks before connecting. Each connection snapshots this registry. */
|
||||||
|
export class LocalInterface<S> {
|
||||||
|
#handlers = new Map<string, Handler<S>>();
|
||||||
|
on<A, R>(cmd: Command<A, R>, handler: (scope: Scope<S>, args: A) => R | Promise<R>): this {
|
||||||
|
if (this.#handlers.has(cmd.name)) throw new TypeError(`Duplicate command: ${cmd.name}`);
|
||||||
|
this.#handlers.set(cmd.name, async (scope, payload) => cmd.result.encode(await handler(scope, cmd.args.decode(payload))));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
/** @internal */ snapshot(): ReadonlyMap<string, Handler<S>> { return new Map(this.#handlers); }
|
||||||
|
}
|
||||||
|
export interface ConnectOptions<S> {
|
||||||
|
/** Trusted Ed25519 server key obtained independently of this connection. Required; no trust-on-first-use fallback. */
|
||||||
|
readonly expectedServerKey: Uint8Array;
|
||||||
|
readonly identity?: Identity;
|
||||||
|
readonly local?: LocalInterface<S>;
|
||||||
|
readonly session: S;
|
||||||
|
readonly signal?: AbortSignal;
|
||||||
|
readonly timeoutMs?: number;
|
||||||
|
readonly maxPacketBytes?: number;
|
||||||
|
readonly maxPendingCalls?: number;
|
||||||
|
readonly maxIncomingCalls?: number;
|
||||||
|
/** Interval for encrypted heartbeat; 0 disables. Default 30 seconds. */
|
||||||
|
readonly heartbeatMs?: number;
|
||||||
|
/** For a host-provided WebSocket implementation. Must implement the standard binary WebSocket API. */
|
||||||
|
readonly socketFactory?: (url: string) => WebSocket;
|
||||||
|
}
|
||||||
|
type Pending = { resolve(value: Uint8Array): void; reject(error: unknown): void; cleanup(): void; readResponse?: (raw: Uint8Array) => Uint8Array };
|
||||||
|
const pingCommand = command('@kiloparsec/ping', int64, int64);
|
||||||
|
const empty = new Uint8Array();
|
||||||
|
function bound(value: number, label: string, min = 1): number {
|
||||||
|
if (!Number.isSafeInteger(value) || value < min) throw new TypeError(`Invalid ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function same(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let result = 0; for (let i = 0; i < a.length; i++) result |= a[i]! ^ b[i]!;
|
||||||
|
return result === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One full Kiloparsec connection. Reconnection creates a fresh client and DH session.
|
||||||
|
* Calls are never automatically replayed: transport IDs are not business idempotency IDs.
|
||||||
|
*/
|
||||||
|
export class Client<S> {
|
||||||
|
#socket: WebSocket;
|
||||||
|
#crypto?: SessionCrypto;
|
||||||
|
#token: Uint8Array = empty;
|
||||||
|
#remoteIdentity: Uint8Array = empty;
|
||||||
|
#nonce = 0n;
|
||||||
|
#received = new ReplayWindow();
|
||||||
|
#nextId = 1;
|
||||||
|
#pending = new Map<number, Pending>();
|
||||||
|
#incoming = new Set<number>();
|
||||||
|
#incomingCount = 0;
|
||||||
|
#handlers: ReadonlyMap<string, Handler<S>>;
|
||||||
|
#closed = false;
|
||||||
|
#closeReason: unknown = new ConnectionError('closed');
|
||||||
|
#ready = false;
|
||||||
|
#heartbeat?: ReturnType<typeof setInterval>;
|
||||||
|
#heartbeatBusy = false;
|
||||||
|
#finishClosed!: () => void;
|
||||||
|
/** Resolves after local connection disposal. No rejected Promise to forget to observe. */
|
||||||
|
readonly closed: Promise<void>;
|
||||||
|
readonly session: S;
|
||||||
|
readonly maxPacketBytes: number;
|
||||||
|
readonly maxPendingCalls: number;
|
||||||
|
readonly maxIncomingCalls: number;
|
||||||
|
readonly timeoutMs: number;
|
||||||
|
#onMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) throw new ConnectionError('protocol', 'Expected binary WebSocket message');
|
||||||
|
if (event.data.byteLength > this.maxPacketBytes) throw new ConnectionError('protocol', 'Packet too large');
|
||||||
|
this.#receive(blockCodec.decode(new Uint8Array(event.data)));
|
||||||
|
} catch (error) { if (!(error instanceof DiscardPacket)) this.#fail(new ConnectionError('protocol', 'Invalid peer packet')); }
|
||||||
|
};
|
||||||
|
#onClose = () => this.#fail(new ConnectionError('closed'));
|
||||||
|
#onError = () => this.#fail(new ConnectionError('closed', 'WebSocket failed'));
|
||||||
|
|
||||||
|
private constructor(socket: WebSocket, options: ConnectOptions<S>) {
|
||||||
|
this.#socket = socket;
|
||||||
|
this.session = options.session;
|
||||||
|
this.#handlers = options.local?.snapshot() ?? new Map();
|
||||||
|
this.maxPacketBytes = bound(options.maxPacketBytes ?? 8 * 1024 * 1024, 'maxPacketBytes');
|
||||||
|
this.maxPendingCalls = bound(options.maxPendingCalls ?? 1024, 'maxPendingCalls');
|
||||||
|
this.maxIncomingCalls = bound(options.maxIncomingCalls ?? 128, 'maxIncomingCalls');
|
||||||
|
this.timeoutMs = bound(options.timeoutMs ?? 15_000, 'timeoutMs');
|
||||||
|
this.closed = new Promise(resolve => { this.#finishClosed = resolve; });
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
socket.addEventListener('message', this.#onMessage);
|
||||||
|
socket.addEventListener('close', this.#onClose);
|
||||||
|
socket.addEventListener('error', this.#onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect, authenticate the pinned server, then identify the client inside the encrypted session. */
|
||||||
|
static async connect<S>(url: string, options: ConnectOptions<S>): Promise<Client<S>> {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (!['ws:', 'wss:'].includes(parsed.protocol) || parsed.username || parsed.password) throw new TypeError('Expected ws/wss URL without credentials');
|
||||||
|
if (!(options.expectedServerKey instanceof Uint8Array) || options.expectedServerKey.length !== 32) throw new TypeError('A 32-byte trusted server key is required');
|
||||||
|
const expected = options.expectedServerKey.slice();
|
||||||
|
const timeout = bound(options.timeoutMs ?? 15_000, 'timeoutMs');
|
||||||
|
const heartbeat = bound(options.heartbeatMs ?? 30_000, 'heartbeatMs', 0);
|
||||||
|
// Validate all limits before allocating the connection.
|
||||||
|
bound(options.maxPacketBytes ?? 8 * 1024 * 1024, 'maxPacketBytes');
|
||||||
|
bound(options.maxPendingCalls ?? 1024, 'maxPendingCalls');
|
||||||
|
bound(options.maxIncomingCalls ?? 128, 'maxIncomingCalls');
|
||||||
|
if (options.signal?.aborted) throw new ConnectionError('aborted');
|
||||||
|
const factory = options.socketFactory ?? (u => new WebSocket(u));
|
||||||
|
const socket = factory(url);
|
||||||
|
const client = new Client(socket, options);
|
||||||
|
const abort = () => client.#fail(new ConnectionError('aborted'));
|
||||||
|
options.signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
const timer = setTimeout(() => client.#fail(new ConnectionError('timeout')), timeout);
|
||||||
|
try {
|
||||||
|
const exchange = await client.#whileOpen(createKeyExchange().then(exchange => {
|
||||||
|
if (client.#closed) { exchange.destroy(); throw client.#closeReason; }
|
||||||
|
return exchange;
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await client.#waitOpen();
|
||||||
|
const reply = handshakeCodec.decode(await client.#rpc('L0Request', handshakeCodec.encode({ version: 2, publicKey: exchange.publicKey, signature: null }), { signal: options.signal }));
|
||||||
|
if (reply.version !== 2 || !reply.signature || !same(reply.signature.publicKey, expected)) throw new ConnectionError('authentication', 'Server identity does not match');
|
||||||
|
client.#crypto = exchange.clientSession(reply.publicKey);
|
||||||
|
client.#token = client.#crypto.token;
|
||||||
|
const seal = reply.signature;
|
||||||
|
const now = BigInt(Date.now());
|
||||||
|
if (seal.createdAt > now + 45_000n || (seal.expiresAt !== null && now >= seal.expiresAt) ||
|
||||||
|
!verifySignature(seal.publicKey, seal.signature, sealedDataCodec.encode({ message: authenticationMessage('server', client.#token), nonce: seal.nonce, createdAt: seal.createdAt, validUntil: seal.expiresAt }))) {
|
||||||
|
throw new ConnectionError('authentication', 'Invalid server signature');
|
||||||
|
}
|
||||||
|
client.#remoteIdentity = expected;
|
||||||
|
const publicKey = options.identity?.publicKey.slice() ?? null;
|
||||||
|
if (publicKey && publicKey.length !== 32) throw new TypeError('Expected a 32-byte client public key');
|
||||||
|
const signature = options.identity ? await client.#whileOpen(Promise.resolve(options.identity.sign(authenticationMessage('client', client.#token)))) : null;
|
||||||
|
if (signature && (!publicKey || !verifySignature(publicKey, signature, authenticationMessage('client', client.#token)))) throw new ConnectionError('authentication', 'Invalid local signature');
|
||||||
|
// The trusted server may call us from its onConnected handler before replying.
|
||||||
|
client.#ready = true;
|
||||||
|
const identityBytes = clientIdentityCodec.encode({ clientIdKey: publicKey, signature });
|
||||||
|
const result = await client.#rpc('L0ClientId', bytes.encode(client.#encrypt(identityBytes)), { signal: options.signal }, raw => client.#decrypt(bytes.decode(raw)));
|
||||||
|
const finished = protectedMessageCodec.decode(result);
|
||||||
|
if (finished.kind !== 'finished' || finished.correlation !== 0n || finished.priority !== 0 || !same(finished.payload, identityBytes)) {
|
||||||
|
throw new ConnectionError('authentication', 'Invalid server identity confirmation');
|
||||||
|
}
|
||||||
|
if (client.#closed) throw new ConnectionError('closed');
|
||||||
|
client.#ready = true;
|
||||||
|
if (heartbeat) client.#heartbeat = setInterval(() => {
|
||||||
|
if (client.#heartbeatBusy) return;
|
||||||
|
client.#heartbeatBusy = true;
|
||||||
|
void client.ping().catch(() => client.#fail(new ConnectionError('timeout', 'Heartbeat failed'))).finally(() => { client.#heartbeatBusy = false; });
|
||||||
|
}, heartbeat);
|
||||||
|
return client;
|
||||||
|
} finally { exchange.destroy(); }
|
||||||
|
} catch (error) { client.#fail(error); throw error; }
|
||||||
|
finally { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async #whileOpen<T>(promise: Promise<T>): Promise<T> {
|
||||||
|
if (this.#closed) throw this.#closeReason;
|
||||||
|
return Promise.race([promise, this.closed.then(() => { throw this.#closeReason; })]);
|
||||||
|
}
|
||||||
|
async #waitOpen(): Promise<void> {
|
||||||
|
if (this.#socket.readyState === 1) return;
|
||||||
|
if (this.#socket.readyState !== 0 || this.#closed) throw new ConnectionError('closed');
|
||||||
|
let open!: () => void;
|
||||||
|
try { await this.#whileOpen(new Promise<void>(resolve => { open = resolve; this.#socket.addEventListener('open', open, { once: true }); })); }
|
||||||
|
finally { this.#socket.removeEventListener('open', open); }
|
||||||
|
}
|
||||||
|
#send(block: Block): void {
|
||||||
|
if (this.#closed || this.#socket.readyState !== 1) throw new ConnectionError('closed');
|
||||||
|
const packet = blockCodec.encode(block);
|
||||||
|
if (packet.length > this.maxPacketBytes || this.#socket.bufferedAmount + packet.length > this.maxPacketBytes * 2) throw new ConnectionError('overloaded', 'Send buffer limit');
|
||||||
|
this.#socket.send(packet);
|
||||||
|
}
|
||||||
|
#rpc(name: string, packedArgs: Uint8Array | ((id: number) => Uint8Array), options: CallOptions = {}, readResponse?: (raw: Uint8Array) => Uint8Array): Promise<Uint8Array> {
|
||||||
|
if (this.#closed) return Promise.reject(new ConnectionError('closed'));
|
||||||
|
if (options.signal?.aborted) return Promise.reject(new ConnectionError('aborted'));
|
||||||
|
if (this.#pending.size >= this.maxPendingCalls) return Promise.reject(new ConnectionError('overloaded'));
|
||||||
|
const timeout = bound(options.timeoutMs ?? this.timeoutMs, 'timeoutMs');
|
||||||
|
if (this.#nextId > 0xffffffff) { this.#fail(new ConnectionError('closed', 'RPC ID space exhausted')); return Promise.reject(new ConnectionError('closed')); }
|
||||||
|
const id = this.#nextId++;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const finish = (error: unknown) => { const pending = this.#pending.get(id); if (pending) { this.#pending.delete(id); pending.cleanup(); pending.reject(error); } };
|
||||||
|
const abort = () => finish(new ConnectionError('aborted'));
|
||||||
|
const timer = setTimeout(() => finish(new ConnectionError('timeout')), timeout);
|
||||||
|
const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); };
|
||||||
|
this.#pending.set(id, { resolve, reject, cleanup, readResponse });
|
||||||
|
options.signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
try { this.#send({ kind: 'call', id, name, packedArgs: typeof packedArgs === 'function' ? packedArgs(id) : packedArgs, priority: options.priority ?? 0 }); }
|
||||||
|
catch (error) { finish(error); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#receive(block: Block): void {
|
||||||
|
if (this.#closed) return;
|
||||||
|
if (block.kind !== 'call') {
|
||||||
|
const pending = this.#pending.get(block.forId);
|
||||||
|
if (!pending) return; // Late response after timeout/abort; no implicit retry.
|
||||||
|
// Decode/authenticate before consuming the waiter: duplicates and stale replies
|
||||||
|
// must leave it available for a later valid response (or timeout/abort).
|
||||||
|
if (block.kind === 'response') {
|
||||||
|
const result = pending.readResponse ? pending.readResponse(block.packedResult) : block.packedResult;
|
||||||
|
this.#pending.delete(block.forId); pending.cleanup(); pending.resolve(result);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.#pending.delete(block.forId); pending.cleanup();
|
||||||
|
const error = new ConnectionError('protocol', 'Unencrypted outer RPC error');
|
||||||
|
pending.reject(error); this.#fail(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (block.name !== 'L0Call' || !this.#crypto || !this.#remoteIdentity.length) throw new ConnectionError('protocol');
|
||||||
|
const message = protectedMessageCodec.decode(this.#decrypt(bytes.decode(block.packedArgs)));
|
||||||
|
if (message.kind !== (block.id === 0 ? 'push' : 'call') || message.correlation !== BigInt(block.id) || message.priority !== (block.priority ?? 0)) {
|
||||||
|
throw new ConnectionError('protocol', 'Unauthenticated call metadata');
|
||||||
|
}
|
||||||
|
if (this.#incomingCount >= this.maxIncomingCalls || (block.id !== 0 && this.#incoming.has(block.id))) throw new ConnectionError('overloaded');
|
||||||
|
const call = commandCallCodec.decode(message.payload);
|
||||||
|
this.#incomingCount++;
|
||||||
|
if (block.id) this.#incoming.add(block.id);
|
||||||
|
const scope: Scope<S> = Object.freeze({ session: this.session, remote: this, remoteIdentity: this.#remoteIdentity.slice(), sessionToken: this.#token.slice() });
|
||||||
|
// Dispatch independently: a callback may itself await a call to the peer.
|
||||||
|
void (async () => {
|
||||||
|
let reply: Block;
|
||||||
|
try {
|
||||||
|
const handler = this.#handlers.get(call.name);
|
||||||
|
const packedResult = call.name === pingCommand.name ? int64.encode(int64.decode(call.serializedArgs)) :
|
||||||
|
handler ? await handler(scope, call.serializedArgs) : (() => { throw new RemoteError('UnknownCommand', call.name); })();
|
||||||
|
reply = { kind: 'response', forId: 0, packedResult };
|
||||||
|
} catch (error) {
|
||||||
|
reply = error instanceof RemoteError ? { kind: 'error', forId: 0, code: error.code, text: error.message, extra: error.extra } :
|
||||||
|
{ kind: 'error', forId: 0, code: 'InternalError', text: 'Local handler failed', extra: null };
|
||||||
|
}
|
||||||
|
if (block.id && !this.#closed) this.#send({ kind: 'response', forId: block.id, packedResult: bytes.encode(this.#encrypt(protectedMessageCodec.encode({ kind: reply.kind === 'error' ? 'error' : 'response', correlation: BigInt(block.id), priority: message.priority, payload: blockCodec.encode(reply) }))) });
|
||||||
|
})().catch(() => this.#fail(new ConnectionError('protocol'))).finally(() => {
|
||||||
|
this.#incomingCount--; if (block.id) this.#incoming.delete(block.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
#encrypt(payload: Uint8Array): Uint8Array {
|
||||||
|
if (!this.#crypto || this.#closed) throw new ConnectionError('closed');
|
||||||
|
if (this.#nonce > 0xffffffffffffffffn) { this.#fail(new ConnectionError('closed')); throw new ConnectionError('closed', 'Nonce exhausted'); }
|
||||||
|
const nonce = this.#nonce++;
|
||||||
|
return encryptedPackageCodec.encode({ nonce, encryptedMessage: this.#crypto.encrypt(nonce, payload) });
|
||||||
|
}
|
||||||
|
#decrypt(payload: Uint8Array): Uint8Array {
|
||||||
|
if (!this.#crypto || this.#closed) throw new ConnectionError('closed');
|
||||||
|
const pkg = encryptedPackageCodec.decode(payload);
|
||||||
|
if (this.#received.containsOrStale(pkg.nonce)) throw new DiscardPacket();
|
||||||
|
const clear = this.#crypto.decrypt(pkg.nonce, pkg.encryptedMessage);
|
||||||
|
this.#received.record(pkg.nonce);
|
||||||
|
return clear;
|
||||||
|
}
|
||||||
|
/** Execute a typed command. Timeout/abort stops waiting, never cancels remote work. */
|
||||||
|
async call<A, R>(cmd: Command<A, R>, args: A, options: CallOptions = {}): Promise<R> {
|
||||||
|
if (!this.#ready || this.#closed) throw new ConnectionError('closed');
|
||||||
|
if (options.signal?.aborted) throw new ConnectionError('aborted');
|
||||||
|
const priority = options.priority ?? 0;
|
||||||
|
let requestId = 0;
|
||||||
|
const raw = await this.#rpc('L0Call', id => {
|
||||||
|
requestId = id;
|
||||||
|
return bytes.encode(this.#encrypt(protectedMessageCodec.encode({kind: 'call', correlation: BigInt(id), priority,
|
||||||
|
payload: commandCallCodec.encode({ name: cmd.name, serializedArgs: cmd.args.encode(args) }) })));
|
||||||
|
}, options, raw => this.#decrypt(bytes.decode(raw)));
|
||||||
|
let reply: Block;
|
||||||
|
try {
|
||||||
|
const message = protectedMessageCodec.decode(raw);
|
||||||
|
if ((message.kind !== 'response' && message.kind !== 'error') || message.correlation !== BigInt(requestId) || message.priority !== priority) {
|
||||||
|
throw new ConnectionError('protocol', 'Response metadata does not match request');
|
||||||
|
}
|
||||||
|
reply = blockCodec.decode(message.payload);
|
||||||
|
if (reply.kind !== message.kind || reply.forId !== 0) throw new ConnectionError('protocol', 'Invalid response envelope');
|
||||||
|
} catch { this.#fail(new ConnectionError('protocol')); throw new ConnectionError('protocol', 'Invalid encrypted response'); }
|
||||||
|
if (reply.kind === 'error') throw new RemoteError(reply.code, reply.text ?? reply.code, reply.extra);
|
||||||
|
if (reply.kind !== 'response') throw new ConnectionError('protocol');
|
||||||
|
return cmd.result.decode(reply.packedResult);
|
||||||
|
}
|
||||||
|
/** Send an id=0 notification. Resolution proves local enqueue only, not remote execution. */
|
||||||
|
push<A>(cmd: Command<A, void>, args: A, priority = 0): void {
|
||||||
|
if (!this.#ready || this.#closed) throw new ConnectionError('closed');
|
||||||
|
this.#send({ kind: 'call', id: 0, name: 'L0Call', packedArgs: bytes.encode(this.#encrypt(protectedMessageCodec.encode({ kind: 'push', correlation: 0n, priority, payload: commandCallCodec.encode({ name: cmd.name, serializedArgs: cmd.args.encode(args) }) }))), priority });
|
||||||
|
}
|
||||||
|
/** Encrypted round trip. */
|
||||||
|
async ping(): Promise<void> {
|
||||||
|
const value = BigInt(Date.now());
|
||||||
|
try { if (await this.call(pingCommand, value) !== value) throw new ConnectionError('protocol'); }
|
||||||
|
catch (error) { if (!(error instanceof RemoteError && error.code === 'UnknownCommand')) throw error; }
|
||||||
|
}
|
||||||
|
/** Dispose this connection, reject pending calls, wipe session keys. Running callbacks cannot be cancelled. */
|
||||||
|
close(): void { this.#fail(new ConnectionError('closed')); }
|
||||||
|
#fail(error: unknown): void {
|
||||||
|
if (this.#closed) return;
|
||||||
|
this.#closed = true; this.#ready = false; this.#closeReason = error;
|
||||||
|
clearInterval(this.#heartbeat);
|
||||||
|
for (const p of this.#pending.values()) { p.cleanup(); p.reject(error); }
|
||||||
|
this.#pending.clear();
|
||||||
|
this.#crypto?.destroy(); this.#token.fill(0); this.#received.clear();
|
||||||
|
this.#socket.removeEventListener('message', this.#onMessage);
|
||||||
|
this.#socket.removeEventListener('close', this.#onClose);
|
||||||
|
// Keep the no-op error listener through close: some socket implementations emit asynchronously.
|
||||||
|
try { this.#socket.close(); } catch { /* already disposed */ }
|
||||||
|
this.#finishClosed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/** Crypto2 0.9.0 / Kiloparsec crypto compatibility. No alternate handshake or downgraded channel. */
|
||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
import { BipackReader, BipackWriter } from './bipack.js';
|
||||||
|
|
||||||
|
/** Wait for libsodium's WASM initialization before using synchronous primitives. */
|
||||||
|
export async function cryptoReady(): Promise<void> { await sodium.ready; }
|
||||||
|
function size(bytes: Uint8Array, length: number, label: string): void {
|
||||||
|
if (bytes.length !== length) throw new RangeError(`${label} must contain ${length} bytes`);
|
||||||
|
}
|
||||||
|
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||||
|
const result = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||||
|
let offset = 0; for (const p of parts) { result.set(p, offset); offset += p.length; } return result;
|
||||||
|
}
|
||||||
|
const utf8 = (s: string) => new TextEncoder().encode(s);
|
||||||
|
/** Crypto2 uses BLAKE2b with a 32-byte output, not truncation of BLAKE2b-512. */
|
||||||
|
export function blake2b(data: Uint8Array): Uint8Array { return sodium.crypto_generichash(32, data); }
|
||||||
|
export function blake2b3l(data: Uint8Array): Uint8Array {
|
||||||
|
const linked = blake2b(concat(blake2b(data), utf8('All lay loads on a willing horse'), data));
|
||||||
|
return blake2b(concat(linked, utf8('A stitch in time saves nine'), data));
|
||||||
|
}
|
||||||
|
/** Crypto2 BinaryId: body, magic byte, CRC8 (polynomial 0xA7). */
|
||||||
|
export function binaryId(body: Uint8Array, magic: number): Uint8Array {
|
||||||
|
if (!Number.isInteger(magic) || magic < 0 || magic > 255) throw new RangeError('Invalid key magic');
|
||||||
|
const result = concat(body, Uint8Array.of(magic, 0)); let crc = 0;
|
||||||
|
for (const byte of result.subarray(0, -1)) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let i = 0; i < 8; i++) crc = ((crc << 1) ^ ((crc & 128) ? 0xa7 : 0)) & 255;
|
||||||
|
}
|
||||||
|
result[result.length - 1] = crc; return result;
|
||||||
|
}
|
||||||
|
export function verifyingKeyId(publicKey: Uint8Array): Uint8Array { size(publicKey, 32, 'Public key'); return binaryId(publicKey, 4); }
|
||||||
|
/** Preserve the double triple-linked hash used by SymmetricKey.id + KeyId in crypto2. */
|
||||||
|
export function symmetricKeyId(key: Uint8Array): Uint8Array { size(key, 32, 'Symmetric key'); return binaryId(blake2b3l(blake2b3l(key)), 2); }
|
||||||
|
/** XOR the unsigned little-endian counter into a copy of the session token. */
|
||||||
|
export function numericNonce(base: Uint8Array, counter: bigint): Uint8Array {
|
||||||
|
if (counter < 0n || counter > 0xffffffffffffffffn) throw new RangeError('Nonce must fit ULong');
|
||||||
|
const nonce = base.slice(); let n = counter; let i = 0;
|
||||||
|
while (n !== 0n) {
|
||||||
|
if (i >= nonce.length) throw new RangeError('Nonce base is too short');
|
||||||
|
nonce[i] = nonce[i]! ^ Number(n & 255n); n >>= 8n; i++;
|
||||||
|
}
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
export function equalBytes(a: Uint8Array, b: Uint8Array): boolean { return a.length === b.length && sodium.memcmp(a, b); }
|
||||||
|
export function verifySignature(publicKey: Uint8Array, signature: Uint8Array, message: Uint8Array): boolean {
|
||||||
|
size(publicKey, 32, 'Public key'); size(signature, 64, 'Signature');
|
||||||
|
return sodium.crypto_sign_verify_detached(signature, message, publicKey);
|
||||||
|
}
|
||||||
|
/** Local Ed25519 identity. Secret export is explicit; destroy makes further signing impossible. */
|
||||||
|
/** Use generateSigningKey / restoreSigningKey unless cryptoReady has already completed. */
|
||||||
|
export class SigningKey {
|
||||||
|
#secret: Uint8Array; #public: Uint8Array; #destroyed = false;
|
||||||
|
constructor(secret: Uint8Array) {
|
||||||
|
size(secret, 64, 'Secret key');
|
||||||
|
const pair = sodium.crypto_sign_seed_keypair(secret.subarray(0, 32));
|
||||||
|
if (!equalBytes(pair.privateKey, secret)) { sodium.memzero(pair.privateKey); throw new Error('Inconsistent signing key'); }
|
||||||
|
this.#secret = pair.privateKey; this.#public = pair.publicKey;
|
||||||
|
}
|
||||||
|
get publicKey(): Uint8Array { return this.#public.slice(); }
|
||||||
|
get keyId(): Uint8Array { return verifyingKeyId(this.#public); }
|
||||||
|
sign(message: Uint8Array): Uint8Array { this.#check(); return sodium.crypto_sign_detached(message, this.#secret); }
|
||||||
|
exportSecret(): Uint8Array { this.#check(); return this.#secret.slice(); }
|
||||||
|
destroy(): void { sodium.memzero(this.#secret); this.#destroyed = true; }
|
||||||
|
#check(): void { if (this.#destroyed) throw new Error('Signing key was destroyed'); }
|
||||||
|
}
|
||||||
|
export async function generateSigningKey(seed?: Uint8Array): Promise<SigningKey> {
|
||||||
|
await cryptoReady(); if (seed) size(seed, 32, 'Signing seed');
|
||||||
|
const pair = seed ? sodium.crypto_sign_seed_keypair(seed) : sodium.crypto_sign_keypair();
|
||||||
|
try { return new SigningKey(pair.privateKey); } finally { sodium.memzero(pair.privateKey); }
|
||||||
|
}
|
||||||
|
export async function restoreSigningKey(secret: Uint8Array): Promise<SigningKey> { await cryptoReady(); return new SigningKey(secret); }
|
||||||
|
|
||||||
|
/** Directional session keys. Keys are private; the token is shared by the two peers. */
|
||||||
|
export class SessionCrypto {
|
||||||
|
#sending: Uint8Array; #receiving: Uint8Array; #token: Uint8Array; #destroyed = false;
|
||||||
|
constructor(sending: Uint8Array, receiving: Uint8Array, isClient: boolean) {
|
||||||
|
size(sending, 32, 'Sending key'); size(receiving, 32, 'Receiving key');
|
||||||
|
this.#sending = sending.slice(); this.#receiving = receiving.slice();
|
||||||
|
const id = symmetricKeyId(isClient ? sending : receiving);
|
||||||
|
const tag = blake2b(concat(id, id));
|
||||||
|
this.#token = blake2b(concat(utf8('token_'), tag)).slice(0, 24);
|
||||||
|
}
|
||||||
|
get token(): Uint8Array { this.#check(); return this.#token.slice(); }
|
||||||
|
encrypt(counter: bigint, message: Uint8Array): Uint8Array {
|
||||||
|
this.#check();
|
||||||
|
const filled = encodeWithFill(message);
|
||||||
|
try { return sodium.crypto_secretbox_easy(filled, numericNonce(this.#token, counter), this.#sending); }
|
||||||
|
finally { sodium.memzero(filled); }
|
||||||
|
}
|
||||||
|
decrypt(counter: bigint, ciphertext: Uint8Array): Uint8Array {
|
||||||
|
this.#check();
|
||||||
|
const plain = sodium.crypto_secretbox_open_easy(ciphertext, numericNonce(this.#token, counter), this.#receiving);
|
||||||
|
if (!plain) throw new Error('Message authentication failed');
|
||||||
|
try { return decodeWithFill(plain); } finally { sodium.memzero(plain); }
|
||||||
|
}
|
||||||
|
destroy(): void { sodium.memzero(this.#sending); sodium.memzero(this.#receiving); sodium.memzero(this.#token); this.#destroyed = true; }
|
||||||
|
#check(): void { if (this.#destroyed) throw new Error('Session was destroyed'); }
|
||||||
|
}
|
||||||
|
/** Ephemeral X25519 exchange, consumed by one derivation to prevent accidental key reuse. */
|
||||||
|
/** Use createKeyExchange unless cryptoReady has already completed. */
|
||||||
|
export class KeyExchange {
|
||||||
|
#secret: Uint8Array; #public: Uint8Array; #destroyed = false;
|
||||||
|
constructor(seed?: Uint8Array) {
|
||||||
|
if (seed) size(seed, 32, 'Exchange seed');
|
||||||
|
const pair = seed ? sodium.crypto_kx_seed_keypair(seed) : sodium.crypto_kx_keypair();
|
||||||
|
this.#secret = pair.privateKey; this.#public = pair.publicKey;
|
||||||
|
}
|
||||||
|
get publicKey(): Uint8Array { return this.#public.slice(); }
|
||||||
|
clientSession(remote: Uint8Array): SessionCrypto { return this.#derive(remote, true); }
|
||||||
|
serverSession(remote: Uint8Array): SessionCrypto { return this.#derive(remote, false); }
|
||||||
|
#derive(remote: Uint8Array, client: boolean): SessionCrypto {
|
||||||
|
if (this.#destroyed) throw new Error('Key exchange was consumed or destroyed');
|
||||||
|
size(remote, 32, 'Remote exchange key');
|
||||||
|
try {
|
||||||
|
const pair = client ? sodium.crypto_kx_client_session_keys(this.#public, this.#secret, remote)
|
||||||
|
: sodium.crypto_kx_server_session_keys(this.#public, this.#secret, remote);
|
||||||
|
try { return new SessionCrypto(pair.sharedTx, pair.sharedRx, client); }
|
||||||
|
finally { sodium.memzero(pair.sharedTx); sodium.memzero(pair.sharedRx); }
|
||||||
|
} finally { this.destroy(); }
|
||||||
|
}
|
||||||
|
destroy(): void { sodium.memzero(this.#secret); this.#destroyed = true; }
|
||||||
|
}
|
||||||
|
export async function createKeyExchange(seed?: Uint8Array): Promise<KeyExchange> { await cryptoReady(); return new KeyExchange(seed); }
|
||||||
|
export function encryptSession(session: SessionCrypto, nonce: bigint, message: Uint8Array): Uint8Array { return session.encrypt(nonce, message); }
|
||||||
|
export function decryptSession(session: SessionCrypto, nonce: bigint, cipher: Uint8Array): Uint8Array { return session.decrypt(nonce, cipher); }
|
||||||
|
|
||||||
|
// WithFill always has two length-prefixed arrays, even when random fill is empty.
|
||||||
|
export function encodeWithFill(data: Uint8Array, fill = new Uint8Array()): Uint8Array {
|
||||||
|
return new BipackWriter().bytes(fill).bytes(data).finish();
|
||||||
|
}
|
||||||
|
export function decodeWithFill(data: Uint8Array): Uint8Array {
|
||||||
|
const reader = new BipackReader(data); reader.bytes(); const result = reader.bytes(); reader.end(); return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Full Kiloparsec client. No light protocol, no TLS-only authentication fallback. */
|
||||||
|
export { Client, LocalInterface, command, ConnectionError, RemoteError } from './client.js';
|
||||||
|
export type { Command, Scope, Identity, ConnectOptions, CallOptions } from './client.js';
|
||||||
|
export { cryptoReady, generateSigningKey, restoreSigningKey, verifySignature, SigningKey } from './crypto.js';
|
||||||
|
export { BipackReader, BipackWriter, BipackError, codec, array, nullable, bytes, string, boolean, unit, int32, uint32, int64, uint64 } from './bipack.js';
|
||||||
|
export type { Codec, ValueCodec } from './bipack.js';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Bounded circular bitmap; only authenticated messages may call record(). */
|
||||||
|
export class ReplayWindow {
|
||||||
|
#highest = -1n;
|
||||||
|
#bits = new Uint32Array(128);
|
||||||
|
containsOrStale(n: bigint): boolean {
|
||||||
|
if (n > this.#highest) return false;
|
||||||
|
if (this.#highest - n >= 4096n) return true;
|
||||||
|
const i = Number(n % 4096n);
|
||||||
|
return (this.#bits[i >>> 5]! & (1 << (i & 31))) !== 0;
|
||||||
|
}
|
||||||
|
record(n: bigint): void {
|
||||||
|
if (n > this.#highest) {
|
||||||
|
if (this.#highest < 0n || n - this.#highest >= 4096n) this.#bits.fill(0);
|
||||||
|
else for (let next = this.#highest + 1n; next <= n; next++) {
|
||||||
|
const i = Number(next % 4096n);
|
||||||
|
this.#bits[i >>> 5] = this.#bits[i >>> 5]! & ~(1 << (i & 31));
|
||||||
|
}
|
||||||
|
this.#highest = n;
|
||||||
|
}
|
||||||
|
const i = Number(n % 4096n);
|
||||||
|
this.#bits[i >>> 5] = this.#bits[i >>> 5]! | (1 << (i & 31));
|
||||||
|
}
|
||||||
|
clear(): void { this.#highest = -1n; this.#bits.fill(0); }
|
||||||
|
}
|
||||||
|
export class DiscardPacket extends Error {}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/** Exact field-order codecs for Kiloparsec's existing Kotlin wire structures. */
|
||||||
|
import { BipackError, BipackReader, BipackWriter, codec } from './bipack.js';
|
||||||
|
|
||||||
|
export type Block = (
|
||||||
|
| { kind: 'call'; id: number; name: string; packedArgs: Uint8Array }
|
||||||
|
| { kind: 'response'; forId: number; packedResult: Uint8Array }
|
||||||
|
| { kind: 'error'; forId: number; code: string; text: string | null; extra: Uint8Array | null }
|
||||||
|
) & { priority?: number };
|
||||||
|
export const blockCodec = codec<Block>((w, b) => {
|
||||||
|
switch (b.kind) {
|
||||||
|
case 'call': w.byte(0).u32(b.id).string(b.name).bytes(b.packedArgs); break;
|
||||||
|
case 'error': w.byte(1).u32(b.forId).string(b.code).nullable(b.text, (w,v) => { w.string(v); }).nullable(b.extra, (w,v) => { w.bytes(v); }); break;
|
||||||
|
case 'response': w.byte(2).u32(b.forId).bytes(b.packedResult); break;
|
||||||
|
}
|
||||||
|
const priority = b.priority ?? 0;
|
||||||
|
if (!Number.isInteger(priority) || priority < -2147483648 || priority > 2147483647) throw new BipackError('Invalid priority');
|
||||||
|
if (priority !== 0) for (let shift = 24; shift >= 0; shift -= 8) w.byte((priority >>> shift) & 255);
|
||||||
|
}, r => {
|
||||||
|
let b: Block;
|
||||||
|
switch (r.byte()) {
|
||||||
|
case 0: b = { kind: 'call', id: r.u32(), name: r.string(), packedArgs: r.bytes() }; break;
|
||||||
|
case 1: b = { kind: 'error', forId: r.u32(), code: r.string(), text: r.nullable(r => r.string()), extra: r.nullable(r => r.bytes()) }; break;
|
||||||
|
case 2: b = { kind: 'response', forId: r.u32(), packedResult: r.bytes() }; break;
|
||||||
|
default: throw new BipackError('Unknown transport block');
|
||||||
|
}
|
||||||
|
if (r.remaining !== 0) {
|
||||||
|
if (r.remaining !== 4) throw new BipackError('Invalid priority trailer');
|
||||||
|
b.priority = (r.byte() << 24) | (r.byte() << 16) | (r.byte() << 8) | r.byte();
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Crypto2 Seal; timestamps are epoch milliseconds, keys are raw Ed25519 verifying bytes. */
|
||||||
|
export interface Seal {
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
signature: Uint8Array;
|
||||||
|
nonce: Uint8Array | null;
|
||||||
|
createdAt: bigint;
|
||||||
|
expiresAt: bigint | null;
|
||||||
|
}
|
||||||
|
function keyBytes(r: BipackReader): Uint8Array {
|
||||||
|
const key = r.bytes(); if (key.length !== 32) throw new BipackError('Public key must have 32 bytes'); return key;
|
||||||
|
}
|
||||||
|
function writeKey(w: BipackWriter, key: Uint8Array): void {
|
||||||
|
if (key.length !== 32) throw new BipackError('Public key must have 32 bytes'); w.bytes(key);
|
||||||
|
}
|
||||||
|
function writeSeal(w: BipackWriter, v: Seal): void {
|
||||||
|
writeKey(w,v.publicKey);
|
||||||
|
if (v.signature.length !== 64) throw new BipackError('Signature must have 64 bytes');
|
||||||
|
w.bytes(v.signature).nullable(v.nonce, (w,v) => { w.bytes(v); }).i64(v.createdAt).nullable(v.expiresAt, (w,v) => { w.i64(v); });
|
||||||
|
}
|
||||||
|
function readSeal(r: BipackReader): Seal {
|
||||||
|
const publicKey = keyBytes(r), signature = r.bytes();
|
||||||
|
if (signature.length !== 64) throw new BipackError('Signature must have 64 bytes');
|
||||||
|
return { publicKey, signature, nonce: r.nullable(r => r.bytes()), createdAt: r.i64(), expiresAt: r.nullable(r => r.i64()) };
|
||||||
|
}
|
||||||
|
export const sealCodec = codec<Seal>(writeSeal, readSeal);
|
||||||
|
export interface SealedData { message: Uint8Array; nonce: Uint8Array | null; createdAt: bigint | null; validUntil: bigint | null; }
|
||||||
|
export const sealedDataCodec = codec<SealedData>((w,v) => {
|
||||||
|
w.bytes(v.message).nullable(v.nonce, (w,v) => { w.bytes(v); }).nullable(v.createdAt, (w,v) => { w.i64(v); }).nullable(v.validUntil, (w,v) => { w.i64(v); });
|
||||||
|
}, r => ({ message: r.bytes(), nonce: r.nullable(r => r.bytes()), createdAt: r.nullable(r => r.i64()), validUntil: r.nullable(r => r.i64()) }));
|
||||||
|
export interface Handshake { version: number; publicKey: Uint8Array; signature: Seal | null; }
|
||||||
|
// Published Kotlin BiPack encodes unannotated UInt through its signed Int backing value.
|
||||||
|
// Transport IDs and package nonces carry @Unsigned and therefore use unsigned Smartint.
|
||||||
|
export const handshakeCodec = codec<Handshake>((w,v) => {
|
||||||
|
if (!Number.isInteger(v.version) || v.version < 0 || v.version > 0xffffffff) throw new BipackError('Invalid handshake version');
|
||||||
|
w.i32(v.version | 0); writeKey(w,v.publicKey); w.nullable(v.signature,writeSeal);
|
||||||
|
}, r => ({ version: r.i32() >>> 0, publicKey: keyBytes(r), signature: r.nullable(readSeal) }));
|
||||||
|
export interface ClientIdentity { clientIdKey: Uint8Array | null; signature: Uint8Array | null; }
|
||||||
|
export const clientIdentityCodec = codec<ClientIdentity>((w,v) => {
|
||||||
|
w.nullable(v.clientIdKey,writeKey).nullable(v.signature, (w,v) => { w.bytes(v); });
|
||||||
|
}, r => ({ clientIdKey: r.nullable(keyBytes), signature: r.nullable(r => r.bytes()) }));
|
||||||
|
export interface EncryptedPackage { nonce: bigint; encryptedMessage: Uint8Array; }
|
||||||
|
export const encryptedPackageCodec = codec<EncryptedPackage>((w,v) => { w.u64(v.nonce).bytes(v.encryptedMessage); }, r => ({ nonce: r.u64(), encryptedMessage: r.bytes() }));
|
||||||
|
export interface CommandCall { name: string; serializedArgs: Uint8Array; }
|
||||||
|
export const commandCallCodec = codec<CommandCall>((w,v) => { w.string(v.name).bytes(v.serializedArgs); }, r => ({ name: r.string(), serializedArgs: r.bytes() }));
|
||||||
|
|
||||||
|
/** V2 authenticated RPC metadata. No dispatch decision may rely on the outer block alone. */
|
||||||
|
export interface ProtectedMessage {
|
||||||
|
kind: 'call' | 'push' | 'response' | 'error' | 'finished';
|
||||||
|
correlation: bigint;
|
||||||
|
priority: number;
|
||||||
|
payload: Uint8Array;
|
||||||
|
}
|
||||||
|
const protectedKinds = ['call', 'push', 'response', 'error', 'finished'] as const;
|
||||||
|
export const protectedMessageCodec = codec<ProtectedMessage>((w,v) => {
|
||||||
|
const kind = protectedKinds.indexOf(v.kind);
|
||||||
|
if (kind < 0) throw new BipackError('Invalid protected message kind');
|
||||||
|
w.u32(kind).u64(v.correlation).i32(v.priority).bytes(v.payload);
|
||||||
|
}, r => {
|
||||||
|
const kind = protectedKinds[r.u32()];
|
||||||
|
if (kind === undefined) throw new BipackError('Unknown protected message kind');
|
||||||
|
return { kind, correlation: r.u64(), priority: r.i32(), payload: r.bytes() };
|
||||||
|
});
|
||||||
|
/** Distinct version-bound signing domains prevent reuse between roles and protocol versions. */
|
||||||
|
export function authenticationMessage(role: 'server' | 'client', token: Uint8Array): Uint8Array {
|
||||||
|
const domain = new TextEncoder().encode(`kiloparsec/v2/${role}\0`);
|
||||||
|
const message = new Uint8Array(domain.length + token.length);
|
||||||
|
message.set(domain); message.set(token, domain.length); return message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Client, LocalInterface, command } from '../dist/client.js';
|
||||||
|
import { generateSigningKey, createKeyExchange, verifySignature } from '../dist/crypto.js';
|
||||||
|
import { bytes, string, unit } from '../dist/bipack.js';
|
||||||
|
import { blockCodec, handshakeCodec, sealedDataCodec, clientIdentityCodec, encryptedPackageCodec, commandCallCodec, protectedMessageCodec, authenticationMessage } from '../dist/wire.js';
|
||||||
|
|
||||||
|
const echo = command('echo',string,string);
|
||||||
|
class PeerSocket extends EventTarget {
|
||||||
|
readyState = 1; bufferedAmount = 0; binaryType = 'arraybuffer';
|
||||||
|
constructor(peer) { super(); this.peer = peer; }
|
||||||
|
send(data) { queueMicrotask(() => { void this.peer.receive(blockCodec.decode(data)).catch(error => { this.peer.error = error; this.close(); }); }); }
|
||||||
|
deliver(block) { const data = blockCodec.encode(block); this.dispatchEvent(new MessageEvent('message',{data:data.buffer})); }
|
||||||
|
close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); }
|
||||||
|
}
|
||||||
|
async function makePeer(options = {}) {
|
||||||
|
const signing = await generateSigningKey();
|
||||||
|
const exchange = await createKeyExchange();
|
||||||
|
const peer = { signing, exchange, crypto:null, nonce:0n, error:null, calls:0, ...options };
|
||||||
|
peer.socket = new PeerSocket(peer);
|
||||||
|
peer.encrypt = clear => encryptedPackageCodec.encode({nonce:peer.nonce,encryptedMessage:peer.crypto.encrypt(peer.nonce++,clear)});
|
||||||
|
peer.decrypt = packed => { const p=encryptedPackageCodec.decode(packed); return peer.crypto.decrypt(p.nonce,p.encryptedMessage); };
|
||||||
|
peer.respond = (id,result) => peer.socket.deliver({kind:'response',forId:id,packedResult:result});
|
||||||
|
peer.receive = async b => {
|
||||||
|
if (b.kind !== 'call') { peer.callbackResult?.(b); return; }
|
||||||
|
if (b.name === 'L0Request') {
|
||||||
|
const h=handshakeCodec.decode(b.packedArgs), publicKey=exchange.publicKey;
|
||||||
|
peer.crypto=exchange.serverSession(h.publicKey);
|
||||||
|
const createdAt=BigInt(Date.now()), nonce=null, expiresAt=null;
|
||||||
|
const signature=signing.sign(sealedDataCodec.encode({message:authenticationMessage('server',peer.crypto.token),nonce,createdAt,validUntil:expiresAt}));
|
||||||
|
peer.respond(b.id,handshakeCodec.encode({version:2,publicKey,signature:{publicKey:signing.publicKey,signature,nonce,createdAt,expiresAt}}));
|
||||||
|
} else if (b.name === 'L0ClientId') {
|
||||||
|
const identityBytes=peer.decrypt(bytes.decode(b.packedArgs));
|
||||||
|
peer.identity=clientIdentityCodec.decode(identityBytes);
|
||||||
|
peer.respond(b.id,bytes.encode(peer.encrypt(protectedMessageCodec.encode({kind:'finished',correlation:0n,priority:0,payload:identityBytes}))));
|
||||||
|
} else if (b.name === 'L0Call') {
|
||||||
|
peer.calls++; peer.lastPriority=b.priority ?? 0;
|
||||||
|
const message=protectedMessageCodec.decode(peer.decrypt(bytes.decode(b.packedArgs)));
|
||||||
|
assert.equal(message.correlation,BigInt(b.id)); assert.equal(message.priority,b.priority ?? 0);
|
||||||
|
const call=commandCallCodec.decode(message.payload);
|
||||||
|
if (peer.hang) return;
|
||||||
|
const payload=blockCodec.encode({kind:'response',forId:0,packedResult:string.encode(string.decode(call.serializedArgs))});
|
||||||
|
const encrypted=peer.encrypt(protectedMessageCodec.encode({kind:'response',correlation:BigInt(b.id),priority:message.priority,payload}));
|
||||||
|
if(peer.tamper) encrypted[encrypted.length-1]^=1;
|
||||||
|
peer.respond(b.id,bytes.encode(encrypted));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
peer.connect = extra => Client.connect('ws://test.invalid',{expectedServerKey:signing.publicKey,session:{},heartbeatMs:0,timeoutMs:500,socketFactory:()=>peer.socket,...extra});
|
||||||
|
peer.dispose = () => { peer.socket.close(); peer.crypto?.destroy(); exchange.destroy(); signing.destroy(); };
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Client authenticates peer and performs encrypted typed calls', async () => {
|
||||||
|
const peer=await makePeer();
|
||||||
|
try { const client=await peer.connect(); assert.equal(await client.call(echo,'hello'),'hello'); client.close(); await client.closed; assert.equal(peer.error,null); }
|
||||||
|
finally { peer.dispose(); }
|
||||||
|
});
|
||||||
|
test('Wrong pinned key rejects connection before sending identity', async () => {
|
||||||
|
const peer=await makePeer(), wrong=await generateSigningKey();
|
||||||
|
try { await assert.rejects(peer.connect({expectedServerKey:wrong.publicKey}), e=>e.code==='authentication'); assert.equal(peer.identity,undefined); }
|
||||||
|
finally { peer.dispose(); wrong.destroy(); }
|
||||||
|
});
|
||||||
|
test('Call timeout never automatically replays work', async () => {
|
||||||
|
const peer=await makePeer({hang:true});
|
||||||
|
try { const client=await peer.connect(); await assert.rejects(client.call(echo,'once',{timeoutMs:20}),e=>e.code==='timeout'); assert.equal(peer.calls,1); client.close(); }
|
||||||
|
finally { peer.dispose(); }
|
||||||
|
});
|
||||||
|
test('Corrupt encrypted response closes connection', async () => {
|
||||||
|
const peer=await makePeer({tamper:true});
|
||||||
|
try { const client=await peer.connect(); await assert.rejects(client.call(echo,'x'),e=>e.code==='protocol'); await client.closed; await assert.rejects(client.call(echo,'x'),e=>e.code==='closed'); }
|
||||||
|
finally { peer.dispose(); }
|
||||||
|
});
|
||||||
|
test('Incoming handler can await outbound call: full duplex has no dispatch deadlock', async () => {
|
||||||
|
const peer=await makePeer();
|
||||||
|
const callback=command('callback',string,string);
|
||||||
|
const local=new LocalInterface().on(callback,async (scope,arg)=>await scope.remote.call(echo,arg+'!'));
|
||||||
|
try {
|
||||||
|
const client=await peer.connect({local});
|
||||||
|
const reply=new Promise(resolve=>{peer.callbackResult=resolve;});
|
||||||
|
peer.socket.deliver({kind:'call',id:80,name:'L0Call',packedArgs:bytes.encode(peer.encrypt(protectedMessageCodec.encode({kind:'call',correlation:80n,priority:0,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('nested')})})))});
|
||||||
|
const response=await reply;
|
||||||
|
const inner=blockCodec.decode(protectedMessageCodec.decode(peer.decrypt(bytes.decode(response.packedResult))).payload);
|
||||||
|
assert.equal(inner.kind,'response'); assert.equal(string.decode(inner.packedResult),'nested!'); client.close();
|
||||||
|
} finally { peer.dispose(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Abort cleans pending slot and preserves remote execution uncertainty', async () => {
|
||||||
|
const peer=await makePeer({hang:true});
|
||||||
|
try {
|
||||||
|
const client=await peer.connect({maxPendingCalls:1});
|
||||||
|
const abort=new AbortController();
|
||||||
|
const first=client.call(echo,'first',{signal:abort.signal}); abort.abort();
|
||||||
|
await assert.rejects(first,e=>e.code==='aborted');
|
||||||
|
peer.hang=false;
|
||||||
|
assert.equal(await client.call(echo,'second',{priority:7}),'second');
|
||||||
|
assert.equal(peer.lastPriority,7); assert.equal(peer.calls,2); client.close();
|
||||||
|
} finally { peer.dispose(); }
|
||||||
|
});
|
||||||
|
test('Explicit close rejects all pending calls and repeated close is harmless', async () => {
|
||||||
|
const peer=await makePeer({hang:true});
|
||||||
|
try {
|
||||||
|
const client=await peer.connect();
|
||||||
|
const first=client.call(echo,'a'), second=client.call(echo,'b');
|
||||||
|
client.close(); client.close();
|
||||||
|
await Promise.all([assert.rejects(first,e=>e.code==='closed'),assert.rejects(second,e=>e.code==='closed')]);
|
||||||
|
await client.closed;
|
||||||
|
} finally { peer.dispose(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client identity signs the v2 client domain, distinct from server and legacy proofs',async()=>{
|
||||||
|
const peer=await makePeer(), identity=await generateSigningKey();
|
||||||
|
try {
|
||||||
|
const client=await peer.connect({identity});
|
||||||
|
assert.deepEqual(peer.identity.clientIdKey,identity.publicKey);
|
||||||
|
assert.equal(verifySignature(identity.publicKey,peer.identity.signature,authenticationMessage('client',peer.crypto.token)),true);
|
||||||
|
assert.equal(verifySignature(identity.publicKey,peer.identity.signature,authenticationMessage('server',peer.crypto.token)),false);
|
||||||
|
assert.equal(verifySignature(identity.publicKey,peer.identity.signature,peer.crypto.token),false);
|
||||||
|
client.close();
|
||||||
|
} finally {peer.dispose();identity.destroy();}
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { array, nullable, BipackReader, BipackWriter, uint64, int64, int32, uint32, string, bytes, boolean, unit } from '../dist/bipack.js';
|
||||||
|
import { blockCodec, handshakeCodec, clientIdentityCodec, encryptedPackageCodec, sealedDataCodec, sealCodec, commandCallCodec } from '../dist/wire.js';
|
||||||
|
const hex = bytes => Buffer.from(bytes).toString('hex');
|
||||||
|
|
||||||
|
test('Smartint boundaries and sign-magnitude vectors match Kotlin source', () => {
|
||||||
|
const vectors = [[0n,'00'],[1n,'04'],[63n,'fc'],[64n,'0101'],[16383n,'fdff'],[16384n,'020001'],[4194303n,'feffff'],[4194304n,'03000001'],[(1n<<64n)-1n,'ffffffffffffffff7f']];
|
||||||
|
for (const [value, expected] of vectors) { assert.equal(hex(uint64.encode(value)), expected); assert.equal(uint64.decode(Buffer.from(expected,'hex')),value); }
|
||||||
|
assert.equal(hex(int64.encode(-1n)), '0c');
|
||||||
|
assert.equal(hex(int64.encode(1n)), '08');
|
||||||
|
for (const value of [-(1n<<63n)+1n,-16384n,-32n,0n,31n,32n, (1n<<63n)-1n]) assert.equal(int64.decode(int64.encode(value)),value);
|
||||||
|
for (const value of [-2147483648,2147483647]) assert.equal(int32.decode(int32.encode(value)),value);
|
||||||
|
assert.equal(uint32.decode(uint32.encode(0xffffffff)),0xffffffff);
|
||||||
|
assert.throws(() => int64.encode(-(1n<<63n)), /overflow/);
|
||||||
|
assert.throws(() => uint64.encode(1n<<64n), /overflow/);
|
||||||
|
});
|
||||||
|
test('Bounded decoding rejects malformed values without allocation from untrusted lengths', () => {
|
||||||
|
for (const value of ['01','0201','03000080','03000080808080808000','0100','020000','03000000']) assert.throws(() => uint64.decode(Buffer.from(value,'hex')));
|
||||||
|
assert.throws(() => int64.decode(Uint8Array.of(4)), /Negative zero/);
|
||||||
|
assert.throws(() => bytes.decode(uint32.encode(0xffffffff)), /Truncated/);
|
||||||
|
assert.throws(() => string.decode(Uint8Array.of(4,255)), /UTF-8/);
|
||||||
|
assert.throws(() => boolean.decode(Uint8Array.of(2)), /boolean/);
|
||||||
|
assert.throws(() => unit.decode(Uint8Array.of(0)), /Trailing/);
|
||||||
|
assert.throws(() => new BipackReader(new Uint8Array(4),3), /limit/);
|
||||||
|
assert.throws(() => new BipackWriter(2).bytes(new Uint8Array(2)), /limit/);
|
||||||
|
assert.equal(string.decode(string.encode('Привет 🌌')), 'Привет 🌌');
|
||||||
|
});
|
||||||
|
test('Transport legacy vectors and optional signed priority trailer', () => {
|
||||||
|
const call = {kind:'call',id:1,name:'x',packedArgs:Uint8Array.of(42)};
|
||||||
|
assert.equal(hex(blockCodec.encode(call)), '00040478042a');
|
||||||
|
assert.deepEqual(blockCodec.decode(blockCodec.encode(call)),call);
|
||||||
|
const response = {kind:'response',forId:1,packedResult:new Uint8Array()};
|
||||||
|
assert.equal(hex(blockCodec.encode(response)), '020400');
|
||||||
|
const error = {kind:'error',forId:1,code:'E',text:null,extra:null};
|
||||||
|
assert.equal(hex(blockCodec.encode(error)), '010404450000');
|
||||||
|
for (const p of [-2147483648,-1,1,2147483647]) assert.deepEqual(blockCodec.decode(blockCodec.encode({...call,priority:p})), {...call,priority:p});
|
||||||
|
assert.equal(hex(blockCodec.encode({...call,priority:0})), hex(blockCodec.encode(call)));
|
||||||
|
const encoded = blockCodec.encode({...call,priority:1});
|
||||||
|
for (let trim=1;trim<=3;trim++) assert.throws(() => blockCodec.decode(encoded.slice(0,-trim)), /trailer/);
|
||||||
|
assert.throws(() => blockCodec.decode(Uint8Array.of(...encoded,0)), /trailer/);
|
||||||
|
assert.throws(() => blockCodec.decode(Uint8Array.of(4)), /Unknown/);
|
||||||
|
});
|
||||||
|
test('Handshake, Seal and nullable Instant exact field order', () => {
|
||||||
|
const publicKey = new Uint8Array(32);
|
||||||
|
const seal = {publicKey,signature:new Uint8Array(64),nonce:null,createdAt:1n,expiresAt:null};
|
||||||
|
assert.equal(hex(sealCodec.encode(seal)), '80'+'00'.repeat(32)+'0101'+'00'.repeat(64)+'000800');
|
||||||
|
assert.deepEqual(sealCodec.decode(sealCodec.encode(seal)),seal);
|
||||||
|
const hs = {version:1,publicKey,signature:null};
|
||||||
|
assert.equal(hex(handshakeCodec.encode(hs)),'0880'+'00'.repeat(32)+'00');
|
||||||
|
assert.deepEqual(handshakeCodec.decode(handshakeCodec.encode({...hs,signature:seal})),{...hs,signature:seal});
|
||||||
|
assert.equal(hex(clientIdentityCodec.encode({clientIdKey:null,signature:null})),'0000');
|
||||||
|
assert.equal(hex(sealedDataCodec.encode({message:Uint8Array.of(7),nonce:null,createdAt:1n,validUntil:null})),'040700010800');
|
||||||
|
assert.throws(() => handshakeCodec.encode({...hs,publicKey:new Uint8Array(31)}), /32 bytes/);
|
||||||
|
});
|
||||||
|
test('Encrypted envelope and command call roundtrip, complete consumption enforced', () => {
|
||||||
|
const p = {nonce:(1n<<64n)-1n,encryptedMessage:Uint8Array.of(0,128,255)};
|
||||||
|
assert.deepEqual(encryptedPackageCodec.decode(encryptedPackageCodec.encode(p)),p);
|
||||||
|
const c = {name:'echo',serializedArgs:string.encode('hello')};
|
||||||
|
assert.deepEqual(commandCallCodec.decode(commandCallCodec.encode(c)),c);
|
||||||
|
assert.throws(() => commandCallCodec.decode(Uint8Array.of(...commandCallCodec.encode(c),0)),/Trailing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Typed lists and nullable values match unframed Kotlin collection encoding', () => {
|
||||||
|
const ints = array(int32);
|
||||||
|
assert.equal(hex(ints.encode([1,-1,0])), '0c080c00');
|
||||||
|
assert.deepEqual(ints.decode(ints.encode([1,-1,0])),[1,-1,0]);
|
||||||
|
const nested = array(nullable(array(string)));
|
||||||
|
assert.deepEqual(nested.decode(nested.encode([null,[],['x','ы']])),[null,[],['x','ы']]);
|
||||||
|
assert.throws(() => array(unit,3).decode(uint32.encode(4)), /Array limit/);
|
||||||
|
assert.throws(() => array(unit,3).encode([undefined,undefined,undefined,undefined]), /Array limit/);
|
||||||
|
assert.throws(() => ints.decode(Uint8Array.of(8,8)), /Truncated/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {cryptoReady, generateSigningKey, restoreSigningKey, createKeyExchange,
|
||||||
|
verifySignature, numericNonce, encodeWithFill, decodeWithFill} from '../dist/crypto.js';
|
||||||
|
await cryptoReady();
|
||||||
|
const fromHex = s => new Uint8Array(Buffer.from(s, 'hex'));
|
||||||
|
const hex = b => Buffer.from(b).toString('hex');
|
||||||
|
|
||||||
|
test('Ed25519 RFC8032 vector; identity restore and secret lifecycle', async () => {
|
||||||
|
const key = await generateSigningKey(fromHex('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60'));
|
||||||
|
assert.equal(hex(key.publicKey), 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a');
|
||||||
|
const signature = key.sign(new Uint8Array());
|
||||||
|
assert.equal(hex(signature), 'e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b');
|
||||||
|
assert.ok(verifySignature(key.publicKey, signature, new Uint8Array()));
|
||||||
|
assert.equal(verifySignature(key.publicKey, signature, Uint8Array.of(1)), false);
|
||||||
|
assert.equal(key.keyId.length, 34); assert.equal(key.keyId[32], 4);
|
||||||
|
const saved = key.exportSecret(); const restored = await restoreSigningKey(saved);
|
||||||
|
assert.deepEqual(restored.keyId, key.keyId);
|
||||||
|
saved[63] ^= 1; await assert.rejects(restoreSigningKey(saved), /Inconsistent/);
|
||||||
|
key.destroy(); assert.throws(() => key.sign(new Uint8Array()), /destroyed/);
|
||||||
|
assert.throws(() => key.exportSecret(), /destroyed/); restored.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('directional KX agrees on token and authenticates both directions', async () => {
|
||||||
|
const client = await createKeyExchange(new Uint8Array(32).fill(1));
|
||||||
|
const server = await createKeyExchange(new Uint8Array(32).fill(2));
|
||||||
|
const c = client.clientSession(server.publicKey), s = server.serverSession(client.publicKey);
|
||||||
|
assert.deepEqual(c.token, s.token);
|
||||||
|
const message = new TextEncoder().encode('full Kiloparsec');
|
||||||
|
const cipher = c.encrypt(0n, message);
|
||||||
|
assert.deepEqual(s.decrypt(0n, cipher), message);
|
||||||
|
assert.deepEqual(c.decrypt(7n, s.encrypt(7n, message)), message);
|
||||||
|
assert.throws(() => c.decrypt(0n, cipher)); // wrong direction
|
||||||
|
assert.throws(() => s.decrypt(1n, cipher)); // wrong nonce
|
||||||
|
cipher[0] ^= 1; assert.throws(() => s.decrypt(0n, cipher));
|
||||||
|
assert.throws(() => client.clientSession(server.publicKey), /consumed/);
|
||||||
|
c.destroy(); s.destroy(); assert.throws(() => c.encrypt(0n, message), /destroyed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numeric nonce uses unsigned little-endian XOR, validates overflow', () => {
|
||||||
|
assert.deepEqual(numericNonce(Uint8Array.of(0xaa, 0xbb, 0xcc), 0x1234n), Uint8Array.of(0x9e, 0xa9, 0xcc));
|
||||||
|
assert.throws(() => numericNonce(new Uint8Array(24), -1n), RangeError);
|
||||||
|
assert.throws(() => numericNonce(new Uint8Array(24), 1n << 64n), RangeError);
|
||||||
|
assert.throws(() => numericNonce(new Uint8Array(1), 256n), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WithFill preserves arbitrary bytes and rejects trailing input', () => {
|
||||||
|
const payload = Uint8Array.of(0, 128, 255);
|
||||||
|
assert.deepEqual(decodeWithFill(encodeWithFill(payload)), payload);
|
||||||
|
assert.deepEqual(decodeWithFill(encodeWithFill(payload, Uint8Array.of(4, 5))), payload);
|
||||||
|
assert.throws(() => decodeWithFill(new Uint8Array([...encodeWithFill(payload), 0])));
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Client, LocalInterface, command, string, int32, array, unit, generateSigningKey, RemoteError } from '../dist/index.js';
|
||||||
|
const url = process.env.KILOPARSEC_TEST_URL;
|
||||||
|
const pin = Uint8Array.from(Buffer.from('03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8', 'hex'));
|
||||||
|
const echo = command('echo', string, string);
|
||||||
|
const callback = command('callback', string, string);
|
||||||
|
const clientEcho = command('clientEcho', string, string);
|
||||||
|
const notice = command('notice', string, unit);
|
||||||
|
|
||||||
|
test('source Kotlin protocol 2 server: pinned DH, identity, codecs, duplex, push, errors, parallel calls, reconnect', { skip: !url, timeout: 20_000 }, async () => {
|
||||||
|
const identity = await generateSigningKey();
|
||||||
|
const session = { calls: 0 };
|
||||||
|
let notify;
|
||||||
|
const noticed = new Promise(resolve => { notify = resolve; });
|
||||||
|
const local = new LocalInterface()
|
||||||
|
.on(clientEcho, async scope => {
|
||||||
|
assert.equal(scope.session, session);
|
||||||
|
assert.deepEqual(scope.remoteIdentity, pin);
|
||||||
|
scope.session.calls++;
|
||||||
|
// Server is waiting for this callback; client can call back into the server.
|
||||||
|
return await scope.remote.call(echo, 'nested ✓');
|
||||||
|
})
|
||||||
|
.on(notice, (scope, text) => { notify(text); });
|
||||||
|
const options = { session, local, identity, expectedServerKey: pin, heartbeatMs: 0 };
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = await Client.connect(url, options);
|
||||||
|
assert.equal(await client.call(echo, 'Привет, Kotlin 👋'), 'Привет, Kotlin 👋');
|
||||||
|
assert.equal(await client.call(command('identity', unit, string), undefined), Buffer.from(identity.publicKey).toString('hex'));
|
||||||
|
assert.equal(await client.call(command('sum', array(int32), int32), [-1000, 500, 507]), 7);
|
||||||
|
assert.equal(await client.call(callback, 'request callback'), 'nested ✓');
|
||||||
|
assert.equal(session.calls, 1);
|
||||||
|
client.push(command('push', string, unit), 'notification from TS');
|
||||||
|
assert.equal(await noticed, 'notification from TS');
|
||||||
|
await assert.rejects(client.call(command('fail', string, string), 'expected rejection'), e => e instanceof RemoteError && e.code === 'IllegalArgumentException');
|
||||||
|
await assert.rejects(client.call(command('missing-command', unit, unit), undefined), e => e instanceof RemoteError && e.code === 'UnknownCommand');
|
||||||
|
const calls = Array.from({ length: 64 }, (_, i) => `parallel:${i}`);
|
||||||
|
assert.deepEqual(await Promise.all(calls.map(x => client.call(echo, x))), calls);
|
||||||
|
const large = 'я'.repeat(65536);
|
||||||
|
assert.equal(await client.call(echo, large), large);
|
||||||
|
await client.ping();
|
||||||
|
client.close(); await client.closed;
|
||||||
|
client = await Client.connect(url, options);
|
||||||
|
assert.equal(await client.call(echo, 'new session'), 'new session');
|
||||||
|
} finally { client?.close(); identity.destroy(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {ReplayWindow} from '../dist/replay.js';
|
||||||
|
|
||||||
|
test('circular replay bitmap agrees with a reference across wraparound and jumps', () => {
|
||||||
|
const window = new ReplayWindow(), seen = new Set();
|
||||||
|
let high = -1n, seed = 17;
|
||||||
|
const inputs = [4096n, 1n, 2n, 0n, 1n, 8191n, 4096n, 8192n];
|
||||||
|
for (let i=0;i<15000;i++) { seed = (Math.imul(seed,1664525)+1013904223)>>>0; inputs.push(BigInt(seed % 30000)); }
|
||||||
|
inputs.push((1n<<64n)-1n, (1n<<64n)-4096n, (1n<<64n)-4097n);
|
||||||
|
for (const n of inputs) {
|
||||||
|
const reject = seen.has(n) || (n <= high && high-n >= 4096n);
|
||||||
|
assert.equal(window.containsOrStale(n), reject);
|
||||||
|
if (!reject) { window.record(n); seen.add(n); if(n>high) high=n; assert.equal(window.containsOrStale(n),true); }
|
||||||
|
}
|
||||||
|
window.clear(); assert.equal(window.containsOrStale(0n),false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {Client, LocalInterface, command} from '../dist/client.js';
|
||||||
|
import {generateSigningKey, createKeyExchange} from '../dist/crypto.js';
|
||||||
|
import {bytes, string} from '../dist/bipack.js';
|
||||||
|
import {blockCodec, handshakeCodec, sealedDataCodec, encryptedPackageCodec, commandCallCodec, protectedMessageCodec, authenticationMessage} from '../dist/wire.js';
|
||||||
|
|
||||||
|
/** In-memory hostile peer: no TLS or socket timing assumptions in these tests. */
|
||||||
|
async function peer(mode = 'normal') {
|
||||||
|
const key = await generateSigningKey();
|
||||||
|
const exchange = await createKeyExchange();
|
||||||
|
let session, nonce = 0n, previous, calls = [], peerError;
|
||||||
|
class Socket extends EventTarget {
|
||||||
|
readyState = 1; bufferedAmount = 0; binaryType = 'arraybuffer';
|
||||||
|
send(data) { queueMicrotask(() => { void receive(blockCodec.decode(data)).catch(e => { peerError = e; this.close(); }); }); }
|
||||||
|
close() { if (this.readyState === 3) return; this.readyState = 3; this.dispatchEvent(new Event('close')); }
|
||||||
|
}
|
||||||
|
const socket = new Socket();
|
||||||
|
const emit = block => {
|
||||||
|
const data = blockCodec.encode(block);
|
||||||
|
socket.dispatchEvent(new MessageEvent('message', {data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)}));
|
||||||
|
};
|
||||||
|
const encrypted = value => {
|
||||||
|
const n = nonce++;
|
||||||
|
return bytes.encode(encryptedPackageCodec.encode({nonce:n, encryptedMessage:session.encrypt(n,value)}));
|
||||||
|
};
|
||||||
|
async function receive(block) {
|
||||||
|
if (block.kind !== 'call') return;
|
||||||
|
if (block.name === 'L0Request') {
|
||||||
|
const request = handshakeCodec.decode(block.packedArgs);
|
||||||
|
session = exchange.serverSession(request.publicKey);
|
||||||
|
const now = BigInt(Date.now());
|
||||||
|
const createdAt = mode === 'future' ? now + 100000n : now;
|
||||||
|
const expiresAt = mode === 'expired' ? now - 1000n : null;
|
||||||
|
const signature = key.sign(sealedDataCodec.encode({message:mode === 'legacy-signature' ? session.token : authenticationMessage('server',session.token), nonce:null, createdAt, validUntil:expiresAt}));
|
||||||
|
if (mode === 'tamper') signature[0] ^= 1;
|
||||||
|
emit({kind:'response', forId:block.id, packedResult:handshakeCodec.encode({version:mode === 'downgrade' ? 1 : 2,publicKey:exchange.publicKey,
|
||||||
|
signature:mode === 'unsigned' ? null : {publicKey:key.publicKey,signature,nonce:null,createdAt,expiresAt}})});
|
||||||
|
} else if (block.name === 'L0ClientId') {
|
||||||
|
const p=encryptedPackageCodec.decode(bytes.decode(block.packedArgs));
|
||||||
|
const identity=session.decrypt(p.nonce,p.encryptedMessage);
|
||||||
|
const packedResult=mode === 'forged-finished' ? new Uint8Array() : encrypted(protectedMessageCodec.encode({kind:'finished',correlation:0n,priority:0,
|
||||||
|
payload:mode === 'wrong-finished' ? new Uint8Array() : identity}));
|
||||||
|
emit({kind:'response', forId:block.id, packedResult});
|
||||||
|
} else {
|
||||||
|
const pkg = encryptedPackageCodec.decode(bytes.decode(block.packedArgs));
|
||||||
|
const message=protectedMessageCodec.decode(session.decrypt(pkg.nonce,pkg.encryptedMessage));
|
||||||
|
const call = commandCallCodec.decode(message.payload);
|
||||||
|
let packedResult = encrypted(protectedMessageCodec.encode({kind:'response',correlation:message.correlation,priority:message.priority,payload:blockCodec.encode({kind:'response',forId:0,packedResult:call.serializedArgs})}));
|
||||||
|
if (mode === 'damage') packedResult[packedResult.length - 1] ^= 1;
|
||||||
|
if (mode === 'replay-then-valid') {
|
||||||
|
if (previous) emit({kind:'response',forId:block.id,packedResult:previous});
|
||||||
|
previous = packedResult;
|
||||||
|
}
|
||||||
|
if (mode === 'replay') { const saved = packedResult; packedResult = previous ?? saved; previous = saved; }
|
||||||
|
if (mode === 'swap') {
|
||||||
|
calls.push({id:block.id, packedResult});
|
||||||
|
if (calls.length === 2) {
|
||||||
|
emit({kind:'response',forId:calls[1].id,packedResult:calls[0].packedResult});
|
||||||
|
emit({kind:'response',forId:calls[0].id,packedResult:calls[1].packedResult});
|
||||||
|
}
|
||||||
|
} else emit({kind:'response',forId:block.id,packedResult});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {key, socket, injectCall({id=90, signedId=90n, priority=0, signedPriority=0, kind='call'}={}) {
|
||||||
|
const packet = {kind:'call', id, priority, name:'L0Call', packedArgs:encrypted(protectedMessageCodec.encode({kind,correlation:signedId,priority:signedPriority,payload:commandCallCodec.encode({name:'callback',serializedArgs:string.encode('payload')})}))};
|
||||||
|
emit(packet);
|
||||||
|
return () => emit(packet);
|
||||||
|
}, skipNonces(count) { nonce += BigInt(count); }, connect: (pin, extra={}) => Client.connect('ws://test.invalid/', {expectedServerKey:pin ?? key.publicKey,session:null,
|
||||||
|
heartbeatMs:0,timeoutMs:1000,socketFactory:() => socket,...extra}), cleanup() {socket.close();session?.destroy();key.destroy();exchange.destroy(); if(peerError) throw peerError;} };
|
||||||
|
}
|
||||||
|
const echo = command('echo',string,string);
|
||||||
|
test('server pin is mandatory before creating a socket', async () => {
|
||||||
|
let created = false;
|
||||||
|
await assert.rejects(Client.connect('ws://test.invalid/',{session:null,socketFactory:() => {created=true;}}), /trusted server key/);
|
||||||
|
assert.equal(created,false);
|
||||||
|
});
|
||||||
|
for (const mode of ['wrong-pin','unsigned','tamper','expired','future','downgrade','legacy-signature']) {
|
||||||
|
test(`handshake fails closed: ${mode}`,async () => {
|
||||||
|
const p = await peer(mode);
|
||||||
|
try {
|
||||||
|
await assert.rejects(p.connect(mode === 'wrong-pin' ? new Uint8Array(32) : undefined), e => e.code === 'authentication');
|
||||||
|
assert.equal(p.socket.readyState,3);
|
||||||
|
} finally {p.cleanup();}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('damaged encrypted response closes connection', async () => {
|
||||||
|
const p=await peer('damage');
|
||||||
|
try {
|
||||||
|
const client=await p.connect();
|
||||||
|
await assert.rejects(client.call(echo,'hello'),e=>e.code==='protocol');
|
||||||
|
await client.closed;
|
||||||
|
await assert.rejects(client.call(echo,'again'),e=>e.code==='closed');
|
||||||
|
} finally {p.cleanup();}
|
||||||
|
});
|
||||||
|
test('replayed encrypted response is discarded without closing connection', async () => {
|
||||||
|
const p=await peer('replay');
|
||||||
|
try {
|
||||||
|
const client=await p.connect(); assert.equal(await client.call(echo,'first'),'first');
|
||||||
|
await assert.rejects(client.call(echo,'second',{timeoutMs:20}),e=>e.code==='timeout');
|
||||||
|
assert.equal(p.socket.readyState,1);
|
||||||
|
} finally {p.cleanup();}
|
||||||
|
});
|
||||||
|
test('outer forId substitution fails closed without delivering swapped responses', async () => {
|
||||||
|
const p=await peer('swap');
|
||||||
|
try {
|
||||||
|
const client=await p.connect();
|
||||||
|
const results=await Promise.allSettled([client.call(echo,'first'),client.call(echo,'second')]);
|
||||||
|
assert.ok(results.every(r=>r.status==='rejected'));
|
||||||
|
assert.ok(results.some(r=>r.reason.code==='protocol'));
|
||||||
|
await client.closed;
|
||||||
|
client.close();
|
||||||
|
} finally {p.cleanup();}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mode of ['forged-finished','wrong-finished']) {
|
||||||
|
test(`connection requires authenticated identity confirmation: ${mode}`,async()=>{
|
||||||
|
const p=await peer(mode);
|
||||||
|
try { await assert.rejects(p.connect()); assert.equal(p.socket.readyState,3); }
|
||||||
|
finally {p.cleanup();}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, metadata] of [
|
||||||
|
['outer call ID substitution',{id:91}],
|
||||||
|
['call rewritten as push',{id:0}],
|
||||||
|
['outer priority substitution',{priority:8}],
|
||||||
|
['protected response rewritten as call',{kind:'response'}],
|
||||||
|
]) {
|
||||||
|
test(`incoming metadata is authenticated before dispatch: ${name}`,async()=>{
|
||||||
|
const p=await peer(); let invoked=0;
|
||||||
|
const local=new LocalInterface().on(command('callback',string,string),()=>{invoked++;return 'response';});
|
||||||
|
try {
|
||||||
|
const client=await p.connect(undefined,{local});
|
||||||
|
p.injectCall(metadata);
|
||||||
|
await client.closed;
|
||||||
|
assert.equal(invoked,0);
|
||||||
|
} finally {p.cleanup();}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
test('a replayed response does not consume the waiter for the valid reply', async () => {
|
||||||
|
const p = await peer('replay-then-valid');
|
||||||
|
try {
|
||||||
|
const client = await p.connect();
|
||||||
|
assert.equal(await client.call(echo, 'first'), 'first');
|
||||||
|
assert.equal(await client.call(echo, 'second'), 'second');
|
||||||
|
assert.equal(p.socket.readyState, 1);
|
||||||
|
} finally { p.cleanup(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate and stale incoming pushes are discarded without closing', async () => {
|
||||||
|
const p = await peer();
|
||||||
|
let calls = 0;
|
||||||
|
const local = new LocalInterface().on(command('callback', string, string), () => { calls++; return 'ok'; });
|
||||||
|
try {
|
||||||
|
const client = await p.connect(undefined, {local});
|
||||||
|
const replay = p.injectCall({id:0, signedId:0n, kind:'push'});
|
||||||
|
replay();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
p.skipNonces(4096);
|
||||||
|
p.injectCall({id:0, signedId:0n, kind:'push'});
|
||||||
|
replay();
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(await client.call(echo, 'still alive'), 'still alive');
|
||||||
|
} finally { p.cleanup(); }
|
||||||
|
});
|
||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user