Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8f73e21b4 |
No files matched your search
+3
-14
@@ -5,7 +5,6 @@ build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
@@ -24,7 +23,8 @@ out/
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-caches
|
||||
.sts4-cache
|
||||
bin/
|
||||
!**/src/main/**/bin/
|
||||
!**/src/test/**/bin/
|
||||
|
||||
@@ -39,15 +39,4 @@ out/
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
|
||||
# More
|
||||
.kotlin
|
||||
/.idea/workspace.xml
|
||||
/.gigaide/gigaide.properties
|
||||
local.properties
|
||||
/kotlin-js-store/yarn.lock
|
||||
|
||||
# JVM crash diagnostics must never be published
|
||||
*.hprof
|
||||
hs_err_pid*.log
|
||||
.DS_Store
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+2
-2
@@ -1,7 +1,7 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.4">
|
||||
<artifact type="jar" name="kiloparsec-js-0.1.0-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.4.jar">
|
||||
<root id="archive" name="kiloparsec-js-0.1.0-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
@@ -1,6 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.1.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.1.2-SNAPSHOT.jar" />
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.2-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.5">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.5.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.5-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.5-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.2.6">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.2.6.jar" />
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.1">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.1.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.2">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.2.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-js-0.3.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-js-0.3.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jsMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<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
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.1-SNAPSHOT">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.1.0-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.1-SNAPSHOT.jar">
|
||||
<root id="archive" name="kiloparsec-jvm-0.1.0-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
@@ -1,6 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.1.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.1.2-SNAPSHOT.jar" />
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.2-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.2-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.4">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.4.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.5">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.5.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.5-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.5-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.2.6">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.2.6.jar" />
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.1">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.1.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.1-SNAPSHOT">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.1-SNAPSHOT.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.2">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.2.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<component name="ArtifactManager">
|
||||
<artifact type="jar" name="kiloparsec-jvm-0.3.3">
|
||||
<output-path>$PROJECT_DIR$/build/libs</output-path>
|
||||
<root id="archive" name="kiloparsec-jvm-0.3.3.jar">
|
||||
<element id="module-output" name="kiloparsec.jvmMain" />
|
||||
</root>
|
||||
</artifact>
|
||||
</component>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<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
@@ -1,8 +0,0 @@
|
||||
<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,29 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<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>
|
||||
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
|
||||
</ScalaCodeStyleSettings>
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ReplaceUntilWithRangeUntil" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.20" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="showProblemsInCodeBlocks" value="false" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
-1
@@ -1,9 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="17 (5)" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ScalaCompilerConfiguration">
|
||||
<option name="separateProdTestSources" value="false" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,463 +0,0 @@
|
||||
# Concealed Kiloparsec protocol: design record
|
||||
|
||||
Recorded: 2026-10-21; implementation work started 2026-10-22. Current candidate: 0.9.0-SNAPSHOT, an experimental source implementation and wire draft with [qualification evidence](CONCEALED-PROTOCOL-QUALIFICATION.md); not an audited or universally runtime-qualified protocol.
|
||||
|
||||
See [wire specification](CONCEALED-PROTOCOL-WIRE.md), [implementation/continuation](CONCEALED-PROTOCOL-IMPLEMENTATION.md) and the [timing assessment](CONCEALED-PROTOCOL-TIMING.md) for current code, usage, measurements and validation status.
|
||||
|
||||
This document preserves the conversation before further design work. It distinguishes agreed direction from assistant proposals and unresolved details. It is the continuation point for this work. The existing protocol-2 review is [REVIEW-237d404.md](REVIEW-237d404.md); its issue numbers I1–I7 concern the released implementation, not this new design.
|
||||
|
||||
Stable references: **D1–D14** are decisions/directions, **C1–C8** are corrections and reasoning, **Q1–Q14** are discussion questions (Q1/Q3–Q12 resolved at design level; Q2 is a specification task; Q13/Q14 are implementation/validation tasks), **V1–V7** are proposed validation steps. Do not renumber existing references; append new ones. Questions are not approval gates for routine work. Discuss them individually before freezing the wire format.
|
||||
|
||||
## 1. Objective and scope
|
||||
|
||||
### D1 — Conceal protocol metadata as well as application data
|
||||
|
||||
The present protocol has recognizable envelopes and exposed routing metadata. The new flavor should make its wire bytes look random, including the initial handshake, while authenticating the information needed to process records. No visible magic prefix, protocol version, command name, RPC identifier, priority, error code or sequential packet counter should be required in the concealed encoding.
|
||||
|
||||
This is a second connection flavor, not an instruction to replace the released protocol. Use separate configured ports/URLs for the existing and concealed flavors (resolved Q8). Endpoint configuration selects the protocol before parsing the handshake; no wire marker or autodetection is required. The original shared-endpoint autodetection idea is deferred as an optional future capability, not initial scope.
|
||||
|
||||
Random-looking bytes do not conceal endpoints, traffic volume, timing, packet lengths or application traffic patterns. A raw TCP connection and a WebSocket connection also expose different carrier information. No claim of universal undetectability, censorship resistance, or security against all active attacks has been established.
|
||||
|
||||
### D2 — KMP first, no new crypto library dependency
|
||||
|
||||
Scope is the Kotlin Multiplatform library. Leave the lightweight TypeScript client and its present protocol behavior unchanged. A later TS implementation is possible, but is not part of the initial implementation.
|
||||
|
||||
Reuse the existing libsodium bindings and Ionspin KMP bignum library. Avoid adding Monocypher or another runtime crypto library solely for Elligator. Independent implementations may be used as test references without becoming shipped dependencies.
|
||||
|
||||
### D3 — A pre-known, publicly distributable server encryption key
|
||||
|
||||
The client knows a suitable server encryption public key before the handshake. It may be obtained from a site, API, an earlier authenticated connection, or another provisioning mechanism. Confidential distribution is not required. Authentic distribution is still necessary to know which server is being authenticated.
|
||||
|
||||
A shared confidential entrance secret was proposed and not selected. Anyone knowing the public encryption key can construct a new encrypted HELO. This means the design does not prevent active probes by such parties merely through bootstrap encryption.
|
||||
|
||||
Separate encryption and signing keys are involved if we use both proofs. Their authentic association, provisioning and replacement belong to application policy (resolved Q3); automatic rotation is outside the communications protocol.
|
||||
|
||||
## 2. Cryptographic bootstrap
|
||||
|
||||
### D4 — X25519 with Elligator 2 concealment
|
||||
|
||||
RSA was considered, but not selected. Ordinary X25519 ephemeral key encodings are distinguishable from uniformly random 32-byte strings. Elligator 2 is the selected direction for concealing the exposed ephemeral bootstrap key.
|
||||
|
||||
Conceptual bootstrap layout, not yet a wire specification:
|
||||
|
||||
```text
|
||||
32-byte Elligator representative | authentication tag | encrypted HELO
|
||||
```
|
||||
|
||||
The discussion used a 16-byte tag and the 48-byte overhead of libsodium sealed boxes as the model. This does not mean an unchanged crypto_box_seal call implements the intended bootstrap. Exact suite, derivation encodings and authenticated data remain Q2; the response-protection choice is resolved in Q1.
|
||||
|
||||
The server's static encryption public key need not itself be Elligator-encoded in the packet: it is pre-known. Only ephemeral key material exposed on the wire needs concealment. Subsequent DH keys can be carried inside encryption.
|
||||
|
||||
Correct concealed key generation requires more than applying an inverse map to an ordinary public key:
|
||||
|
||||
1. Generate a fresh ephemeral secret.
|
||||
2. Generate the specially randomized public point, including the required small-order component/sign handling.
|
||||
3. Apply the inverse Elligator map; regenerate locally if the point is not representable.
|
||||
4. Encode with the required random choices, including otherwise unused high bits.
|
||||
|
||||
Approximately two generation attempts are expected for a representable point. This is distinct from rejecting an invalid peer DH input: never loop regenerating our key to accommodate a malicious low-order peer key.
|
||||
|
||||
### D5 — Implement the mapping in Kotlin using existing arithmetic
|
||||
|
||||
Verified dependency graph in Kiloparsec:
|
||||
|
||||
```text
|
||||
crypto2:0.9.0 -> com.ionspin.kotlin:bignum:0.3.10
|
||||
```
|
||||
|
||||
BigInteger/ModularBigInteger, not BigDecimal, supply the relevant arithmetic. Existing bindings 0.9.6 expose Edwards base multiplication, point addition and X25519. Proposed split:
|
||||
|
||||
| Work | Component |
|
||||
| --- | --- |
|
||||
| Random bytes, base multiplication, point addition, X25519 | Existing libsodium |
|
||||
| Edwards/Montgomery conversion, Elligator maps, encoding | Kotlin + existing bignum |
|
||||
| Hashing, authentication and encryption | Existing libsodium |
|
||||
|
||||
Libsodium's scalar arithmetic is modulo the subgroup order; Elligator field arithmetic is modulo p = 2^255 - 19. They are not interchangeable. Its from_uniform function maps to a subgroup point; it does not provide the inverse concealed-key generation we need.
|
||||
|
||||
This is a feasible direction, not a tested implementation. Mixed-order point acceptance must be checked through the actual bindings. General bignum arithmetic is not assumed constant-time. Concealed intermediate points cannot automatically be treated as harmless public information. Start with a readable, independently checked prototype, then evaluate timing and production suitability. Added binary size must be measured: using already-present dependencies can retain additional previously stripped code.
|
||||
|
||||
### D6 — Move client identity proof into a fresh-session ACK
|
||||
|
||||
Agreed handshake direction:
|
||||
|
||||
```text
|
||||
C -> S: HELO, encrypted to the known server encryption key
|
||||
fresh client session-DH public key; no client identity/signature
|
||||
|
||||
S -> C: server hello
|
||||
fresh server session-DH public key
|
||||
proof relating to this HELO
|
||||
server signature (agreed addition)
|
||||
encrypted/authenticated with the Q1 reply key and a fresh random 24-byte IV
|
||||
|
||||
C -> S: ACK, encrypted/authenticated under fresh session keys
|
||||
client identity public key
|
||||
client signature binding this session
|
||||
|
||||
S -> C: acceptance, encrypted/authenticated under fresh session keys
|
||||
sent after ACK verification and successful application authorization
|
||||
```
|
||||
|
||||
Both session-DH key pairs are fresh for each connection attempt. Remove the client identity public key as well as its signature from HELO. Moving only the signature would not conceal identity against later compromise of the server's static decryption key.
|
||||
|
||||
The user proposed the connection token derived from the directional session-key pair in the proper order as the ACK's signed value. The current SafeKeyExchange session tag is a relevant existing mechanism, but the exact new token derivation and signature input are not frozen. Role/domain separation and handshake-context binding were agreed: cover the connection token, both session-DH public keys and selected flavor/options. Q2 remains the task of specifying exact inputs and encodings.
|
||||
|
||||
Once ACK arrives and verifies, the server has authenticated the client. It then completes application authorization and sends a protected acceptance response. The client reports connected only after verifying this response. This fourth handshake message is agreed (resolved Q4).
|
||||
|
||||
### D7 — Server proof and signature
|
||||
|
||||
The user proposed BLAKE2b(clientSessionDhPublicKey) as proof that the server decrypted HELO. That client public key is fresh and concealed inside HELO; it is not necessarily the exposed ephemeral bootstrap key. Under that assumption, the hash is evidence of knowing the concealed value.
|
||||
|
||||
The user subsequently agreed to add a server signature over the server DH key and that proof. The exact signed transcript, role label, options and identity-key association remain Q2/Q3.
|
||||
|
||||
Q1 is resolved: derive a domain-separated temporary reply key from the fresh client session-DH public key concealed inside HELO. Encrypt/authenticate the complete signed server response using that key and a fresh random 24-byte IV, transmitted alongside the ciphertext. This uses the concealed client DH value as bootstrap material, not the exposed Elligator representative. Retain the agreed server signature. Exact suite and derivation encodings remain Q2.
|
||||
|
||||
### D8 — Reject failed DH agreement at the crypto interface
|
||||
|
||||
Libsodium crypto_kx already rejects unacceptable low-order inputs before deriving session keys. Inspected JVM and Linux bindings propagate failures. Runtime tests through crypto2 verified rejection on both roles, including nonzero low-order encodings and high-bit aliases.
|
||||
|
||||
The check concerns failure of the operation / an all-zero raw DH result, not merely whether received public-key bytes contain a nonzero byte. Checking derived session keys for zero is not a substitute: those keys are hashes of the DH result and context.
|
||||
|
||||
The crypto2 investigation found a separate missing length guard. SafeKeyExchange now rejects peer public-key arrays whose length is not exactly 32 before entering native code. This was published as crypto2 0.9.6-SNAPSHOT to Maven Local/private Maven and committed/pushed as 70d7a13. All 67 JVM and 67 Linux Native tests passed. Its focused JS tests fail during the existing libsodium loader initialization (_sodium_init is not a function), not DH validation. Other runtimes were not qualified.
|
||||
|
||||
Kiloparsec has not been switched from its existing crypto2 dependency as part of this design discussion.
|
||||
|
||||
## 3. Established connection
|
||||
|
||||
### D9 — One protection layer below RPC
|
||||
|
||||
Do not tunnel the current encrypted L0Call protocol inside the new encrypted channel. Serialize ordinary RPC blocks, then protect them once at the channel layer.
|
||||
|
||||
```text
|
||||
Command / RemoteInterface / LocalInterface / application scope
|
||||
-> RPC calls, responses, errors, pushes and correlation
|
||||
-> protected channel
|
||||
-> carrier
|
||||
```
|
||||
|
||||
Reuse application APIs and authorization hooks. A serialized Transport.Block can initially serve as the inner message. Its IDs, names, priority and error metadata are then entirely concealed and authenticated. Avoid duplicating those fields in another ProtectedMessage merely to preserve the old wrapping architecture.
|
||||
|
||||
Current relevant implementation points:
|
||||
|
||||
- Transport.kt: block serialization, correlation, pending waiters, dispatch and sending.
|
||||
- KiloRemoteInterface.kt and KiloL0Interface.kt: current L0Call/protected-message wrapping.
|
||||
- KiloClientConnection.kt and KiloServerConnection.kt: current handshake and lifecycle.
|
||||
- KiloParams.kt: current session cipher/counter handling.
|
||||
- ReplayWindow.kt, adapter/OutQueue.kt, adapter/PriorityBlockWriter.kt: replay/scheduling machinery.
|
||||
|
||||
The exact common interface and refactoring boundary remain Q12. Device currently carries bytes and is not, by itself, a sufficient interface for carrying local scheduling metadata.
|
||||
|
||||
### D10 — UDP: independent records with random 24-byte IVs
|
||||
|
||||
Agreed for the datagram path:
|
||||
|
||||
```text
|
||||
random IV [24 bytes] | authentication tag [16 bytes] | ciphertext {
|
||||
sequence number | RPC block | optional padding
|
||||
}
|
||||
```
|
||||
|
||||
The IV is the encryption nonce. Generate it with the CSPRNG; no session-seeded custom PRNG construction is selected. Random generation gives negligible collision probability, not strict mathematical uniqueness. For q independent 192-bit nonces under one directional key, collision probability is approximately q(q-1)/2^193.
|
||||
|
||||
Fresh directional session keys isolate the two directions. The concealed sequential counter performs replay tracking and queue ordering; the random IV permits independent decryption after loss/reordering. Authenticate before updating replay state, dispatching a command or consuming a pending response waiter. Duplicate/stale records are nonfatal.
|
||||
|
||||
Retain the preparation mutex and sequence-aware priority queue: allocate sequence, encrypt and admit atomically; await actual transmission outside the mutex. Counter exhaustion must not wrap. The existing 4096-position window and 2730 maximum local overtaking are the reuse baseline, not a guarantee against arbitrary network delay.
|
||||
|
||||
Q9 adds configurable random-length random-byte padding inside the protected record. No packet-loss recovery for application RPCs is implicitly added by encryption or replay tracking.
|
||||
|
||||
### D11 — TCP: continuous encryption plus object authentication
|
||||
|
||||
The user chose a continuous counter-mode/stream-cipher construction. Each direction maintains its own keystream position; arbitrary socket write/read segmentation must not affect it. Use fresh directional encryption keys and a defined initial nonce/counter; never restart the stream under reused key/nonce material.
|
||||
|
||||
Agreed conceptual plaintext stream:
|
||||
|
||||
```text
|
||||
length | encoded object + optional padding | keyed BLAKE2b tag
|
||||
length | encoded object + optional padding | keyed BLAKE2b tag
|
||||
...
|
||||
```
|
||||
|
||||
Encrypt every byte, including lengths and tags, with the continuous stream. No per-object IV is needed on this path. TCP's ordered delivery synchronizes the stream position. This supersedes the random-per-record-IV proposal for TCP only; UDP retains D10.
|
||||
|
||||
Proposed MAC input, accepted conceptually:
|
||||
|
||||
```text
|
||||
tag = keyedBLAKE2b(macKey, sequence || exactEncodedLength || exactProtectedBodyBytes)
|
||||
```
|
||||
|
||||
The protected body contains the encoded object and optional Q9 padding, with authenticated delimitation between them. Exact padded-body length encoding is Q2 specification work. The MAC covers the entire body, including padding and its delimiters. Use a separately derived MAC key for each direction and a 16-byte tag. The TCP object sequence may be implicit, incrementing on authenticated objects. Exact integer encodings, domain separation, cipher and limits remain Q2/Q6/Q7.
|
||||
|
||||
Receive pipeline:
|
||||
|
||||
1. Decrypt a bounded length header.
|
||||
2. Reject invalid/over-limit lengths before allocation or body reading.
|
||||
3. Read bounded object bytes and tag with bounded waiting.
|
||||
4. Verify the MAC in constant time.
|
||||
5. Only then decode BiPack and invoke RPC/application logic.
|
||||
|
||||
The length remains untrusted until MAC verification. A modified length can cause a bounded read, wait or connection failure; the construction does not eliminate denial of service. On invalid MAC, abort; do not scan for resynchronization or continue consuming objects.
|
||||
|
||||
This is a custom construction requiring validation, not an already-audited equivalent of a standard AEAD record protocol. Authenticate the exact received bytes, not a decoded-and-reserialized object. Authenticated clean closure is selected in resolved Q11.
|
||||
|
||||
### D12 — Priority and fairness must survive the new layering
|
||||
|
||||
The current queue's bounded nonce overtaking intrinsically prevents indefinite postponement of old messages. The user explicitly valued that property over a simpler lambda queue that encrypts only when popped.
|
||||
|
||||
TCP's continuous cipher must be advanced in actual wire order. Therefore select complete encoded objects before advancing stream encryption; serialize writes through one writer. Once bytes of an object enter the stream, another object's bytes cannot be arbitrarily interleaved with them.
|
||||
|
||||
Resolved Q7: reuse the existing generic OutQueue bounded-overtaking algorithm for TCP as well as UDP. For TCP, its ordering field is a monotonically increasing local admission ordinal, not the stream cipher position or implicit MAC sequence. Allocate the ordinal and enqueue atomically. Queue encoded plaintext objects; a single writer pops by priority within the bounded window, assigns the wire-order MAC sequence, authenticates/encrypts, and writes. For UDP, retain the sequence-aware ciphertext queue from D10. A large in-progress object can delay small urgent objects; cancellation after writing starts must finish that record or close the stream.
|
||||
|
||||
### D13 — Preserve application authorization and scope
|
||||
|
||||
Kotlin intentionally does not reduce server/client trust policy to a constructor list of keys. Verified remote credentials and session information must be exposed through application scope/init handlers. SecurityException and BreakConnectionException subclasses must still abort initialization.
|
||||
|
||||
Preserve the existing deferred readiness mechanism (resolved Q5): calls made before readiness suspend awaiting the result. RPC starts only when identities are ready and the channel is protected. Preserve existing initialization/application-policy ordering rather than introducing a new pre-establishment RPC mechanism or a blanket prohibition on RPC from init handlers. Application authorization remains enforced by its existing hooks/policy; Q4 governs the client connected notification after protected acceptance.
|
||||
|
||||
Protocol replay prevention is not business-operation deduplication after reconnect. A new valid request may be an intentional repeat; application idempotency remains application responsibility.
|
||||
|
||||
### D14 — Compatibility and release scope
|
||||
|
||||
Current Kiloparsec 0.8.0 was published and pushed as 009d62a / tag v0.8.0. The review's I1–I5 repairs are included; I6/I7 were closed as intentional decisions/non-defects. The concealed protocol is future work and must not be represented as part of that release.
|
||||
|
||||
No new wire version number, public API name or release version has been chosen. Avoid calling the new flavor "protocol 2" ambiguously: the existing released authenticated envelope already uses protocol 2.
|
||||
|
||||
## 4. Corrections that must not be lost
|
||||
|
||||
### C1 — EC encryption is hybrid encryption
|
||||
|
||||
X25519's 32 bytes are a public coordinate/shared-secret size, not an RSA-style plaintext block size. Public-key encryption normally exposes an ephemeral public key plus symmetric ciphertext/tag. The ephemeral bootstrap key and the client session-DH key concealed inside HELO are separate in the discussed design; their reuse/separation must be explicit in Q2.
|
||||
|
||||
### C2 — Ordinary public keys leave a statistical fingerprint
|
||||
|
||||
For normally generated X25519 encodings, canonical field range, curve membership and prime-subgroup membership give public tests. Roughly 1/32 of uniformly random 32-byte strings passes all three. This is a distinguisher against uniform bytes, not a probability that a packet is Kiloparsec. Multiple independent observations strengthen it. Poly1305 tags have no analogous public validity test under the usual assumptions.
|
||||
|
||||
### C3 — RSA does not automatically look uniform either
|
||||
|
||||
A ciphertext integer is less than the public modulus n. Random k-bit values pass that range test with probability n/2^k. RSA was not adopted as an escape from encoding distinguishers.
|
||||
|
||||
### C4 — Continuous XOR encryption does not authenticate
|
||||
|
||||
Fresh masks and fresh keys do not prevent chosen bit flips in a single ciphertext: (P XOR K XOR delta) XOR K = P XOR delta. The attack does not require keystream reuse.
|
||||
|
||||
### C5 — Encrypted CRC is not a MAC
|
||||
|
||||
BiPack has @CrcProtected; inspected cached source uses CRC32Source and checks at endStructure. CRC changes are predictable from a fixed-length bit-change pattern without knowing the message. For standard affine CRC conventions, the correction is CRC(delta) XOR CRC(zeros). Encrypting the CRC under a different fresh mask does not stop applying that correction. Secret CRC initialization would not fix the algebra. Keyed BLAKE2b was selected instead.
|
||||
|
||||
### C6 — Freshness and signature strength are different
|
||||
|
||||
An identity signature in HELO proves that the signer produced those bytes sometime. A captured HELO can be replayed. ACK binding the server's fresh contribution proves participation in the current handshake. This is why client authentication was moved; no claim that the signature algorithm itself was weak is intended.
|
||||
|
||||
### C7 — Correct interpretation of the hash-echo proposal
|
||||
|
||||
The assistant initially treated clientDH as public to Mallory and overstated a key-substitution attack. Correction: client session-DH is concealed in HELO. Mallory can copy the correct hash from the current response and substitute her own serverDH, so the hash does not bind the adjacent server key. But with a normal attacker-generated DH key and no access to clientDH, that attack alone does not give Mallory the shared secret or readable ACK. It causes a failed exchange, not demonstrated successful impersonation. Low-order inputs must be rejected. No complete security proof of the unsigned design was established.
|
||||
|
||||
Possession of the decryption private key is not inherently weaker evidence than possession of a signing private key. The distinction is what exact response/transcript is authenticated. Adding a signature was agreed, but does not automatically double security, especially if both private keys share the same compromise boundary.
|
||||
|
||||
### C8 — Three handshake messages versus confirmation
|
||||
|
||||
Three messages can provide server authentication to the client and client authentication to the server when ACK verifies. They do not by themselves tell the client that ACK arrived or authorization succeeded. A dedicated protected acceptance response was subsequently agreed in Q4. This lifecycle choice does not mean the three-message authentication argument was invalid.
|
||||
|
||||
## 5. Decisions and remaining specification/validation tasks
|
||||
|
||||
### Q1 — Server hello protection — RESOLVED
|
||||
|
||||
**Agreed:** derive the temporary response-encryption key with a domain-separated KDF from the fresh client session-DH public key concealed inside HELO. Send a fresh random 24-byte IV alongside the authenticated encryption of the complete server response, including its fresh session-DH key, proof and server signature. The IV is not derived deterministically from the client DH key. Exact KDF labels, cipher suite and byte encodings are specified under Q2.
|
||||
|
||||
```text
|
||||
replyKey = KDF(concealedClientSessionDhPublicKey, replyDomain)
|
||||
wire = randomIV[24] | authenticatedCiphertext(replyKey, randomIV, signedServerHello)
|
||||
```
|
||||
|
||||
**Duplicate guard:** keep a bounded ring buffer of recently used client DH public keys and silently ignore repeats. Check/insert atomically after authenticating/decrypting HELO and validating its DH key, before allocating the new session. This is a best-effort operational guard against accidental client DH reuse, not a cryptographic security requirement. Capacity/retention are implementation parameters; eviction or restart is acceptable.
|
||||
|
||||
A replayed HELO alone cannot authenticate a client: the server generates fresh session-DH material, so the old ACK does not bind to the new session. Random response IVs avoid deterministic key/IV reuse when an old HELO is accepted after eviction/restart. This does not remove resource-exhaustion concerns or stop an attacker from creating fresh HELOs using the public server key; those remain Q10.
|
||||
|
||||
For UDP, a lost response followed by the same HELO may encounter the silent duplicate guard. A new client connection attempt therefore uses a fresh DH pair and fresh HELO; retry/lifecycle details remain Q10. Forward secrecy of client identity still comes from protecting ACK with fresh ephemeral session keys.
|
||||
|
||||
### Q2 — Cryptographic suite and transcript — SPECIFICATION TASK
|
||||
|
||||
**Direction agreed; exact specification and independent composition fixtures now exist.** This is not an outstanding architectural choice or a request for approval of every encoding detail. Reuse existing libsodium primitives, X25519/Elligator bootstrap concealment, a separate fresh session-DH exchange, Q1 response protection, server signature, session-bound client ACK and separate directional encryption/MAC keys.
|
||||
|
||||
Bind the handshake context as well as the connection token: both session-DH public keys and selected flavor/options. Draft exact Elligator encoding, KDF labels and inputs, session-token ordering, signed/MACed bytes, stream cipher initialization, integer encodings, failure handling, secret erasure and test vectors. Bind actual transmitted representative bytes to handle equivalent encodings. The completed specification still requires review and validation; this status does not claim those tasks are finished.
|
||||
|
||||
### Q3 — Server key provisioning and rotation — RESOLVED
|
||||
|
||||
**Application-level policy, not a communications-protocol rotation mechanism.** Each connection attempt uses fresh ephemeral keys. Longer-lived client/server identity keys and the server encryption key used for HELO are provisioned and replaced by the application. Do not add automatic rotation, overlap negotiation or key-selection machinery merely to satisfy this former question.
|
||||
|
||||
The server's signature authenticates its HELO response when verified against an application-trusted verifying key and bound to the agreed handshake context. A key supplied in the response alone is not a trust anchor. The protocol exposes credentials for application policy; it does not prescribe how that policy obtains, associates or rotates the keys.
|
||||
|
||||
### Q4 — Connected/authorized notification — RESOLVED
|
||||
|
||||
**Use a dedicated authenticated, session-encrypted response to the client ACK.** The server verifies the client identity proof and completes authorization before sending acceptance. The client reports connected only after verifying that acceptance.
|
||||
|
||||
```text
|
||||
Client -> Server: encrypted HELO
|
||||
Server -> Client: protected, signed server hello
|
||||
Client -> Server: session-protected ACK with client identity proof
|
||||
Server -> Client: session-protected acceptance
|
||||
```
|
||||
|
||||
This is four handshake messages. Rejection, timeout and lost-message behavior still need the retry/failure details in Q10; no acceptance is sent for failed authorization.
|
||||
|
||||
### Q5 — RPC readiness during initialization — RESOLVED
|
||||
|
||||
**Reuse the original deferred readiness gate.** RPC calls made before the required identities and protected channel are ready suspend awaiting a deferred result. Once readiness is established, waiting calls may proceed through the protected RPC path. Do not send application RPC over an unprotected or identity-unready channel.
|
||||
|
||||
Preserve existing initialization semantics and ordering. The assistant's earlier suggestion to prohibit RPC from authorization/init handlers was not adopted; no new special pre-establishment RPC mechanism is required. Do not move deferred completion behind a handler that awaits that same deferred, creating a circular wait. Q4's protected acceptance remains the agreed client connected-notification condition.
|
||||
|
||||
Initialization rejection or connection failure must complete/fail the readiness wait appropriately so suspended callers are released with failure rather than left waiting indefinitely. Regression tests should cover calls queued before readiness, successful release, rejection/close, and the existing init-handler behavior. This records the intended reuse; it does not claim the future concealed-channel implementation has already passed those tests.
|
||||
|
||||
### Q6 — TCP framing and object authentication — RESOLVED
|
||||
|
||||
**Already agreed in D11:** use a continuous counter-mode/stream cipher over successive variable-length BiPack objects, each prefixed with its encoded byte length and followed by a keyed BLAKE2b authentication tag. Encrypt the length and tag as part of the same stream. No per-object IV or fixed-size chunks are introduced. Q9 subsequently adds configurable optional padding.
|
||||
|
||||
```text
|
||||
continuousEncryption(length | protectedBody | tag | length | protectedBody | tag | ...)
|
||||
protectedBody = encoded object plus optional padding, with authenticated delimitation
|
||||
tag = keyedBLAKE2b(directionMacKey, sequence || exactEncodedLength || protectedBody)
|
||||
```
|
||||
|
||||
Objects have arbitrary variable size within configured resource limits, not unlimited allocation. The receiver decrypts a bounded length, reads the bounded object and tag, verifies authentication, and only then invokes BiPack decoding/RPC dispatch. Separate directional encryption and MAC keys, implicit TCP object sequence, and a 16-byte tag remain the agreed direction. Reject authentication failure and close rather than attempting resynchronization.
|
||||
|
||||
Section 6 supplies the length encoding and configurable size/deadline defaults. Exact sequence encoding, counter exhaustion and test vectors remain specification/implementation work under Q2, not reasons to reopen the framing architecture. UDP retains independent records with random 24-byte IVs (D10).
|
||||
|
||||
### Q7 — Universal bounded-overtaking queue — RESOLVED
|
||||
|
||||
**Reuse the existing OutQueue algorithm for both carriers.** Its generic payload and monotonically increasing ordering value already support this. Keep the existing allowance initially (2730), with queue capacity/backpressure handled by the writer. The window prevents indefinitely admitting newer high-priority work ahead of the oldest queued item; it is not a wall-clock latency guarantee.
|
||||
|
||||
For TCP, assign a local admission ordinal atomically with queue insertion and store the encoded plaintext object. After pop, the single stream writer assigns the next implicit MAC sequence, computes the MAC, encrypts in actual wire order, and writes the complete record. Admission ordinals are local scheduler metadata and need not be transmitted. They are distinct from both MAC sequence numbers and stream cipher counters. Every normal queued object receives an ordinal; bypassing the bound with null ordinals would undermine the fairness guarantee.
|
||||
|
||||
For UDP, preserve D10: assign the protected record sequence, encrypt with a random IV and admit under the preparation mutex; the queue orders already protected records using that sequence. Thus the same scheduling algorithm applies with different preparation placement required by each carrier.
|
||||
|
||||
The queue bounds sender-side overtaking; it does not prevent arbitrary network reordering or loss. Partial-write/cancellation handling, counter exhaustion and tests remain implementation work under Q2/V3/V6. No additional scheduling algorithm is required.
|
||||
|
||||
### Q8 — Endpoint selection and optional autodetection — RESOLVED
|
||||
|
||||
**Use separate ports/URLs selected by application/deployment configuration.** Each listener/route knows its protocol flavor before processing the first message. No automatic detection, probing, trial parsing of legacy traffic, or client downgrade is required for the initial implementation.
|
||||
|
||||
Shared-endpoint autodetection is technically possible, but its rare operational benefit does not justify the initial complexity. Record it only as an optional future feature for a concrete deployment need (for example, coexistence on a single permitted TCP port); it is not a prerequisite or committed implementation task.
|
||||
|
||||
Dedicated endpoints do not themselves conceal the service's address or purpose. A WebSocket URL selects the route but does not hide HTTP/WebSocket setup. Bounded HELO framing and malformed-input handling are still required and belong to Q2/Q10; removing autodetection does not remove those requirements.
|
||||
|
||||
### Q9 — Configurable size noise — RESOLVED
|
||||
|
||||
**Add configurable random-length filling with random bytes to outgoing protocol records.** Apply padding inside encryption and authentication, including its delimiting/length information, so the receiver can recover the object unambiguously and an attacker cannot expose the true size by reading a padding-length field. The MAC/authentication covers the entire padded body. No unauthenticated filler is appended outside the protected record.
|
||||
|
||||
Expose an application configuration controlling the amount of padding, including a disabled option. Section 6 now supplies configurable starting values selected under the user's delegation; Q2 records their exact wire encoding. Keep total padded sizes within configured frame/allocation limits and the UDP datagram budget. Consider handshake records as well as application records when specifying coverage.
|
||||
|
||||
“Packet” here means a protocol record/message we construct, not an individual TCP segment: the operating system may split or combine writes. Padding is useful over WebSocket too: it adds uncertainty to visible message sizes even though HTTP/WebSocket presence remains recognizable. It does not hide timing, endpoints or total traffic, and repeated observations may still reveal statistical size patterns. No cover traffic or timing shaping is implied by this decision.
|
||||
|
||||
### Q10 — UDP handshake and resource handling — RESOLVED AT DESIGN LEVEL
|
||||
|
||||
Use bounded attempts and state. A new attempt always generates fresh ephemeral material and HELO, as already agreed. No automatic application-RPC retry is introduced: repeating an operation after an uncertain result is application policy.
|
||||
|
||||
- Lost HELO/server hello: expire the attempt and retry with a new DH pair. The recent-DH guard continues silently ignoring repeated HELOs.
|
||||
- Lost client ACK or server acceptance: permit bounded retransmission of the exact protected ACK within the same attempt. Retain its authenticated identity and the exact acceptance ciphertext briefly. An identical authenticated ACK may trigger retransmission of that cached acceptance, without rerunning identity hooks, authorization, connected callbacks or application commands. Compare the exact authenticated flight/session association; do not treat arbitrary replayed data as a handshake retry. Normal data replay filtering remains unchanged.
|
||||
- Do not autonomously flood acceptance messages. Resend only in response to the matching ACK and within the retry/rate budget.
|
||||
- Bind incomplete UDP attempts to the receiving listener and source address/port. No transparent migration/NAT-rebinding mechanism in the initial version; reconnect with fresh keys. Do not allow unverified packets to replace an established session for that address. The detailed adapter/session-dispatch implementation must preserve this rule.
|
||||
- Before return-path confirmation, bound outgoing bytes to at most three times bytes received for the attempt; there is only one server-hello response to a fresh HELO. Size/pad the legitimate HELO sufficiently for the supported response within the datagram budget. Successful decryption alone is not return-path validation: a sender knowing the public key can spoof an address. The protected ACK demonstrates receipt of the fresh server flight.
|
||||
- Apply bounded admission before expensive public-key work. Use a finite worker pool without an unbounded waiting queue, per-source/global rate budgets and pending-state limits. Over-limit unauthenticated traffic is silently dropped. These controls limit resource use, not malicious access by someone able to create fresh valid HELOs.
|
||||
- Initial UDP mode has one complete protected record per datagram, no new fragmentation/reassembly subsystem. Oversized messages fail locally with a clear size error; use an explicitly configured stream carrier for larger objects. Padding shrinks to the available budget before rejecting a message whose unpadded record still cannot fit. Do not silently downgrade protection.
|
||||
|
||||
Defaults and tunable bounds appear in section 6. UDP size guidance is informed by [RFC 8085](https://www.rfc-editor.org/rfc/rfc8085.html), which recommends avoiding fragmentation and congestion-unresponsive traffic. The selected 1200-byte default is our conservative starting budget, not a universal path-MTU guarantee or a claim that pacing alone supplies full congestion control. Retain existing transport controls; sustained Internet UDP throughput requires an appropriate congestion-control strategy and validation.
|
||||
|
||||
### Q11 — Closure and truncation — RESOLVED AT DESIGN LEVEL
|
||||
|
||||
Provide an authenticated internal CLOSE record carrying a bounded reason code. A locally requested graceful close stops new admission, drains already admitted writes up to a configurable deadline, then emits CLOSE and closes the carrier. If the deadline expires or writing fails, abort and release queued/pending operations with failure. No half-close API is introduced initially.
|
||||
|
||||
For TCP, a valid CLOSE at a record boundary signals clean channel termination. EOF without it is an abrupt/truncated connection; a partial record or bad MAC is a failure. None of these imply that pending RPCs succeeded. CLOSE can use a barrier after the queued records rather than bypass them through priority scheduling.
|
||||
|
||||
For UDP, CLOSE is best-effort, sent once; loss is possible and does not justify adding a reliable teardown protocol. Its reception means the sender closed, not that all preceding datagrams arrived. Reordering must not convert pending operations into successes. Retain existing optional liveness mechanisms; no mandatory idle heartbeat or idle-session timeout is added.
|
||||
|
||||
### Q12 — Integration and rollout — RESOLVED AT DESIGN LEVEL
|
||||
|
||||
Implement an internal protected-channel boundary around complete encoded RPC blocks. It receives explicit local priority metadata and exposes authenticated plaintext blocks. Reuse RPC correlation/dispatch and application interfaces; bypass the existing L0Call encryption wrappers for the new flavor. One scheduling owner per connection; no second priority queue that can reorder already stream-encrypted bytes.
|
||||
|
||||
Use separate explicit endpoint factories/configuration for the concealed flavor. Existing endpoints, public API behavior and the light TS package remain unchanged. Configuration is passed through a single immutable options object with grouped policies; reuse existing reconnection/health settings where their semantics match. No automatic fallback to the existing protocol.
|
||||
|
||||
During KMP prototyping, consume crypto2 0.9.6-SNAPSHOT containing the DH length guard. Do not update the currently released Kiloparsec branch merely as a documentation change. A stable concealed-protocol release must pin a qualified stable crypto2 release rather than a snapshot. Investigate the recorded JS loader failure before claiming Kotlin/JS support; the separate TypeScript client is not being changed.
|
||||
|
||||
### Q13 — Kotlin Elligator — IMPLEMENTATION/VALIDATION TASK
|
||||
|
||||
Proceed with a Kotlin prototype using existing libsodium and bignum, without adding a runtime dependency. Test mapping and shared-secret compatibility against independent vectors/implementations. Verify mixed-order input behavior through the actual platform bindings. Treat timing suitability as a dataflow and threat-model question, not something resolved by arithmetic tests alone. The [timing assessment](CONCEALED-PROTOCOL-TIMING.md) records the current public-coordinate dataflow, JVM/Linux Native measurements and the proposed 50 ms roundup experiment; variable execution time is not itself evidence of private-key leakage.
|
||||
|
||||
Use fixed test vectors and a fixed-seed randomized corpus (at least 10,000 round trips in the extended suite), boundary/exceptional cases and malformed inputs. Test mask/sign/padding choices and low-order rejection. Measure timing, allocations and retained binary size on JVM and Linux Native. Statistical distinguishers detect regressions; they do not prove cryptographic indistinguishability or constant-time execution.
|
||||
|
||||
If these investigations show general bignum unsuitable, implement reviewed fixed-size Kotlin field arithmetic and validate it before production. This stays within the no-new-runtime-dependency direction. Any later decision to add a native library or weaken a security property requires a new design decision; do not silently do either.
|
||||
|
||||
|
||||
**2026-10-22 user decision: bounded HELO failure work.** Each shared server identity keeps an independent random throwaway X25519 scalar seed. Only a failed HELO envelope authentication triggers one extra decryption attempt, using a temporary seed copy with 1–8 distinct effective scalar bits flipped. Discard every dummy outcome; preserve the original authentication rejection. Do not modify the real key, send a decoy response, invoke identity hooks, recursively retry or admit the peer. Erase the temporary key copy after use. Keep the attempt within existing crypto admission bounds. `ConcealedOptions.Handshake.decoyOnHelloAuthenticationFailure` defaults to `true` and can disable this local policy. No wire format change, clock-padding policy or random division loop is introduced. The [timing assessment, section 7](CONCEALED-PROTOCOL-TIMING.md#7-requested-server-side-decoy-decryption) records tests and measured cost. This is bounded failure-path work, without a claim that key mutation defeats every timing or microarchitectural attack.
|
||||
|
||||
|
||||
### Q14 — Qualification — IMPLEMENTATION/VALIDATION TASK
|
||||
|
||||
Keep the new flavor experimental until the wire specification and V1–V7 checks pass. First qualify JVM and Linux Native; compile other configured KMP targets and label runtime support according to evidence. Do not advertise browser/mobile/Apple runtime qualification based only on compilation. Leave the current protocol available throughout rollout.
|
||||
|
||||
Use deterministic wire vectors across implementations, negative handshake/MAC tests, bounded-input fuzzing, real loopback UDP impairment, TCP segmentation/coalescing, concurrent duplex and fairness tests. Extended CI starts with 10,000 deterministic malformed-input cases and a 60-second bounded duplex/priority run; soak durations and seeds are test-harness settings, not wire parameters. Expand these workloads based on findings rather than treating their counts as a security proof.
|
||||
|
||||
Before calling this a production concealed protocol, review the full handshake and custom stream-plus-MAC construction independently of the implementation, including the KDF from concealed clientDH, identity/session binding, rejection behavior, resource limits, truncation and forward-secrecy claims. This review is work still to do, not a completed audit. No further user design question is needed to begin the specification/prototype.
|
||||
|
||||
## 6. Configurable implementation defaults
|
||||
|
||||
These are assistant-selected starting defaults under the user's instruction to settle routine choices. They are not protocol standards or measured optimal values. Keep them in one immutable `ConcealedConnectionOptions` with grouped `handshake`, `limits`, `padding`, `queue`, `udp` and `close` policies (names provisional). Validate ranges at construction and expose application overrides. Do not change existing-flavor defaults.
|
||||
|
||||
| Setting | Initial default | Semantics |
|
||||
| --- | --- | --- |
|
||||
| Handshake attempt deadline | 15 seconds | Whole attempt, including hooks; override for interactive/external authorization. |
|
||||
| Maximum connection attempts | 3 | Includes first attempt; finite by default, fresh DH/HELO each time. |
|
||||
| Delay between fresh attempts | 700 ms, exponential backoff capped at 5 s, ±20% jitter | Align base delay with existing reconnection configuration; cancellation interrupts waits. |
|
||||
| UDP ACK retry interval | 1 s, doubling to 4 s, ±20% jitter | Only after a valid server hello; at most 3 retransmissions and never past attempt deadline. |
|
||||
| Incomplete server state lifetime | 15 seconds | Starts at admission, not reset indefinitely by duplicate traffic. |
|
||||
| Cached acceptance lifetime | 30 seconds | Expire cryptographic retry state independently of established session lifetime. Bound cache entries. |
|
||||
| Recent-DH guard | 4096 entries | Optional; 0 disables. Ring eviction, no persistence requirement. Operational only. |
|
||||
| Pending handshakes | 1024 global / 8 per source IP | Per-listener starting budgets; shared-NAT deployments may raise the per-source limit. |
|
||||
| Acceptance retry cache | One bounded entry per active server connection | Lifetime configurable; total bounded by listener carrier limits. No independent unbounded map. |
|
||||
| Bootstrap crypto concurrency | 4 per listener | Reject excess before expensive work; configurable for server CPU capacity. |
|
||||
| New-HELO admission | 100/s global, burst 200; 10/s per source IP, burst 20 | Bounded token buckets; expire inactive source entries and cap source table at 4096 entries. Reject new entries when full rather than allowing unlimited allocation. |
|
||||
| Maximum UDP datagram payload | 1200 bytes | Includes all protocol/crypto overhead and padding; configurable for known paths, obey platform/protocol absolute bounds. No claim of universal MTU safety. |
|
||||
| Maximum bootstrap frame on a stream carrier | 16 KiB | Bound first-message parsing before a session exists. UDP uses its stricter datagram limit. |
|
||||
| Maximum protected stream body | 16 MiB | Includes inner object delimitation and padding, excludes fixed outer length/tag; checked before allocation. |
|
||||
| Length encoding | Fixed unsigned 32-bit, big-endian | Encrypted 4-byte outer body length; authenticated inner object byte length before object+padding. Avoid unbounded varint parsing. |
|
||||
| Partial stream-record deadline | 30 seconds | Starts when the first byte of a new record arrives; does not time out an otherwise idle connection. Configurable for slow links/large records. |
|
||||
| Outgoing queue | 128 items / 32 MiB | Reuse current writer defaults; enforce byte budget including protection/padding cost. |
|
||||
| Maximum priority overtaking | 2730 admission positions | Reuse OutQueue behavior; all regular entries numbered. |
|
||||
| UDP replay window | 4096 sequence positions | Existing baseline; local overtaking <= floor(2*window/3). |
|
||||
| Random padding | Uniform 0–255 bytes per record | Fresh random length and bytes, protected with body. Explicit disabled option; effective maximum capped by carrier/record/queue budget. Handshake minimum padding for response budget is separate. |
|
||||
| Graceful-close drain deadline | 2 seconds | Abort if drain/close cannot finish; no false success for unanswered RPCs. |
|
||||
| Heartbeat | Disabled unless application enables existing health policy | Do not add idle polling as part of concealment. |
|
||||
|
||||
Padding layout for stream records becomes `encrypted(outerBodyLength | innerObjectLength | objectBytes | padding | tag)`, with the tag covering the implicit sequence, exact outer length and entire body. Verify before interpreting inner length or decoding the object. Outer length is fixed-width but still untrusted until authentication; cap the read and deadline. The same protected inner object-length/body convention can be used in independent UDP records, alongside their encrypted sequence number. Specify exact control/message types in Q2.
|
||||
|
||||
Local receive limits and any peer-advertised limits must be reconciled in the authenticated handshake (use the smaller applicable bound); never allocate from a peer claim beyond the local cap. The bootstrap starts with bounded endpoint-configured limits before negotiation. Do not silently change wire encodings or crypto algorithms through ordinary tuning parameters.
|
||||
|
||||
The 16-byte MAC tag, 24-byte UDP IV, key separation, identity verification, DH-error rejection, authenticated-before-dispatch rule and counter non-wrap are fixed security properties, not options to disable. For the first wire version, keep the replay window at 4096 and validate configurable overtaking against it; changing the window requires explicit authenticated negotiation/specification support. Specify cipher usage ceilings/rekeying in Q2; fail closed before limits and reconnect with fresh keys rather than reset counters under an old key.
|
||||
|
||||
## 7. Proposed validation sequence
|
||||
|
||||
1. **V1 — Elligator compatibility:** reference vectors; encode/decode; sign/top-bit choices; exceptional inputs; randomized subgroup coverage; agreement between Kotlin/libsodium and an independent implementation.
|
||||
2. **V2 — Bootstrap/handshake:** mutation of every authenticated field, replayed HELO/ACK, substituted DH, wrong server identity, rejected client authorization, transcript/role confusion, all-zero DH rejection, safe failure behavior.
|
||||
3. **V3 — TCP:** arbitrary segmentation/coalescing and partial cipher-mask blocks; authenticated length mutation; truncation; MAC changes; deadlines; size limits; no object deserialization/dispatch before verification; cancellation mid-write.
|
||||
4. **V4 — UDP:** duplicate, stale, reordered, lost and replayed records; independent decryption; bounded handshake retransmission/cache behavior; nonce/sequence exhaustion handling.
|
||||
5. **V5 — RPC/lifecycle:** duplex calls, responses, errors, pushes, initialization RPCs, authorization rejection, connection notification and close semantics.
|
||||
6. **V6 — Scheduling/performance:** sustained priorities with low-priority progress, large-message latency, bounded queues, concurrent producers, allocation/cipher/MAC cost and actual artifact-size delta.
|
||||
7. **V7 — Platform/security review:** JVM and Linux Native first, then the explicitly supported KMP targets; reference implementation outside production dependencies; randomness distinguishers as regression tests, not proofs; review the custom stream-plus-MAC composition before release.
|
||||
|
||||
## 8. Sources consulted
|
||||
|
||||
- [Elligator paper](https://elligator.cr.yp.to/elligator-20130828.pdf)
|
||||
- [Elligator key-exchange construction](https://elligator.org/key-exchange)
|
||||
- [Elligator direct/inverse maps](https://elligator.org/map)
|
||||
- [Monocypher Elligator manual](https://monocypher.org/manual/elligator) — reference, not chosen runtime dependency.
|
||||
- [Monocypher security disclosures](https://monocypher.org/quality-assurance/disclosures) — use a reference version including applicable timing fixes.
|
||||
- [Libsodium point arithmetic](https://libsodium.gitbook.io/doc/advanced/point-arithmetic)
|
||||
- [Libsodium sealed boxes](https://doc.libsodium.org/public-key_cryptography/sealed_boxes)
|
||||
- [Libsodium key exchange](https://doc.libsodium.org/key_exchange)
|
||||
- [Libsodium stream ciphers](https://doc.libsodium.org/advanced/stream_ciphers)
|
||||
- [Libsodium secretstream](https://doc.libsodium.org/secret-key_cryptography/secretstream) — investigated alternative; still requires ciphertext chunk boundaries, not selected for the current TCP plan.
|
||||
- [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748.html) — X25519 encoding and DH result checking.
|
||||
- [RFC 8017](https://www.rfc-editor.org/rfc/rfc8017.html) — RSA ciphertext range.
|
||||
- [Ionspin KMP bignum](https://github.com/ionspin/kotlin-multiplatform-bignum)
|
||||
- [RFC 8085: UDP usage guidelines](https://www.rfc-editor.org/rfc/rfc8085.html) — datagram sizing, reliability and congestion considerations.
|
||||
|
||||
An experimental source implementation and wire draft now exist; see the implementation record for completed validation and remaining qualification. The design below preserves the decisions and rationale, not a production-security claim. Q1/Q3–Q12 are resolved at design level. Q2 is the wire-specification task; Q13/Q14 and V1–V7 are implementation/validation work, not completed security claims. The concrete specification, prototype and expanded validation now exist; the [qualification record](CONCEALED-PROTOCOL-QUALIFICATION.md) is the current continuation point. Retain the stable identifiers.
|
||||
@@ -1,147 +0,0 @@
|
||||
# Experimental concealed protocol: implementation and continuation
|
||||
|
||||
The design is recorded in [CONCEALED-PROTOCOL-DESIGN.md](CONCEALED-PROTOCOL-DESIGN.md); exact byte layouts and cryptographic inputs are in [CONCEALED-PROTOCOL-WIRE.md](CONCEALED-PROTOCOL-WIRE.md). This implementation is experimental. The custom handshake/stream composition has not received an external cryptographic audit. Elligator uses variable-time bignum arithmetic on public coordinates in the current call graph; the [timing assessment](CONCEALED-PROTOCOL-TIMING.md) narrows the original warning and records measured variation without claiming a demonstrated private-key leak.
|
||||
|
||||
## Implemented components
|
||||
|
||||
- Common Kotlin Elligator 2 mapping and full-curve key generation, using the existing bignum/libsodium dependencies. Includes published reference vectors, subgroup and representation tests.
|
||||
- Four-flight handshake: concealed HELO, protected signed server reply, session-encrypted client identity proof, session-encrypted acceptance. Exact transmitted representatives, both DH keys, options and identities are bound through the transcript. A fresh random 24-byte IV protects each bootstrap/finished envelope.
|
||||
- Independently decryptable UDP records with random 24-byte IVs, encrypted sequence counters, authenticated padding and the existing replay bitmap.
|
||||
- Continuous TCP ChaCha20 stream with preserved partial-block state, hidden bounded lengths and keyed BLAKE2b-128 record authentication. The existing binding does not expose portable XChaCha20 stream operations, so a derived 8-byte nonce is used with the original ChaCha20 64-bit block-counter API. This does not change UDP's 24-byte IVs.
|
||||
- Complete-block integration with the existing Transport RPC engine. Ciphertext never passes through the legacy L0Call wrapper. One priority scheduler owns admission and sending. TCP authenticates/encrypts after queue selection; UDP protects before admission. Both reuse OutQueue's bounded-overtaking algorithm.
|
||||
- Deferred readiness, application credential policy through the existing non-suspending KiloInterface hooks, client/server scope identities and tokens, duplex RPC, pushes, exception mapping, protected closure, finite client establishment retries and no automatic retry of application operations.
|
||||
- Raw TCP, UDP and binary WebSocket carriers, plus bounded listener admission on sockets. The old TCP/UDP envelopes and keepalive bytes are absent from the new socket adapters.
|
||||
|
||||
The exact footprint and completed validation are recorded below. No publication or deployment is implied by these source changes.
|
||||
|
||||
## Using the experimental API
|
||||
|
||||
Initialize crypto as usual. The server's encryption key and identity key are separate and supplied by the application. Share one `ConcealedServerIdentity` between accepted connections to share its recent-DH guard and cryptographic concurrency budget. Key provisioning and rotation remain application policy.
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalConcealedProtocol::class)
|
||||
suspend fun serve(
|
||||
api: KiloInterface<Unit>,
|
||||
encryptionKey: DecryptingSecretKey,
|
||||
signingKey: SigningKey,
|
||||
) = coroutineScope {
|
||||
val identity = ConcealedServerIdentity(encryptionKey, signingKey)
|
||||
val listener = ConcealedTcpListener(port = 7443)
|
||||
try {
|
||||
listener.carriers.collect { carrier ->
|
||||
launch {
|
||||
try {
|
||||
ConcealedConnection.server(api, carrier, Unit, identity).run()
|
||||
} catch (_: CancellationException) {
|
||||
// Parent shutdown.
|
||||
} catch (_: Exception) {
|
||||
// Local logging/metrics policy; do not reply in plaintext.
|
||||
} finally { carrier.close() }
|
||||
}
|
||||
}
|
||||
} finally { listener.close() }
|
||||
}
|
||||
```
|
||||
|
||||
Client establishment retries use a new carrier and fresh ephemeral material on each attempt:
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalConcealedProtocol::class)
|
||||
suspend fun connectExample(
|
||||
api: KiloInterface<Unit>,
|
||||
serverEncryption: EncryptingPublicKey,
|
||||
trustedServerIdentity: VerifyingPublicKey,
|
||||
clientIdentity: SigningKey,
|
||||
) = coroutineScope {
|
||||
val options = ConcealedOptions(
|
||||
padding = ConcealedOptions.Padding(maxBytes = 255),
|
||||
handshake = ConcealedOptions.Handshake(maxAttempts = 3),
|
||||
)
|
||||
val client = ConcealedClient(api, Unit, serverEncryption,
|
||||
trustedServerIdentity, clientIdentity, options) {
|
||||
connectConcealedTcpCarrier(NetworkAddress("server.example", 7443))
|
||||
}
|
||||
val runner = launch { client.run() }
|
||||
try {
|
||||
client.awaitReady()
|
||||
// client.call(command, args), client.push(command, args)
|
||||
} finally {
|
||||
client.close()
|
||||
runner.cancelAndJoin()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ConcealedConnection.client(...)` is the lower-level single-attempt form. A `ConcealedConnection`/`ConcealedClient` instance is run only once. `ConcealedClient` retries establishment failures within its finite policy, stops on authenticated signature/policy failures, and does not reconnect/reissue an RPC after an established connection fails. The caller decides how business operations recover from an uncertain result.
|
||||
|
||||
Raw TCP/UDP APIs are available on the source sets configured for Ktor sockets (including JVM, Android, Linux and Apple targets). Common JS/Wasm code can use supported WebSocket or custom carriers; compilation alone does not establish browser runtime qualification.
|
||||
|
||||
For UDP use `ConcealedUdpListener` / `connectConcealedUdpCarrier`. One protected message fits one datagram. The default complete-datagram budget is 1200 bytes; oversize objects fail locally. This implementation does not introduce fragmentation or reliable delivery for ordinary RPCs.
|
||||
|
||||
For WebSocket use `connectConcealedWebSocketCarrier(url)` or a caller-owned Ktor WebSocket session's `asConcealedCarrier()`. The server selects the explicit route and installs Ktor WebSockets. Protocol bytes use binary frames; frame boundaries are just stream chunk boundaries. Caller-owned HTTP clients remain caller-owned. Padding obscures message-size relationships, while the HTTP/WebSocket handshake remains visible.
|
||||
|
||||
Existing `onConnected` hooks are ordinary, non-suspending functions. They may reject credentials or launch work that awaits RPC readiness. They must not block the event loop waiting for an RPC on the same not-yet-ready connection. No new suspending authorization callback or special pre-establishment RPC capability is introduced.
|
||||
|
||||
## Configuration and fixed properties
|
||||
|
||||
`ConcealedOptions` groups handshake, framing/padding, queue, UDP and closure limits. Socket listeners expose their own carrier-count, source-table and new-connection rate budgets; `ConcealedServerIdentity` exposes recent-DH capacity and crypto concurrency. Keep them associated with a listener, not rebuilt per packet.
|
||||
|
||||
Receive body size, datagram budget and padding ceilings are advertised inside authentication and reconciled with local bounds. Queue limits count admitted encoded data; stream protection adds bounded padding and authentication overhead when the selected record is written. At most one record is encrypted/written outside the queue at a time. A large record already writing is not preemptible.
|
||||
|
||||
Server HELO authentication failures now perform one discarded decryption with a mutated copy of a separate random throwaway key. This is enabled by default through `Handshake.decoyOnHelloAuthenticationFailure`; it remains inside the existing crypto admission limit, cannot authenticate a peer or send a response, and does no extra work for successful HELOs. It roughly doubled failed-HELO CPU time in the measured JVM run; see the [timing assessment](CONCEALED-PROTOCOL-TIMING.md#7-requested-server-side-decoy-decryption) for exact behavior, tests and limits.
|
||||
|
||||
The 16-byte authentication tag, identity checks, DH rejection, role separation, verified-before-decoding rule and fail-before-counter-wrap behavior are not disable switches. Per-direction limits additionally cap records at 2^32 and TCP encrypted bytes at 1 TiB; usage exhaustion requires a new session, not counter reset under an old key.
|
||||
|
||||
Low-level record state is externally serialized by the connection. Do not share a cipher/handshake instance between connections or restart it with reused keys/initialization material. Close/cancel the old run before abandoning its carrier.
|
||||
|
||||
## Build and dependency details
|
||||
|
||||
- The prototype consumes `crypto2:0.9.6-SNAPSHOT`, which contains the peer-DH length guard. This snapshot was compiled with Kotlin 2.4, so the project and its included interoperability fixtures now use Kotlin 2.4.0 as well.
|
||||
- The existing `multiplatform-crypto-libsodium-bindings:0.9.6` backend is resolved from the YoungBlood Maven repository; keep that repository alongside SergeychWorks, Maven Central and Universa as shown in the main README. A standalone private-Maven-only consumer exposed the missing repository in its initial fixture; adding the existing backend repository resolved it.
|
||||
- Direct use of the existing libsodium backend is explicit in the build. No additional crypto runtime library such as Monocypher was added.
|
||||
- The existing libsodium binding eagerly captures `_sodium_init`. In JS runtime 0.7.16 that export appears only after readiness, so the build pins transitive `libsodium-sumo` to compatible 0.7.15 for Kotlin JS/Wasm. This is isolated from the separate lightweight TypeScript package. A proper initialization fix exists in the separate bindings repository and can be qualified later.
|
||||
- A stable release must use a qualified stable crypto2 dependency and complete the qualification tasks. The first concealed distribution is 0.9.0-SNAPSHOT; see the [qualification record](CONCEALED-PROTOCOL-QUALIFICATION.md) for publication status and exact checks.
|
||||
|
||||
## Remaining qualification
|
||||
|
||||
Complete the independent protocol review, review the documented Elligator timing/dataflow assessment across supported targets, extend malformed-input fuzzing and sustained-load tests, and qualify platform runtimes according to actual evidence. Deterministic tests and randomness checks are valuable regression evidence, not proofs of cryptographic security.
|
||||
|
||||
The first implementation has no automatic key rotation, shared-port flavor detection, UDP fragmentation, address migration, mandatory heartbeat, cover traffic or timing shaping. These decisions are recorded in the design and are not silently supplied by the encryption layer.
|
||||
|
||||
## Verified run: 2026-10-22
|
||||
|
||||
- Full JVM suite: **109 passed**, including existing protocol regressions, raw TCP/UDP loopback, and real Netty WebSocket tests.
|
||||
- Full Kotlin/JS Node suite: **82 passed**, with the compatible libsodium runtime resolution.
|
||||
- Full Linux x64 Native suite: **85 passed**.
|
||||
- Compilation passed for Android release, Wasm, Linux arm64, Windows x64, macOS arm64/x64, and iOS arm64/x64/simulator arm64. These compile checks do not qualify device runtimes or final Apple application linking.
|
||||
- TypeScript unit suite: **38 passed**, one source-server interoperability test intentionally skipped by its ordinary unit command; the separate live source-JVM interoperability command passed pinned identity, duplex RPC, pushes, errors, parallel calls and reconnection.
|
||||
- Tests cover independent Elligator/KDF/MAC vectors, all eight torsion components, alias encodings, signatures/transcripts, bad DH inputs, duplicate/stale records, lost UDP handshake flights, unauthenticated noise, negotiated limits, deadline trickling, deferred initialization, cancellation, finite retries, uncertain-operation behavior and explicit key cleanup with rejection of destroyed record state.
|
||||
- Native testing exposed an existing empty-response hazard: the generic `utools.unpack` helper maps empty bytes to generic null. The transport's encrypted-response and complete-block network boundaries now use strict BiPack decoding, avoiding null-backed unsigned arrays. The legacy forged-ACK regression passes on all three tested runtimes.
|
||||
- The unshrunk JVM JAR is 828,075 bytes versus the published 0.8.0 baseline's 537,671 bytes: **+290,404 bytes (about 284 KiB)**. This includes the prototype, raw carriers, compiler/dependency metadata changes and coroutine classes. It is not an APK, native executable or tree-shaken browser size measurement. No additional crypto runtime library was added.
|
||||
|
||||
Validation command (using JDK 21 and configured SDK/toolchains):
|
||||
|
||||
```sh
|
||||
./gradlew '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' \
|
||||
jvmTest jsNodeTest linuxX64Test compileKotlinWasmJs compileReleaseKotlinAndroid \
|
||||
compileKotlinLinuxArm64 compileKotlinMingwX64 compileKotlinMacosArm64 \
|
||||
compileKotlinMacosX64 compileKotlinIosArm64 compileKotlinIosX64 \
|
||||
compileKotlinIosSimulatorArm64 jvmJar
|
||||
```
|
||||
|
||||
The combined command completed successfully after the final lifecycle cleanup. A legacy WebSocket test was changed to await the actual disconnected state with a real-time deadline instead of using a virtual delay to wait for socket IO. New WebSocket test fixtures own their server coroutine scopes, observe peer closure, and have bounded coroutine/JUnit deadlines. An earlier failed run exposed that fixture cleanup issue; it was corrected before the successful run. Existing JS/Wasm lockfile policy may require `kotlinUpgradeYarnLock` when changing the Kotlin toolchain; the libsodium runtime is explicitly pinned by the build.
|
||||
|
||||
Remaining work before a production release: independent full-protocol review, target-specific Elligator timing/dataflow qualification and any hardening justified by those findings, extended fuzz/soak workloads and actual browser/mobile/Apple runtime tests. This paragraph records the initial implementation checkpoint; current source/distribution status is in the [snapshot qualification record](CONCEALED-PROTOCOL-QUALIFICATION.md#7-distribution-verification).
|
||||
|
||||
|
||||
## Subsequent timing and failure-path validation: 2026-10-22
|
||||
|
||||
The [timing report](CONCEALED-PROTOCOL-TIMING.md) records opt-in JVM/Linux Native sampling, actual handshake-stage measurements, and the exact 50 ms grid plus whole-millisecond jitter experiment. The original timing warning was narrowed: the current bignum call graph operates on public coordinates, not the server's private scalar.
|
||||
|
||||
After adding the requested bounded decoy behavior, focused `HelloFailureNoiseTest` and `ConcealedConnectionTest` runs passed **11 tests each on JVM, Kotlin/JS and Linux Native**. Android release and Wasm compilation passed. A separate actual-path JVM timing probe passed with 64 samples per class. These are subsequent focused checks, separate from the earlier full-suite counts and JAR size recorded above. No artificial delay or random division loop was added.
|
||||
|
||||
|
||||
The subsequent 0.9.0-SNAPSHOT release qualification passed 127 JVM tests (including extended campaigns), 90 Kotlin/JS tests and 94 Linux Native tests. [The qualification record](CONCEALED-PROTOCOL-QUALIFICATION.md) includes independent composition vectors, real socket payload captures, optimized Native performance, allocation data and publication verification.
|
||||
|
||||
|
||||
Published 0.9.0-SNAPSHOT to Maven Local and private Maven; all 13 publications / 58 downloaded files passed SHA-256 verification. A standalone application consuming only private-Maven dependencies passed actual TCP duplex RPC. API documentation was deployed and verified. See [distribution verification](CONCEALED-PROTOCOL-QUALIFICATION.md#7-distribution-verification) for precise evidence and repository requirements.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Concealed protocol: 0.9.0-SNAPSHOT qualification
|
||||
|
||||
2026-10-22. This is the continuation point for the first snapshot release. It records completed checks and their limits, separate from the historical [design](CONCEALED-PROTOCOL-DESIGN.md), [wire specification](CONCEALED-PROTOCOL-WIRE.md), [API guide](CONCEALED-PROTOCOL-IMPLEMENTATION.md) and earlier [timing assessment](CONCEALED-PROTOCOL-TIMING.md).
|
||||
|
||||
## 1. Release decision
|
||||
|
||||
Publish **0.9.0-SNAPSHOT**, with the concealed flavor opt-in on separate endpoints. Existing protocol 2 remains available; the lightweight TypeScript client stays at 0.8.0. The project now requires Kotlin 2.4.0 and consumes crypto2 0.9.6-SNAPSHOT. This is the first distributed concealed implementation, not a frozen stable protocol or a universal runtime qualification.
|
||||
|
||||
No new crypto runtime dependency was added. The explicit libsodium binding is already the crypto2 backend; the independent Python reference is a development tool only.
|
||||
|
||||
## 2. Independent composition verification
|
||||
|
||||
The [reference and fixture guide](CONCEALED-PROTOCOL-VECTORS.md) records 52 deterministic fixture fields and their SHA-256. Python independently implements the protocol labels, lengths, hashes, ordered KX derivation, signatures, F1–F4 envelopes, stream cursor and UDP encoding. Its continuous ChaCha20 implementation is pure Python; trusted curve/box/signature primitives use system libsodium.
|
||||
|
||||
Three common tests passed on JVM, Kotlin/JS and Linux Native. They compare exact production component output with the reference, exercise partial cipher-mask boundaries at offsets 27/121/148, decode reordered UDP, and pass independent F1 through the real server state machine. Fixed F2–F4 comparisons exercise components; complete client/server state machines are additionally covered by randomized integration tests. No production entropy override was added.
|
||||
|
||||
Source review checked exact HELO binding, the signed server core, role-assigned session keys, client signature/token binding, h3/traffic-key association and authentication-before-replay-state updates. It found no new composition defect. This is source review and executable cross-checking, not an external audit of the primitives or protocol.
|
||||
|
||||
## 3. Adversarial and sustained-load tests
|
||||
|
||||
`ExtendedConcealedQualificationTest` is enabled with `KILOPARSEC_EXTENDED_TESTS=1` and passed all four campaigns:
|
||||
|
||||
1. **10,000 malformed inputs**, seed `0x237d404`: 2,500 random unauthenticated datagrams, 2,500 authenticated inconsistent inner lengths, 2,500 modified stream records and 2,500 truncated stream records. No unauthenticated object was delivered; invalid datagrams did not consume replay state; stream failures were terminal. Inputs and allocation sizes were bounded.
|
||||
2. **12,800 UDP records**, seed `0x51a7`: 11,636 delivered, 1,164 intentionally dropped, 2,327 duplicate drops, plus tag forgery before each delivered packet and a stale replay after window eviction. Shuffling retained at most 128 packets at a time.
|
||||
3. **Admission campaign:** 10,000 attempts respected a global burst of 200; refill admitted 100 in the next simulated second; a 64-entry source table rejected rotating new sources until idle expiry. This targets the actual admission implementation directly, not 10,000 expensive full HELO decryption attempts.
|
||||
4. **60-second duplex priority run:** eight RPC workers and a low-priority push worker over 16-capacity channels. Low-priority calls and pushes made progress in every 10-second interval. Explicit item and byte queue saturation rejected excess admission, and shutdown completed within its deadline. Results are workload-specific, not a general throughput guarantee.
|
||||
|
||||
## 4. Real socket payload capture
|
||||
|
||||
`ConcealedTrafficCaptureTest` passed eight fresh TCP and eight fresh UDP sessions through forwarding proxies, with five duplex echo calls per session. It captured actual loopback socket payloads without privileged packet access. All 16 first-flight representative prefixes differed. Neither the repeated synthetic RPC plaintext marker nor either identity public key nor the static server encryption public key appeared in captured directional payload streams.
|
||||
|
||||
Local artifacts are under `build/concealed-qualification/traffic/`: binary chunks, `capture.csv` with monotonic timestamps/sizes/directions, and `summary.txt`. TCP boundaries here are proxy read chunks; UDP boundaries are complete datagrams. This is not an IP-level packet capture or a traffic-classifier evaluation. Endpoints, timing, volume and statistical length/flight patterns remain observable. WebSocket upgrade visibility remains documented and its real Netty regression tests passed, but it was not included in this payload-capture campaign.
|
||||
|
||||
The first capture fixture incorrectly expected a locally cancelled socket read to leave an empty terminal diagnostic after deliberate shutdown. The assertion now checks connection health before shutdown, and all subsequent captures and the full suite passed. No protocol code was changed to obtain that result.
|
||||
|
||||
## 5. Platform results
|
||||
|
||||
The final combined command passed:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Full JVM suite, extended tests enabled | 127 passed |
|
||||
| Full Kotlin/JS Node suite | 90 passed |
|
||||
| Full Linux x64 Native suite | 94 passed |
|
||||
| Android release and Wasm | Compilation passed |
|
||||
| Linux arm64, Windows x64 | Compilation passed |
|
||||
| macOS arm64/x64, iOS arm64/x64/simulator arm64 | Compilation passed |
|
||||
| Dokka HTML | Generated successfully |
|
||||
| TypeScript unit suite | 38 passed, one ordinary-command interop skip |
|
||||
| Live TypeScript/JVM interoperability | Passed pinned identity, duplex RPC, pushes, errors, parallel calls and reconnect |
|
||||
|
||||
Compilation is not browser/device runtime qualification or final Apple application linking. Existing unrelated compiler/deprecation and Dokka link warnings remain non-fatal.
|
||||
|
||||
```sh
|
||||
KILOPARSEC_EXTENDED_TESTS=1 ./gradlew \
|
||||
'-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' \
|
||||
jvmTest jsNodeTest linuxX64Test compileKotlinWasmJs compileReleaseKotlinAndroid \
|
||||
compileKotlinLinuxArm64 compileKotlinMingwX64 compileKotlinMacosArm64 \
|
||||
compileKotlinMacosX64 compileKotlinIosArm64 compileKotlinIosX64 \
|
||||
compileKotlinIosSimulatorArm64 dokkaGeneratePublicationHtml --console=plain
|
||||
```
|
||||
|
||||
## 6. Optimized Native measurements
|
||||
|
||||
The optimized Linux x64 test binary was linked using `NativeBuildType.RELEASE`, through an explicit optional Gradle test run. The timing report identifies it as release and is stored separately from the debug run (`build/concealed-timing/linuxX64-release.csv`). Sampling used 256 measurements per class after 64 warmup rounds on the same Intel Core Ultra 9 host, with no simultaneous load test or other benchmark from this task. CPU frequency scaling and unrelated workstation activity were not disabled.
|
||||
|
||||
| Operation | Native release median | p99 | Maximum |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Decode random public representative | 6.741 ms | 8.765 ms | 8.986 ms |
|
||||
| Encode representable coordinate | 12.736 ms | 14.772 ms | 15.187 ms |
|
||||
| Reject unrepresentable coordinate | 5.954 ms | 7.382 ms | 8.052 ms |
|
||||
| Generate fresh Elligator key pair | 14.603 ms | 57.171 ms | 59.791 ms |
|
||||
|
||||
The corresponding earlier debug generation median/p99 were 154.524/537.195 ms. The optimized result is about 10.6 times faster at the median; neither run sets a universal deadline. Inverse-map rejection counts still produce expected generation tails. This is a Linux x64 measurement, not an Apple/Android performance claim.
|
||||
|
||||
```sh
|
||||
KILOPARSEC_TIMING_PROBE=1 KILOPARSEC_TIMING_FLAVOR=release \
|
||||
KILOPARSEC_TIMING_SAMPLES=256 KILOPARSEC_TIMING_WARMUP=64 \
|
||||
./gradlew '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' \
|
||||
-PconcealedNativeBenchmark=true linuxX64ConcealedBenchmarkTest \
|
||||
--tests '*ElligatorTimingNativeTest*' --console=plain
|
||||
```
|
||||
|
||||
An additional HotSpot per-thread allocation probe measured 128 real generation/destroy operations after 32 warmups: **17,621,495 mean bytes**, **11,529,472 median**, **34,325,888 p95**, **74,474,504 maximum**. These are temporary managed allocations, including bignum work and varying rejection counts. They exclude other threads and native libsodium allocations, and do not measure retained heap or Native allocation counts. Profiling/optimization is worthwhile for high connection rates; no fixed-field arithmetic rewrite was introduced in this release. Raw output is `build/concealed-timing/allocations-jvm.csv`.
|
||||
|
||||
Enable this branch of the existing JVM timing probe with `KILOPARSEC_TIMING_PROBE=1 KILOPARSEC_ALLOCATION_PROBE=1 KILOPARSEC_TIMING_SAMPLES=128 KILOPARSEC_TIMING_WARMUP=32`, filtering `jvmTest --tests '*ElligatorTimingJvmTest*'`. It does not overwrite the timing sample CSV.
|
||||
|
||||
The unshrunk candidate JVM JAR is **834,461 bytes**, compared with the published 0.8.0 baseline's 537,671 bytes: **+296,790 bytes (about 290 KiB)**. This includes compiler/metadata/coroutine classes and carriers, not just crypto code. It is not a native executable/APK/browser bundle measurement. Test/reference tools are absent from runtime artifacts.
|
||||
|
||||
## 7. Distribution verification
|
||||
|
||||
Published all **13 Maven publications** to Maven Local and the private SergeychWorks repository. Every one of **58 files** (JAR/AAR/KLIB, POM, Gradle module metadata, sources, tooling JSON and Apple metadata bridge artifacts) was downloaded from private Maven and matched against the local artifact by SHA-256. The initial timestamped build is `0.9.0-20261021.223610-1`; the normal dependency coordinate is `net.sergeych:kiloparsec:0.9.0-SNAPSHOT`.
|
||||
|
||||
Canonical JVM JAR: 834,461 bytes, SHA-256:
|
||||
|
||||
```text
|
||||
dfa3e2123d614eb432b8b949e2d4eac35d2b6a51b904b61f1fcd1e8e66296575
|
||||
```
|
||||
|
||||
A standalone Kotlin/JVM application resolved **only published dependencies**, with no composite/source substitution and no Maven Local repository. It authenticated both peers, completed bidirectional RPC over actual loopback TCP, and loaded the JAR from Gradle's downloaded private-Maven cache. That JAR's SHA-256 matches the verified publication. The first consumer fixture omitted the existing YoungBlood backend repository; adding it resolved the dependency and the README example was corrected. This was a consumer repository configuration gap, not a missing published Kiloparsec artifact.
|
||||
|
||||
The API site was deployed using `bin/pubdocs` with strict existing SSH trust. Live index, concealed connection and handshake-options HTML were downloaded and matched to the generated pages by SHA-256. [Published API documentation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.concealed/index.html).
|
||||
|
||||
Local receipts: `build/concealed-qualification/publication.json`, `docs-publication.json`, `final-functional-results/`, and the standalone consumer/build logs under `/tmp`. Source publication includes this report and the deterministic fixtures; raw test captures and operational receipts remain ignored local artifacts. The source release is committed/pushed separately from Maven artifact creation; Git revision records the exact implementation and version declaration.
|
||||
|
||||
## 8. Remaining scope
|
||||
|
||||
The current evidence supports a controlled SNAPSHOT trial. Remaining work includes real browser/mobile/Apple runtimes, Internet-path deployment behavior and prolonged application-specific load, broader state-machine cross-implementation coverage, and independent review where required for production assurance. Timing variation in public-coordinate mapping is not a demonstrated server-private-key leak. The decoy decryption is supplementary bounded work; effectiveness against a particular Intel microarchitectural attack was not measured here.
|
||||
|
||||
The extended suites are opt-in so ordinary tests remain quick. Raw captures, timing samples and publication receipts are ignored build artifacts; this document and the deterministic fixtures preserve reproducible evidence in source control. No real application credentials or customer data were used.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Concealed protocol timing assessment
|
||||
|
||||
Recorded 2026-10-22. This evaluates the current Elligator call graph, measures its implementation, and tests the proposed wall-clock roundup policy. It is not a demonstration of a private-key recovery attack or a proof that no side channel exists.
|
||||
|
||||
## 1. Correcting the original concern
|
||||
|
||||
The implementation is variable-time, but the original warning did not adequately distinguish public-input variation from secret-dependent leakage.
|
||||
|
||||
- The server calls `decode()` on the representative received in HELO. All bignum inputs in this path are public wire data. The long-lived server scalar enters libsodium's DH operation after that mapping; it does not enter the bignum implementation.
|
||||
- The client uses libsodium to generate an ephemeral point, then bignum to convert/map its public coordinates. The successful Montgomery coordinate is recoverable from the transmitted representative. The low three torsion-selection bits are cleared by ordinary X25519 and do not contribute to its secret scalar.
|
||||
- The client retries with independent random candidates when the inverse mapping fails. Discarded candidates are not reused. Elligator's [key-exchange reference](https://elligator.org/key-exchange) explicitly permits this variable retry count for properly random ephemeral keys.
|
||||
- Client preparation precedes the first HELO. Measuring it remotely requires an independent connection-start reference. Server mapping affects the response path and is directly measurable in principle, but its inputs are public.
|
||||
|
||||
Consequently, these measurements must not be described as evidence that our server's private key leaks through bignum arithmetic. Current dataflow does not establish such a leak. This conclusion is based primarily on which values the arithmetic consumes, not on a low statistical score. Future changes that put secret scalars or secret-dependent hidden coordinates into this arithmetic require a new assessment.
|
||||
|
||||
## 2. Measurement method and scope
|
||||
|
||||
Host: Intel Core Ultra 9 275HX, 24 logical processors, Linux 7.1.8-legion-audio x86_64. JVM: OpenJDK 21.0.12. Kotlin 2.4.0. The original Native measurements below use the Linux x64 **debug test binary**. Subsequent optimized release results and JVM allocation profiling are recorded in the [snapshot qualification report](CONCEALED-PROTOCOL-QUALIFICATION.md#6-optimized-native-measurements).
|
||||
|
||||
The Elligator probe uses 64 warm-up rounds and 256 measured samples per class. Class order is shuffled each round with a fixed seed. The eight torsion cases share the same clamped scalar within each round; only the three low bits differ. Received representatives include random public inputs, a zero boundary case, and paired encodings differing only in ignored high bits. Inverse-map cases compare reference inputs that are representable or rejected. Generation uses fresh synthetic randomness; a separate equivalent loop records rejection counts. No key bytes are logged.
|
||||
|
||||
The JVM handshake probe performs all four actual cryptographic flights with fresh ephemeral keys and fixed synthetic identities, including the prefix parsing used by the connection. It excludes socket IO, application hooks, admission/queue waits and artificial delays. Its first sample is **not** a process-cold benchmark: earlier tests can have initialized classes. Padding is disabled to isolate cryptographic work.
|
||||
|
||||
Monotonic clocks measure elapsed time. Mean, median, p95, p99, maximum and exploratory Welch t statistics are retained with raw samples. Measurements are a single host run, without CPU isolation, a controlled thermal/frequency profile or adversarial co-resident load. GC/JIT, scheduler activity, autocorrelation and multiple comparisons prevent interpreting these numbers as a constant-time certificate.
|
||||
|
||||
## 3. Measured results
|
||||
|
||||
All times below are milliseconds; 256 measurements per listed group.
|
||||
|
||||
| Runtime | Operation | Median | p99 | Maximum |
|
||||
| --- | --- | ---: | ---: | ---: |
|
||||
| JVM | Decode random public representative | 2.790 | 4.197 | 5.872 |
|
||||
| JVM | Encode representable public coordinate | 5.262 | 8.030 | 10.574 |
|
||||
| JVM | Reject unrepresentable public coordinate | 2.503 | 3.808 | 5.925 |
|
||||
| JVM | Generate fresh Elligator key pair | 7.603 | 27.407 | 34.354 |
|
||||
| Native debug | Decode random public representative | 77.316 | 79.015 | 89.529 |
|
||||
| Native debug | Encode representable public coordinate | 147.269 | 149.589 | 181.251 |
|
||||
| Native debug | Reject unrepresentable public coordinate | 70.917 | 72.753 | 107.829 |
|
||||
| Native debug | Generate fresh Elligator key pair | 154.524 | 537.195 | 766.529 |
|
||||
| JVM | Client HELO preparation | 5.138 | 22.171 | 29.364 |
|
||||
| JVM | Server prefix authentication | 2.494 | 4.775 | 5.087 |
|
||||
| JVM | Server HELO response construction | 2.680 | 4.846 | 5.335 |
|
||||
| JVM | All four crypto flights, without IO | 11.063 | 27.966 | 35.193 |
|
||||
|
||||
The eight matched torsion classes had median special-public-key times of **0.335–0.342 ms on JVM** and **6.105–6.198 ms on Native debug**. Exploratory maximum absolute Welch t against torsion class zero was 0.99 and 2.02 respectively. Those small scores are not evidence of constant-time execution; the accepted/rejected public-input encoding branches are deliberately distinguishable (t approximately 57 on JVM and 371 on Native).
|
||||
|
||||
The separate instrumented generation loop observed **1–12 candidates on JVM** and **1–13 on Native debug** in 256 runs each. This explains expected rejection-related tails. Its largest observed Native debug generation time was 1.075 seconds; this is a separate sample set from the actual-generator row above.
|
||||
|
||||
The revised handshake probe's first-in-probe sample took **198.175 ms**. It was not a controlled process-cold experiment, and startup/JIT/initialization effects were not separately attributed. The remaining warm samples are shown in the table. Neither 50 ms nor the observed warm maximum is a guaranteed execution bound.
|
||||
|
||||
Server prefix parsing and subsequent complete-HELO construction currently perform separate public Elligator mappings. Their costs therefore both belong in the actual response path. These measurements do not include the newly added failed-authentication decoy; that cost is measured separately in section 7.
|
||||
|
||||
## 4. Exact roundup-plus-jitter experiment
|
||||
|
||||
The tested policy is the user's actual proposal, rather than simply adding noise to the current time:
|
||||
|
||||
```text
|
||||
release = ceil(completion / 50 ms) * 50 ms + J
|
||||
J is uniformly selected from {0, 1, ..., 10} whole milliseconds
|
||||
```
|
||||
|
||||
A deterministic test enumerates all 50,000 microsecond start phases and all eleven jitter choices. For 2 ms versus 3 ms computations, the mean target release still differs by exactly **1 ms**. Subtracting the same mean start time gives the same elapsed-latency difference. Jitter broadens the distributions but does not remove that mean difference under this phase model.
|
||||
|
||||
A boundary example is simpler: start at grid phase 47.5 ms. A 2 ms calculation completes at 49.5 ms and releases in 50–60 ms; a 3 ms calculation completes at 50.5 ms and releases in 100–110 ms. These supports are disjoint even after whole-millisecond jitter. Controlling or learning the relevant phase is an additional attacker capability; the example establishes a limitation of the mitigation, not an attack against our handshake.
|
||||
|
||||
An operation-start deadline selected **before** calculation, such as `start + 50 ms + J`, gives the same target distribution for both calculations while they fit the budget. Work exceeding the deadline, correlated scheduler delays, and local cache/CPU-contention observations remain outside that guarantee. Rounding the elapsed operation duration into 50 ms buckets also works only while the compared durations stay in the same bucket.
|
||||
|
||||
The coroutine experiment uses synthetic 2/3 ms CPU work, 100 samples per class per policy, randomized order and independently randomized grid phase before each sample. It uses a fixed **monotonic** grid, equivalent to a fixed wall-clock grid up to its origin for this experiment, without adjustments of the system wall clock. Jitter is an integer number of milliseconds. Cancellable delays round the remaining wait up to milliseconds and recheck the deadline; scheduler overshoot is measured rather than assumed absent. This is a scheduling experiment, separately labelled from the real Elligator measurements.
|
||||
|
||||
| Synthetic CPU work | Unshaped mean | Completion-grid mean | Start-selected deadline mean |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 2 ms | 2.006 ms | 32.465 ms | 54.527 ms |
|
||||
| 3 ms | 3.006 ms | 34.590 ms | 55.061 ms |
|
||||
|
||||
No compute-budget overruns occurred in these synthetic 2/3 ms runs. The 100-sample live comparisons after shaping had absolute Welch t near 1.00 (completion grid) and 1.18 (start deadline), compared with approximately 2574 unshaped. This illustrates how much harder a single small sample set becomes to distinguish. It does **not** invalidate the exact boundary model or establish equal distributions: sampled phases/jitter and scheduler overshoot differ between classes. The exact model, rather than the noisy observed mean gap, establishes the residual grid-boundary effect.
|
||||
|
||||
## 5. Interpretation and next decisions
|
||||
|
||||
1. **Variable execution time is confirmed.** Different public inputs and accepted/rejected inverse-map branches have different costs. That fact alone is not a private-key disclosure.
|
||||
2. **No demonstrated secret leak has been found in the current bignum dataflow.** The timing warning should be narrowed accordingly. This does not independently audit libsodium, platform bindings, the custom handshake or co-resident side channels.
|
||||
3. **Grid roundup is remote timing shaping, not full erasure.** It can make individual timings much less informative. Bucket crossings remain, and independent random extra work, including random bignum divisions, does not supply a general elimination guarantee. Research also demonstrates that concurrent response ordering can expose timing differences despite network noise: [Van Goethem et al., USENIX Security 2020](https://www.usenix.org/conference/usenixsecurity20/presentation/van-goethem).
|
||||
4. **Do not infer a universal 50 ms budget from this host.** Native debug timings, cold startup, device speed and load differ. Any production deadline policy needs configurable bounds, consistent externally observable success/failure handling, explicit overruns, cancellation and admission limits. Waiting must occur outside the cryptographic semaphore. A global release grid can itself introduce a periodic traffic pattern; this has not been tested as a network fingerprint here.
|
||||
5. **No clock-padding or random-division policy was added.** Following the subsequent user request, one bounded decoy decryption was added to the HELO authentication-failure path; section 7 records its exact behavior and cost. It is not an authentication upgrade or a general timing-concealment guarantee. An independent protocol review and the remaining fuzz/platform qualification are still required.
|
||||
|
||||
## 6. Reproduction and artifacts
|
||||
|
||||
Ordinary test runs do not execute the expensive sampling loops. Enable them explicitly:
|
||||
|
||||
```sh
|
||||
KILOPARSEC_TIMING_PROBE=1 KILOPARSEC_TIMING_SAMPLES=256 \
|
||||
KILOPARSEC_TIMING_WARMUP=64 \
|
||||
./gradlew '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' \
|
||||
jvmTest --tests '*Timing*' linuxX64Test --tests '*ElligatorTiming*' --console=plain
|
||||
```
|
||||
|
||||
`KILOPARSEC_SHAPING_SAMPLES` controls the live synthetic sample count per class/policy (default 100). Fresh randomness in actual generation, CPU state and scheduling mean that repeating the command will not reproduce identical timings. A repeated invocation with unchanged source may be considered up-to-date by Gradle; add `--rerun-tasks` when a fresh sampling run is intended. That also reruns compilation prerequisites.
|
||||
|
||||
Local ignored artifacts under `build/concealed-timing/` contain `jvm.csv`, `linuxX64.csv`, `handshake-jvm.csv`, `shaping-samples.csv`, `shaping-summary.txt` and host/run metadata. The tables above preserve the relevant summary in versionable documentation; raw samples remain local build output.
|
||||
|
||||
## 7. Requested server-side decoy decryption
|
||||
|
||||
Each `ConcealedServerIdentity` now owns a separate random 32-byte throwaway scalar seed. When the real HELO envelope authentication throws `UnauthenticatedFlight`, the wrapper copies the seed, flips 1–8 distinct effective X25519 scalar bits (positions 3 through 253), and performs **one** additional envelope-decryption attempt with that copy. The temporary copy and mutation entropy are cleared afterward. The persistent seed and the real server key are unchanged; concurrent attempts use independent copies.
|
||||
|
||||
The dummy outcome is discarded even if it succeeds. It cannot create a response, invoke identity hooks, advance real handshake state or turn a failed authentication into success. Expected dummy authentication/parsing failures preserve the original failure. Cancellation, unexpected runtime failures and fatal errors propagate. Bad lengths, low-order peer inputs and admission rejection do not trigger an envelope-authentication decoy. Client ACK/session-record failures are not part of this HELO-specific mechanism.
|
||||
|
||||
Both prefix and full-envelope failure paths run inside the existing server crypto admission limit. There is no recursive retry, added coroutine delay, random division loop or added unauthenticated response. The local server option `ConcealedOptions.Handshake.decoyOnHelloAuthenticationFailure` defaults to `true`; set it to `false` to omit the extra attempt. It is not negotiated and does not change the wire format.
|
||||
|
||||
A separate JVM probe measured 64 samples per class after 16 warmups, randomized across the same eight synthetic HELOs. It exercises the actual identity/admission wrapper and real envelope authentication, without network IO:
|
||||
|
||||
| Case | Median disabled | Median enabled | Mean added work |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Valid header | 2.488 ms | 2.481 ms | -0.034 ms |
|
||||
| Bad header authentication | 2.501 ms | 4.981 ms | 2.435 ms |
|
||||
| Bad body authentication | 2.584 ms | 5.109 ms | 2.646 ms |
|
||||
|
||||
This approximately doubles failed-HELO CPU work on the measured JVM, while successful authentication adds no dummy work. X25519 is designed to be constant-time and the repeated Elligator decode consumes the same public representative; changing the dummy scalar therefore does not itself imply broad random timing variation. No Intel microarchitectural attack reproduction was performed, and effectiveness against the user's previously observed attacks has not been established by these tests.
|
||||
|
||||
Five new unit tests cover exact invocation bounds, successful dummy-result discard, expected versus unexpected exceptions, effective-bit mutation and erasure, concurrent copy independence, both actual failure stages, and preservation of real keys and a later valid handshake. Existing connection regression tests cover UDP injected noise and readiness behavior. The five new tests plus six connection tests passed on **JVM, Kotlin/JS and Linux Native (11 each)** after the production change; Android release and Wasm compilation also passed. The actual-path JVM failure timing probe passed separately.
|
||||
|
||||
The additional opt-in probe is `HelloFailureTimingProbeTest`; output is `build/concealed-timing/helo-failure-jvm.csv`. Run with `KILOPARSEC_TIMING_PROBE=1`; `KILOPARSEC_FAILURE_TIMING_SAMPLES` controls samples per class (default 64). The earlier `*Timing*` JVM filter includes it on subsequent fresh runs.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Concealed protocol: independent composition vectors
|
||||
|
||||
2026-10-22. These are qualification fixtures for experimental suite 1, not a cryptographic audit or a claim of indistinguishability.
|
||||
|
||||
## Independent implementation
|
||||
|
||||
`tools/concealed-vectors/reference.py` generates 52 fixture fields without importing Kotlin source, executing Kotlin output, or reading the generated fixture file. All private keys, identity seeds, IVs and plaintexts in these files are deliberately public synthetic test inputs. Never use them for a connection.
|
||||
|
||||
The reference uses Python's standard-library `hashlib.blake2b` for hashes, KDFs, MACs, and the explicit `crypto_kx` derivation (`BLAKE2b-512(X25519 || clientPublic || serverPublic)`). Its original ChaCha20 implementation is plain Python with a 64-bit counter and 64-bit nonce. It uses the installed system libsodium through `ctypes` for X25519, XSalsa20-Poly1305 and Ed25519. Thus the protocol composition and stream-cursor implementation are independent, while the underlying trusted curve, box and signature primitives are intentionally shared. There are no added production dependencies or downloaded Python packages.
|
||||
|
||||
Reproduce from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tools/concealed-vectors/reference.py
|
||||
sha256sum tools/concealed-vectors/fixtures.json
|
||||
```
|
||||
|
||||
Expected JSON SHA-256:
|
||||
|
||||
```text
|
||||
7a7acacb100b028a8f78ad933b904cc97afd0f57eac9b1513c9ca6bb01ef0a27
|
||||
```
|
||||
|
||||
Regeneration also writes `IndependentWireFixtures.kt`, consumed on JVM, Kotlin/JS and Native by `IndependentWireVectorsTest`.
|
||||
|
||||
## Checks performed by the Kotlin tests
|
||||
|
||||
1. Exact F1 bytes, public representative decoding, bootstrap DH, bootstrap root and h1. A real `ServerHandshake` accepts this independently generated HELO and obtains the expected client DH public key. Changing either ignored representative top bit preserves its mathematical point but fails authentication because the exact encoding is bound into the KDF.
|
||||
2. Exact F2 bytes, server signature, reply KDF and h2.
|
||||
3. Independent explicit KX derivation agrees with both Kotlin libsodium role APIs; c2s and s2c assignment, token and both finished keys match.
|
||||
4. Exact client signature, F3, h3 and F4 bytes.
|
||||
5. Both directional stream bases, salts, MAC keys and UDP keys agree with `makeSession`.
|
||||
6. Three complete TCP records match the pure-Python ChaCha20/MAC composition byte for byte. Record ends are at offsets 27, 121 and 148, deliberately crossing and retaining partial 64-byte cipher masks. Decoding succeeds at chunk sizes 1, 3, 63, 64, 65 and the entire stream.
|
||||
7. Independently encrypted UDP records are accepted in order 2, 0, 1; duplicates are discarded and a modified ciphertext is rejected.
|
||||
|
||||
Padding is disabled for these byte-exact fixtures. Object bytes are opaque record-engine inputs; they are not examples of valid application RPC serialization. The fixed HELO is for the stream carrier; UDP record fixtures independently exercise datagram protection.
|
||||
|
||||
## Scope limits
|
||||
|
||||
F2–F4 fixture comparisons exercise the production envelope, signature, KDF and session-material components with fixed inputs. They do not force the production client/server state machines to use deterministic secret keys or IVs. The actual state machines continue to obtain entropy exclusively from libsodium, and separate randomized four-flight and connection integration tests cover their operation. No public or internal production entropy override was added to obtain vectors.
|
||||
|
||||
These checks target encoding, key ordering, transcript coverage and cursor errors. They do not prove the custom stream/MAC composition secure, quantify a timing adversary, establish traffic indistinguishability, or replace review of application trust provisioning. The bootstrap private-key compromise boundary remains as designed: recorded bootstrap flights become readable, whereas independently authenticated fresh session DH protects client identity and application traffic, subject to ephemeral-secret erasure.
|
||||
@@ -1,309 +0,0 @@
|
||||
# Concealed Kiloparsec: experimental wire specification
|
||||
|
||||
Draft 0, 2026-10-22. **Experimental source implementation exists; not independently audited or production-qualified.** See [implementation and validation](CONCEALED-PROTOCOL-IMPLEMENTATION.md). This document turns the agreements in [CONCEALED-PROTOCOL-DESIGN.md](CONCEALED-PROTOCOL-DESIGN.md) into a concrete candidate suite for Q2. Design references D1–D14 and Q1–Q14 retain their original meanings. A changed byte layout, primitive, or derivation requires a new suite; ordinary tuning must not silently change the wire format.
|
||||
|
||||
## 1. Scope and notation
|
||||
|
||||
The suite identifier is `1`, internal to authenticated encryption. This is a concealed connection flavor, not a renaming of released protocol 2. Endpoints explicitly select this flavor; no autodetection, downgrade, or TypeScript client change. TCP and WebSocket use stream records; UDP uses independent records. WebSocket message boundaries carry no additional protocol meaning: concatenate binary message payloads into the ordered byte stream. Reject text messages.
|
||||
|
||||
Integers are unsigned, fixed-width big-endian, except curve encodings, which follow their primitive's standard little-endian representation. `u8`, `u16`, `u32`, `u64` denote encoded widths. `a || b` denotes exact byte concatenation. `V(x) = u32(length(x)) || x`. No implicit text normalization, serializer metadata, native integer encoding, or trailing bytes are allowed. Text constants use ASCII. A label is `V(ASCII(label))`.
|
||||
|
||||
`H(x)` is BLAKE2b with output length **32**, no key. `B(n,k,x)` is BLAKE2b with configured output length `n` and key `k`; it is not truncation of a differently configured digest. Define:
|
||||
|
||||
```
|
||||
Derive(n, secret, label, context) =
|
||||
B(n, secret, V("kiloparsec/concealed/1") || V(label) || V(context))
|
||||
```
|
||||
|
||||
Every input called `secret` below is exactly 32 bytes, except the explicitly 64-byte ordered directional-key material in token derivation. Keys never cross roles. Context and complete transcript hashes are public bindings, not entropy sources. Constant-time comparison is required for tags and secret-derived proof values. All randomness comes from libsodium's CSPRNG.
|
||||
|
||||
## 2. Suite and provisioned credentials
|
||||
|
||||
| Purpose | Primitive |
|
||||
| --- | --- |
|
||||
| Bootstrap agreement | X25519 with Elligator 2 concealed ephemeral public representation |
|
||||
| Session agreement | libsodium `crypto_kx` (X25519/BLAKE2b), separate fresh key pairs |
|
||||
| Bootstrap encryption and UDP | XSalsa20-Poly1305 `crypto_secretbox_easy` |
|
||||
| Continuous stream encryption | Original ChaCha20, 32-byte key, 8-byte nonce, 64-bit block counter |
|
||||
| Stream object authentication | Keyed BLAKE2b with 16-byte output |
|
||||
| Hash/KDF | BLAKE2b as defined above |
|
||||
| Identity signature | Ed25519 detached signature, 32-byte public key, 64-byte signature |
|
||||
|
||||
This first suite supports Ed25519 identity keys only. Other crypto2 key formats need another authenticated suite/identity encoding; do not silently serialize arbitrary polymorphic key objects here.
|
||||
|
||||
Client configuration contains an authentically obtained server static X25519 encryption public key `E` and trusted server Ed25519 verifying key `Vs`, with their association governed by application policy. The server owns corresponding private keys. No wire key identifier or automatic rotation protocol is present. Distinct configured endpoints can serve distinct credential sets. Future support for multiple retained keys must impose a small configured trial bound.
|
||||
|
||||
Original ChaCha20 is chosen because the cached common libsodium binding exposes it, including the 64-bit `initialCounter`; its XChaCha20 stream functions are commented out for Apple. This is a concrete candidate primitive under D11, not a claim that the complete composition has received independent review.
|
||||
|
||||
## 3. Elligator interoperability profile: qualification prerequisite
|
||||
|
||||
Let `b` be a fresh clamped X25519 scalar. Generate a Curve25519 public point including a uniformly randomized small-order component before inverse Elligator mapping. Retry with fresh local material if no representative exists. Encode the successful representative `R` as exactly 32 bytes with the otherwise unused high-bit choices randomized. Decode `R` to Montgomery public coordinate `P`. The server's static scalar is clamped, so its multiplication removes the small-order component. Preserve the secret scalar associated with `P` until bootstrap completion.
|
||||
|
||||
The Kotlin prototype now implements the [elligator.org Curve25519 profile](https://elligator.org/map), with `p = 2^255-19`, `A = 486662`, `Z = 2`. Square roots select the integer representative in `[0,(p-1)/2]`. Two most-significant serialized bits are independent random padding and are masked on decode. A separate random bit selects the inverse-map branch corresponding to the point's v sign. The full-curve public point is `clamp(b)*G + (b mod 8)*H`; the order-eight Edwards generator H has y coordinate `2707385501144840649318225287225658788936804267575313519463743609750303402022` and even x. Convert Edwards y to Montgomery `u=(1+y)/(1-y)` modulo p.
|
||||
|
||||
Published direct, inverse, and special scalar-multiplication vectors from [elligator.org/vectors](https://elligator.org/vectors/) are checked into `ElligatorReferenceVectors.kt` (retrieved 2026-10-22; initial fixture SHA-256 `d4518186b46e58476c2bfd5a3a6cd36f413cd8d969384d7cc59b4843651d85a3`). Tests cover all eight torsion components and independent libsodium agreement. This pins the prototype's profile; it does not establish all implementation properties. Expand to the extended vector/randomized corpus before qualification. Do not claim byte-for-byte compatibility with a particular Monocypher release without checking it separately.
|
||||
Every use of a received X25519 public input checks its exact 32-byte width, propagates native rejection, and rejects an all-zero shared result before KDF. Do not regenerate local keys in response to a rejected peer key. The bignum mapping is variable-time, but the current call graph supplies public coordinates rather than the server static scalar. See the [timing assessment](CONCEALED-PROTOCOL-TIMING.md) for measurements, dataflow limits and response-shaping experiments; no constant-time or general timing-concealment guarantee is made.
|
||||
|
||||
## 4. Common protected bootstrap envelope
|
||||
|
||||
The first two flights use separately authenticated hidden lengths. This avoids a visible length field and avoids general object parsing before authentication.
|
||||
|
||||
Given a 32-byte root `K`, a context `C`, and a payload `M`:
|
||||
|
||||
```
|
||||
Kh = Derive(32, K, "flight/header-key", C)
|
||||
Kb = Derive(32, K, "flight/body-key", C)
|
||||
Nh = Derive(24, K, "flight/header-iv", C)
|
||||
Nb = Derive(24, K, "flight/body-iv", C)
|
||||
L = u32(length(M))
|
||||
Header = SecretBoxEasy(L, Nh, Kh) // 16-byte tag || 4 ciphertext bytes
|
||||
Body = SecretBoxEasy(L || M, Nb, Kb) // 16-byte tag || ciphertext
|
||||
Envelope(K,C,M) = Header || Body
|
||||
```
|
||||
|
||||
`SecretBoxEasy` has exactly the libsodium combined layout. Distinct keys and IV derivations separate header/body operations. Duplicating `L` inside the body binds header and body; compare exact copies after both authenticate. Authenticate the 20-byte header before allocating the bounded body. Reject over-limit lengths, invalid lengths, failed authentication, and extra bytes within a datagram. Envelope payload length includes random padding. A stream reads precisely `20 + 16 + 4 + length(M)` bytes. The maximum flight includes its exposed prefix and all envelope overhead.
|
||||
|
||||
For UDP an envelope occupies one datagram. While waiting for a flight, silently discard undersized/oversized or unauthenticated datagrams and continue within the original attempt deadline. In particular, a valid authenticated header followed by a corrupted body must not consume the pending handshake state. Only a completely authenticated envelope with invalid semantic fields/signature makes the attempt terminal. TCP authentication failure remains terminal because there is no safe stream resynchronization. No preliminary error response, parsing oracle, or unauthenticated error record is sent. Invalid unauthenticated input is silently dropped; TCP may close without sending bytes. Enforce admission budgets before expensive key agreement, and whole-attempt/read deadlines throughout.
|
||||
|
||||
## 5. Client HELO, flight 1
|
||||
|
||||
Generate separate fresh bootstrap scalar `b` and fresh session `crypto_kx` pair `(c,C)`. They must not be reused across attempts, and must not be the identity signing key. `C` appears only inside the encrypted HELO.
|
||||
|
||||
```
|
||||
Zb = X25519(b, E) // server computes X25519(e, P)
|
||||
U = Random[24]
|
||||
BootContext = R || U || E
|
||||
Kboot = Derive(32, Zb, "bootstrap", BootContext)
|
||||
|
||||
HeloCore =
|
||||
u8(1) // suite
|
||||
|| u8(carrier) // 1 = stream, 2 = UDP
|
||||
|| C[32]
|
||||
|| Vs[32] // intended server identity
|
||||
|| u32(receiveBodyLimit)
|
||||
|| u16(receiveDatagramLimit) // 0 for stream; includes full datagram
|
||||
|| u32(paddingMaximum)
|
||||
|
||||
HeloPayload = u32(length(HeloCore)) || HeloCore || randomPadding
|
||||
F1 = R[32] || U[24] || Envelope(Kboot, H(BootContext), HeloPayload)
|
||||
h1 = H(V("helo") || V(F1))
|
||||
```
|
||||
|
||||
HeloCore is exactly **76 bytes**; F1 is **176 bytes plus padding**. All fields are fixed for suite 1; reject an unknown suite/carrier or a carrier inconsistent with this endpoint. No feature bitmask with undefined semantics is accepted. A server compares the intended `Vs` to its configured identity. Local limits constrain parsing before any peer-advertised limit. The inner core length must equal the suite-1 core size; the remaining bytes are padding.
|
||||
|
||||
Bind the exact representative bytes into the KDF, including bits ignored by the map. Equivalent representatives must not let an attacker mutate an accepted flight. The optional recent-client-session-DH ring guard atomically checks/inserts `C` after successful decryption and validation; duplicates are silently ignored. The guard is operational only and need not persist across restart.
|
||||
|
||||
## 6. Server hello, flight 2
|
||||
|
||||
After accepting HELO, generate fresh session `crypto_kx` pair `(s,S)` and random reply IV `I[24]`. The client session public key `C` is confidential bootstrap material here. Domain-separated derivation from it is the Q1 decision; it is not a general-purpose KDF from public information.
|
||||
|
||||
```
|
||||
Kreply = Derive(32, C, "server-reply", h1 || E || Vs)
|
||||
ReplyContext = H(V("server-flight") || h1 || I)
|
||||
Proof = H(V("helo-decrypted") || C)
|
||||
|
||||
ServerCore =
|
||||
u8(1) || u8(carrier)
|
||||
|| S[32] || Proof[32] || Vs[32]
|
||||
|| u32(serverReceiveBodyLimit)
|
||||
|| u16(serverReceiveDatagramLimit)
|
||||
|| u32(serverPaddingMaximum)
|
||||
|
||||
ServerSignature = Ed25519Sign(serverSigningKey,
|
||||
V("kiloparsec/concealed/1/server-auth") || h1 || V(ServerCore))
|
||||
ServerPayload = u32(length(ServerCore)) || ServerCore
|
||||
|| ServerSignature[64] || randomPadding
|
||||
F2 = I[24] || Envelope(Kreply, ReplyContext, ServerPayload)
|
||||
h2 = H(V("handshake") || V(F1) || V(F2))
|
||||
```
|
||||
|
||||
ServerCore is exactly **108 bytes**; F2 is **240 bytes plus padding**. The client authenticates hidden length/body, verifies suite/carrier, proof, intended server key, and signature before completing session derivation. The explicit proof is retained as agreed although authenticated response decryption already supplies a related proof. The server signature covers selected parameters, both session keys indirectly (`C` through exact F1), intended static identity through F1, and the response role.
|
||||
|
||||
Effective outbound bounds are the smaller relevant local/peer receive bounds. Peer claims can reduce a sender's allowance; they cannot increase a local allocation cap. The server may reduce its own padding; it must not require padding or silently change algorithms. UDP F2 must fit both configured datagram budgets and pre-validation amplification allowance; pad F1 sufficiently or reject locally before sending an unsupported attempt. Before valid ACK, server bytes sent must not exceed three times bytes received for the attempt.
|
||||
|
||||
A new reception of an old HELO after guard eviction generates fresh S and I. Random I prevents reply key/IV reuse. It does not turn the old hello into an authenticated client.
|
||||
|
||||
## 7. Session material, token, and handshake keys
|
||||
|
||||
Use the ordinary fresh session pairs through libsodium `crypto_kx`:
|
||||
|
||||
```
|
||||
C2S = client.tx = server.rx // 32 bytes
|
||||
S2C = client.rx = server.tx // 32 bytes
|
||||
Token = Derive(32, C2S || S2C, "connection-token", h2)
|
||||
```
|
||||
|
||||
Ordered directional key material, not process-local sending/receiving order, is essential. Do not substitute crypto2 `SessionKey.sessionTag` without checking its exact derivation against these bytes.
|
||||
|
||||
For each direction `d` in ASCII `c2s`, `s2c`, let `Kd` be the corresponding crypto_kx output. Define:
|
||||
|
||||
```
|
||||
Kfinish[d] = Derive(32, Kd, "finished/" || d, h2)
|
||||
```
|
||||
|
||||
ACK and acceptance use independent secretbox records even on TCP. They do not consume the subsequent continuous stream's cipher position. This makes handshake retransmission independent of stream state and avoids changing application traffic keys before both identities are bound.
|
||||
|
||||
## 8. Client ACK, flight 3
|
||||
|
||||
After server authentication, the client signs the session binding with its identity key. This suite supports an Ed25519 client identity `Vc`; application policy decides whether it is authorized. Optional anonymous identity requires a future explicit policy/specification; it is not an absent signature.
|
||||
|
||||
```
|
||||
ClientSignature = Ed25519Sign(clientSigningKey,
|
||||
V("kiloparsec/concealed/1/client-auth") || Token || h2 || Vc)
|
||||
AckCore = u8(3) || h2[32] || Vc[32] || ClientSignature[64]
|
||||
AckPayload = u32(length(AckCore)) || AckCore || randomPadding
|
||||
A = Random[24]
|
||||
F3 = A || Envelope(Kfinish[c2s], H(V("ack") || h2 || A), AckPayload)
|
||||
h3 = H(V("authenticated-handshake") || h2 || V(F3))
|
||||
```
|
||||
|
||||
AckCore is exactly **129 bytes**; F3 is **197 bytes plus padding**. A server accepts F3 only for the pending attempt associated with its listener and, for UDP, source address/port. It checks exact core size, role, transcript, signature, and authorization policy. No RPC is dispatched based on an unverified identity. No connected callback or authorization hook is repeated for a retransmitted identical authenticated F3.
|
||||
|
||||
F3 is retransmitted **byte-for-byte**, with the same A and ciphertext. Never regenerate a signature/padding/IV for a retry of this flight. Reusing an identical cached ciphertext is not encrypting a different plaintext with reused nonce material.
|
||||
|
||||
## 9. Server acceptance, flight 4
|
||||
|
||||
After successful client identity verification and application authorization:
|
||||
|
||||
```
|
||||
AcceptCore = u8(4) || h3[32]
|
||||
AcceptPayload = u32(length(AcceptCore)) || AcceptCore || randomPadding
|
||||
J = Random[24]
|
||||
F4 = J || Envelope(Kfinish[s2c], H(V("accept") || h2 || J), AcceptPayload)
|
||||
```
|
||||
|
||||
AcceptCore is exactly **33 bytes**; F4 is **101 bytes plus padding**. There is no plaintext rejection variant. Failure aborts the attempt; authenticated diagnostic design is outside this initial suite. The client verifies F4 and its h3 before reporting connected. Cache exact F4 for the bounded UDP retry interval. Only a matching authenticated exact F3 may trigger its resend, within retry/rate budgets.
|
||||
|
||||
Identity-ready, authorized dispatch, and public connected state are separate lifecycle events internally. Existing onConnected policy handlers are synchronous KiloScope receiver functions, not suspending RPC callbacks. They inspect verified credentials before acceptance/readiness. An RPC launched asynchronously by a handler waits on the existing deferred readiness gate and runs after setup; a handler must not block waiting for it. Server policies run before F4; client policies run after F4 verification and before local readiness/connected notification. Preserve this existing initialization contract (D13/Q5), and test that background-launched calls release after readiness while rejecting policies fail queued calls. The handshake dispatcher remains separate from RPC.
|
||||
|
||||
## 10. Application traffic keys
|
||||
|
||||
Bind the client identity and exact ACK into traffic keys, avoiding unknown-peer associations across an identical DH transcript:
|
||||
|
||||
```
|
||||
For each d:
|
||||
KstreamBase[d] = Derive(32, Kd, "traffic/stream/" || d, h3)
|
||||
StreamSalt[d] = Derive(24, Kd, "traffic/salt/" || d, h3)
|
||||
Kmac[d] = Derive(32, Kd, "traffic/mac/" || d, h3)
|
||||
Kudp[d] = Derive(32, Kd, "traffic/udp/" || d, h3)
|
||||
```
|
||||
|
||||
The record engine expands the stream base/salt using exact ASCII labels (without V prefixes):
|
||||
|
||||
```
|
||||
Kstream[d] = B(32, KstreamBase[d],
|
||||
ASCII("kiloparsec/concealed/stream-key/v1") || StreamSalt[d])
|
||||
Nstream[d] = first8(B(16, KstreamBase[d],
|
||||
ASCII("kiloparsec/concealed/stream-nonce/v1") || StreamSalt[d]))
|
||||
```
|
||||
|
||||
This two-stage derivation is the record engine's reusable API boundary; handshake-derived inputs bind direction and h3. BLAKE2b generic-hash output lengths are 16–64, so the 8-byte nonce is explicitly truncated from the 16-byte configured digest. h3 is available to both peers before acceptance; F4 confirms its agreement. Neither F4 randomness nor retransmission changes traffic keys.
|
||||
|
||||
Erase raw bootstrap/shared/finished material when it is no longer needed, subject to bounded retransmission state. JVM garbage collection limits guarantees of physical erasure; document actual platform behavior rather than promising complete erasure.
|
||||
|
||||
## 11. Protected record body
|
||||
|
||||
A record's authenticated body is:
|
||||
|
||||
```
|
||||
u32(objectLength) || objectBytes || randomPadding
|
||||
```
|
||||
|
||||
The first byte of objectBytes is the message kind: `0 = RPC`, `1 = CLOSE`. For RPC the remaining bytes are one existing BiPack-encoded `Transport.Block` with no existing L0Call encryption wrapper. Decode only the exact objectLength bytes after integrity verification; require complete consumption. CLOSE objectBytes is exactly `u8(1) || u16(reason)` with reason `0 = normal`, `1 = local shutdown`, `2 = usage limit`; reject undefined reasons in suite 1. An authenticated CLOSE conveys no success for pending RPCs.
|
||||
|
||||
Direction and transcript are bound through separately derived MAC keys; they need not be repeated in each MAC input. The authenticated body delimiter fixes the boundary between object and padding. Padding byte content is random, with an independent uniformly sampled length between zero and configured effective maximum. UDP reduces padding before rejecting an oversized unpadded record. No filler is appended outside authentication/encryption.
|
||||
|
||||
## 12. Stream application records
|
||||
|
||||
Maintain independent byte cursor and implicit record sequence q per direction, both starting at zero after F4. For body B:
|
||||
|
||||
```
|
||||
L = u32(length(B))
|
||||
T = B(16, Kmac[d], ASCII("kiloparsec/concealed/record-mac/v1")
|
||||
|| u64(q) || L || B)
|
||||
PlainRecord = L || B || T
|
||||
WireRecord = PlainRecord XOR nextChaCha20Bytes(length(PlainRecord))
|
||||
```
|
||||
|
||||
ChaCha20 begins at block counter zero with Kstream/Nstream. For byte cursor p, the relevant block is `floor(p/64)` and within-block offset is `p mod 64`. Socket segmentation must not reset the cursor or discard unused block bytes. A binding call with `initialCounter` starts at a block boundary: retain leftovers or generate/discard the prefix of that block correctly. Never restart the primitive per object with counter zero.
|
||||
|
||||
Decrypt exactly 4 header bytes; enforce local/effective body limit before allocation; read body/tag with partial-record deadline; recompute MAC using exact bytes and constant-time compare; only then parse the body and BiPack object. Increment q after successful authentication. Any bad tag, malformed authenticated record, over-limit length, partial-record timeout, or premature EOF fails the channel. Never scan for resynchronization.
|
||||
|
||||
A single writer selects encoded plaintext objects from OutQueue using admission ordinal and priority, assigns q in selected order, protects, and writes each record completely. Enqueue admission is atomic. Cancellation after stream mutation must finish that record or abort; it cannot roll back a cipher cursor. The existing queue's 2730-position bound supplies fairness; no null-ordinal normal entries bypass it. A large object is not preemptible after writing starts.
|
||||
|
||||
EOF following authenticated CLOSE is clean; EOF without CLOSE is abrupt/truncated. CLOSE follows drained admitted writes, with the configured drain deadline. No half-close API initially.
|
||||
|
||||
## 13. UDP application records
|
||||
|
||||
Each direction has a protected u64 sequence q starting at zero. Allocate sequence, encrypt, and enqueue atomically under the preparation mutex; await transmission outside it.
|
||||
|
||||
```
|
||||
I = Random[24]
|
||||
Plain = u64(q) || Body
|
||||
Datagram = I || SecretBoxEasy(Plain, I, Kudp[d])
|
||||
```
|
||||
|
||||
Overhead before object bytes/padding is 24 + 16 + 8 + 4 = **52 bytes**, including an object kind byte inside objectBytes makes RPC framing overhead 53 bytes before BiPack bytes. The encrypted inner delimiter and sequence are authenticated. Require the complete datagram within configured/negotiated budget. Verify secretbox before touching replay state or interpreting record fields. Reject malformed valid records; silently discard unauthenticated garbage without closing an established UDP session. Duplicate/stale authenticated data records are nonfatal discards. Replay tracking uses the existing 4096-position window; local overtaking is at most 2730.
|
||||
|
||||
CLOSE is sent once, best effort. Lost/reordered datagrams do not imply delivery of prior RPCs. No new application retransmission, fragmentation/reassembly, NAT rebinding, or session migration is provided. Keep one active authenticated association per source/listener under existing adapter ownership rules; fresh unverified HELOs cannot replace it.
|
||||
|
||||
Handshake retries remain separate from normal replay filtering. Pending state knows which flight it expects; acceptance cache recognizes exact authenticated F3 without rerunning callbacks. After establishment, process a possible retry as such only after verifying its matching finished key/context; do not reinterpret arbitrary application datagrams as RPC or mutate a live session based on unauthenticated classification. The absence of a visible session selector requires a strictly bounded number of candidate states per tuple. Initial implementation may permit one pending attempt and one established/retry state per tuple; retire superseded pending attempts without replacing the established one.
|
||||
|
||||
## 14. Limits and failures
|
||||
|
||||
Use the grouped immutable options and defaults from design section 6. Fixed security properties are not optional: authentication, trusted identity verification, transcript binding, failed-DH rejection, 16-byte tags, 24-byte independent-record IVs, and non-wrapping counters.
|
||||
|
||||
For this candidate suite, proactively reconnect before a direction exceeds **2^32 application records** or, for streams, **2^40 encrypted plaintext bytes** (including encrypted length and MAC). Datagram records are bounded to 65507 bytes each, so the 2^32-record ceiling implies fewer than 2^48 encrypted bytes; there is no additional cumulative UDP byte counter. Handshake keys are flight-specific and bounded by flight size/retry budgets. These are conservative selected engineering ceilings, not a security-bound proof. Check projected use before admitting a record; never wrap q, cursor, or cipher counter. Usage-limit CLOSE may use a reserved final allowance; otherwise abort. Do not reset counters under existing keys. No in-place rekey protocol in suite 1.
|
||||
|
||||
Bootstrap defaults: 15-second whole-attempt deadline, three fresh attempts, 16-KiB stream-flight cap, UDP cap 1200 bytes. F1/F2/F3/F4 must each fit its carrier cap after overhead. Every inner length is validated against its authenticated container. Stream body default 16 MiB, minimum body size 4 (a control/object decoder may impose a larger minimum); partial-record deadline 30 seconds beginning with the first byte, not while idle. Queue 128 objects/32 MiB, padding default 0–255, graceful close 2 seconds. Admission budgets, retransmission and cache bounds follow Q10/design section 6.
|
||||
|
||||
A local, server-only failure-work policy defaults to one discarded HELO decryption after envelope authentication fails, using a mutated copy of an independent throwaway scalar. It shares crypto admission bounds, never changes the real outcome or emits bytes, and can be disabled by `Handshake.decoyOnHelloAuthenticationFailure`. It changes no wire field and is not an additional authentication check. See the [timing assessment](CONCEALED-PROTOCOL-TIMING.md#7-requested-server-side-decoy-decryption).
|
||||
|
||||
A pre-authentication error produces no detailed wire diagnostic. Local typed exceptions retain safe diagnostic categories, excluding keys/plaintexts. No peer-controlled lengths or rate-table keys cause unbounded allocation. Application deadline overrides do not disable cryptographic checks.
|
||||
|
||||
## 15. Required vectors and implementation status
|
||||
|
||||
This file specifies candidate bytes and labels. Published Elligator vectors and [independent Python composition fixtures](CONCEALED-PROTOCOL-VECTORS.md) now cover exact F1–F4 bytes, signatures, role-assigned KX material, nonaligned continuous stream records and reordered UDP records. These passed on JVM, Kotlin/JS and Linux Native. Fixed F2–F4 fixtures compare production components; randomized integration tests exercise the complete state machines without a production entropy override. This is not a completed cryptographic review. Before freezing it:
|
||||
|
||||
1. Extend the pinned Elligator vector corpus and prove mixed-order agreement on every qualified platform; assess bignum timing and retained binary size.
|
||||
2. Preserve and extend the independent F1–F4/KDF/signature/stream/UDP composition fixtures. A fully deterministic complete state-machine cross-implementation harness remains distinct from the completed component comparisons.
|
||||
3. Verify malformed inputs, modified representative aliases, every authenticated length, incorrect roles/identities, low-order DH rejection, duplicate flights, replay eviction/restart, and key-usage boundaries.
|
||||
4. Exercise initialization RPC readiness and authorization ordering against actual connection code before freezing integration behavior.
|
||||
5. Independently review custom continuous encryption/MAC composition, KDF from concealed C, downgrade/identity binding, forward-secrecy scope, unauthenticated framing bounds, and rejection observability.
|
||||
|
||||
The first and second flights do not have forward secrecy against later compromise of server static encryption key e. Captured HELO then reveals C, which reveals the reply key and server hello. Fresh session DH still protects ACK/client identity and application traffic against that static-key compromise, assuming ephemeral secrets are erased and remain uncompromised. Server identity itself may already be publicly provisioned. No concealment guarantee covers traffic timing, endpoints, WebSocket setup, volume, or active probes possessing public server credentials.
|
||||
|
||||
## 16. Existing API evidence and primary references
|
||||
|
||||
Inspected crypto2 `SigningKey.sign`, `VerifyingKey.verify`, `SafeKeyExchange`, and `Hash`; common cached libsodium-binding 0.9.6 sources expose `GenericHash.genericHash(message, requestedHashLength, key)`, `SecretBox.easy/openEasy`, `ScalarMultiplication.scalarMultiplication`, and `Stream.chacha20XorIc(..., initialCounter: ULong, ...)`. High-level crypto2 default hash wrappers are not sufficient where this suite demands explicit output size/key. Exact per-platform behavior still needs runtime qualification.
|
||||
|
||||
- [Libsodium original ChaCha20 and counters](https://doc.libsodium.org/advanced/stream_ciphers/chacha20)
|
||||
- [Libsodium generic hashing and explicit output size](https://doc.libsodium.org/hashing/generic_hashing)
|
||||
- [Libsodium secretbox combined format](https://doc.libsodium.org/secret-key_cryptography/secretbox)
|
||||
- [Libsodium crypto_kx roles and rejection](https://doc.libsodium.org/key_exchange)
|
||||
- [Elligator key generation and subgroup randomization](https://elligator.org/key-exchange)
|
||||
- [Monocypher reference Elligator API](https://monocypher.org/manual/elligator)
|
||||
- [RFC 7748](https://www.rfc-editor.org/rfc/rfc7748.html)
|
||||
|
||||
### Primitive encoding check vectors
|
||||
|
||||
These small values were independently calculated using Python `hashlib.blake2b` (OpenSSL/Python implementation), not the Kotlin code. They validate selected byte encodings/KDF output parameters only; they are **not full handshake or Elligator vectors**.
|
||||
|
||||
```
|
||||
secret/key = 000102030405060708090a0b0c0d0e0f
|
||||
101112131415161718191a1b1c1d1e1f
|
||||
context = a0a1a2a3
|
||||
label = ASCII("vector")
|
||||
Derive(32, secret, label, context) =
|
||||
595859a9e444bb24d1df7c118eeef70fea86720249bd9292eba6e32744da6f19
|
||||
Derive(24, secret, label, context) =
|
||||
46d4b4de3827562b5654864913c2c46cb9802a6a71322599
|
||||
|
||||
record q = 7
|
||||
body = 000000030161620000
|
||||
L = 00000009
|
||||
MAC using section12 exact domain and key above =
|
||||
d0809fec300f48fb92d8eacd48284a8f
|
||||
```
|
||||
|
||||
The record's objectBytes are `01 61 62`, with two zero padding bytes used solely for deterministic testing. Production padding is random. This vector treats object bytes opaquely and is not a valid RPC/control message example. A 24-byte BLAKE2b digest is not the first 24 bytes of its 32-byte digest; the first two vectors catch accidental truncation.
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
# 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,377 +0,0 @@
|
||||
# Kiloparsec
|
||||
|
||||
The Kotlin implementation and the native [TypeScript + WASM client](typescript/README.md)
|
||||
are maintained here together. Current Kotlin source version **0.9.0-SNAPSHOT** retains
|
||||
**protocol 2**, which authenticates RPC correlation metadata inside encrypted
|
||||
packets, and adds an opt-in [concealed flavor](CONCEALED-PROTOCOL-IMPLEMENTATION.md). The lightweight TypeScript client remains at 0.8.0 and uses protocol 2. Both peers must be upgraded together; protocol 1 peers are rejected with
|
||||
no automatic downgrade. See [the security analysis](typescript/docs/SECURITY.md).
|
||||
|
||||
The new generation of __PARanoid SECurity__ protocol, advanced, faster, more secure. It also allows connecting any "
|
||||
block device" transport to the same local interface. Out if the box it
|
||||
provides the following transports:
|
||||
|
||||
| name | JVM | JS | wasmJS | native |
|
||||
|-------------------|:----:|:---:|:-------:|:------:|
|
||||
| TCP/IP server | ✓ | | | ✓ |
|
||||
| TCP/IP client | ✓ | | | ✓ |
|
||||
| UDP server | ✓ | | | ✓ |
|
||||
| UDP client | ✓ | | | ✓ |
|
||||
| Websockets server | ✓ | | | |
|
||||
| Websockets client | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
### Note on version compatibility
|
||||
|
||||
Version 0.8.0 introduces an intentional wire break to close the response
|
||||
correlation vulnerability. Earlier 0.3–0.7 versions share the old envelope and
|
||||
must not be assumed compatible with current source. Application command schemas
|
||||
and the Kotlin interface/session programming model remain unchanged.
|
||||
|
||||
The 0.9.0-SNAPSHOT concealed flavor uses separate explicit endpoints and has no automatic downgrade or shared-port detection. It requires Kotlin 2.4 and `crypto2:0.9.6-SNAPSHOT`. See [release notes](RELEASE-0.9.0-SNAPSHOT.md) and [qualification evidence](CONCEALED-PROTOCOL-QUALIFICATION.md).
|
||||
|
||||
#### ID calculation algorithm is changed since 0.4.1
|
||||
|
||||
Historical note: 0.4.1 changed public/shared key ID derivation. Current deployments
|
||||
should target protocol 2; upgrading only to an older 0.4 release does not fix the
|
||||
authenticated correlation issue.
|
||||
|
||||
### Supported native targets
|
||||
|
||||
- iosArm64, iosX64
|
||||
- macosArm64, macosArm64
|
||||
- linuxArm64, linuxX64
|
||||
|
||||
### Non-native targets
|
||||
|
||||
- JS (browser and Node.js)
|
||||
- JVM (android, macOS, windows, linux, everywhere where JRE is installed)
|
||||
|
||||
## TCP/IP and UDP transports
|
||||
|
||||
These are the fastest based on async socket implementation of a ktor client. They work everywhere but JS target as
|
||||
there are currently no widely adopted sockets for browser JavaScript.
|
||||
|
||||
While UDP is faster than TCP/IP, it is less reliable, especially with commands and return values that serialize to more
|
||||
than 240 bytes approx, and has no retransmission facilities (use TCP!). UDP, though, shines when all you need is
|
||||
to [push](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-remote-interface/push.html) with
|
||||
little or no data in it.
|
||||
|
||||
## Websockets server
|
||||
|
||||
While it is much slower than TCP or UDP, it is still faster than any http-based API; it uses binary frames based on
|
||||
the Ktor server framework to easily integrate with web services. We recommend using it instead of a classic HTTP API as
|
||||
it beats it in terms of speed and server load even with HTTP/2.
|
||||
|
||||
We recommend to create the `KiloInterface<S>` instance and connect it to the websockets and tcp servers in real
|
||||
applications to get easy access from anywhere.
|
||||
|
||||
## Websocket client
|
||||
|
||||
It is slower than TCP or UDP, but it works on literally all platforms. See the sample below.
|
||||
|
||||
# Usage
|
||||
|
||||
The library should be used as a maven dependency, not as source.
|
||||
|
||||
## Adding dependency
|
||||
|
||||
### Declare maven repository:
|
||||
|
||||
Declare the library and its existing backend repositories in `build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://maven.universablockchain.com/")
|
||||
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||
maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven")
|
||||
}
|
||||
```
|
||||
|
||||
### Add dependency block
|
||||
|
||||
It could be, depending on your project structure, something like:
|
||||
|
||||
```kotlin
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
api("net.sergeych:kiloparsec:0.9.0-SNAPSHOT")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Create a shared interface for your server and the client
|
||||
|
||||
It could be a multiplatform library that exports it or just a shared or copied source file declaring structures
|
||||
and functions available, like:
|
||||
|
||||
```kotlin
|
||||
// Api.kt
|
||||
|
||||
@Serializable
|
||||
class FooArgs(val text: String, val number: Int = 42)
|
||||
|
||||
// Server-side interface
|
||||
val cmdSetFoo by command<FooArgs, Unit>()
|
||||
val cmdGetFoo by command<Unit, FooArgs>()
|
||||
val cmdPing by command<String, String>()
|
||||
val cmdCheckConnected by command<Unit, Boolean>()
|
||||
|
||||
// client-side interface (called from the server)
|
||||
val cmdPushClient by command<String, Unit>()
|
||||
```
|
||||
|
||||
## Call it from the client:
|
||||
|
||||
Remember, we need to implement client interface `cmdPushClient` in our example, so we need to provide
|
||||
local interface too:
|
||||
|
||||
```kotlin
|
||||
// Unit: no session on the client:
|
||||
val client = websocketClient<Unit>("wss://your.host.com/kp") {
|
||||
// This is server-callable function we export:
|
||||
on(cmdPushClient) {
|
||||
"server push: $it"
|
||||
}
|
||||
}
|
||||
|
||||
// If we want to collect connected state changes (this is optional)
|
||||
launch {
|
||||
client.connectedStateFlow.collect {
|
||||
if (it)
|
||||
println("I am connected")
|
||||
else
|
||||
println("trying to connect...")
|
||||
}
|
||||
}
|
||||
|
||||
// now we can call server's functions
|
||||
client.call(cmdSetFoo, FooArgs("bar", 117))
|
||||
assertEquals(FooArgs("bar", 117), client.call(cmdGetFoo))
|
||||
|
||||
```
|
||||
|
||||
## Create a ktor-based server
|
||||
|
||||
Normally the server side needs some session. It is convenient and avoids sending repeating data on each request speeding
|
||||
up
|
||||
the protocol. With KILOPARSEC, it is a rather basic operation:
|
||||
|
||||
~~~kotlin
|
||||
// Our session just keeps Foo for cmd{Get|Set}Foo:
|
||||
data class Session(var fooState: FooArgs? = null)
|
||||
|
||||
// Let's now provide interface we export, it will be used on each connection automatically:
|
||||
|
||||
// Note server interface uses Session:
|
||||
val serverInterface = KiloInterface<Session>().apply {
|
||||
onConnected {
|
||||
// Do some initialization
|
||||
session.fooState = null
|
||||
}
|
||||
// Exceptions are passed through the network and re-created (re-thrown) on other side:
|
||||
on(cmdGetFoo) { session.fooState ?: throw IllegalStateException("foo is not yet set") }
|
||||
on(cmdSetFoo) { session.fooState = it }
|
||||
}
|
||||
|
||||
// now create server using ktor (see ktor project for more):
|
||||
|
||||
val ns: NettyApplicationEngine = embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = {
|
||||
setupWebsocketServer(serverInterface) { Session() }
|
||||
}).start(wait = false)
|
||||
|
||||
~~~
|
||||
|
||||
## Create a TCP / IP client and server
|
||||
|
||||
Using plain TCP/IP is even simpler, and it works way faster than websocket one, and is _the same
|
||||
protected as `wss://` (and `ws://`) the variant above due to same kiloparsec encryption in both cases. Still, a TCP/IP
|
||||
client is not available in JavaScript browser targets, and custom TCP ports could often be blocked by firewalls.
|
||||
|
||||
Documentation is available in samples here:
|
||||
|
||||
- [TCP/IP server creation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-server/index.html)
|
||||
|
||||
- [TCP/IP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/index.html)
|
||||
|
||||
In short, there are two functions that implement asynchronous TCP/IP transport on all platforms buy JS:
|
||||
|
||||
- [acceptTcpDevice](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/accept-tcp-device.html?query=fun%20acceptTcpDevice(port:%20Int):%20Flow%3CInetTransportDevice%3E)
|
||||
to create a server
|
||||
|
||||
- [connectTcpDevice](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/connect-tcp-device.html)
|
||||
to connect to the server
|
||||
|
||||
## UDP client and server
|
||||
|
||||
Is very much straightforward, same as with TCP/IP:
|
||||
|
||||
- [UDP server creation](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/accept-udp-device.html)
|
||||
- [Connect UDP client](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec.adapter/connect-udp-device.html)
|
||||
|
||||
### UDP specifics
|
||||
|
||||
#### Command size
|
||||
|
||||
Each command invocation and result are packed in a separate UDP diagram using effective binary packing.
|
||||
Thus, for the best results commands and results should be relatively short, best to fit into 240 bytes. While bigger
|
||||
datagrams are often transmitted successfully, the probability of the effective transmission drops with the size.
|
||||
|
||||
Kiloparsec UDP transport does not retransmit not delivered packets. Use TCP/IP or websocket if it is a concern.
|
||||
|
||||
For the best results, we recommend
|
||||
using [push](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-remote-interface/index.html#1558240250%2FFunctions%2F788909594)
|
||||
for remote interfaces with UDP.
|
||||
|
||||
#### Timeouts
|
||||
|
||||
As Datagrams do not form protocol itself, kiloparsec issues pings when no data is circulated between parties.
|
||||
When no pings are received long enough, the kiloparsec connection is closed. There are `maxInactivityTimeout` in all
|
||||
relevant functions and constructors.
|
||||
|
||||
Client should not issue pings manually.
|
||||
|
||||
## Reusing code between servers
|
||||
|
||||
The same instance of
|
||||
the [KiloInterface](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-interface/index.html?query=open%20class%20KiloInterface%3CS%3E%20:%20LocalInterface%3CKiloScope%3CS%3E%3E)
|
||||
could easily be reused with all instances of servers with different protocols.
|
||||
|
||||
This is a common practice to create a business logic in a `KiloInterface`, then create a TCP/IP and Websocket servers
|
||||
passing the same instance of the logic to both.
|
||||
|
||||
## Note on the server identification
|
||||
|
||||
We do not recommend relying on TLS (HTTPS://, WSS://) host identification solely; in the modern world there is
|
||||
a high probability of attacks on unfriendly (in respect to at least some of your users) states to the SSL certificates
|
||||
chain, in which case the [MITM attack] and spoofing will be undetected. Check
|
||||
the [remoteId](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-client/remote-id.html?query=suspend%20fun%20remoteId():%20VerifyingPublicKey?)
|
||||
in your client on each connection and provide the
|
||||
safe [serverSecretKey](https://code.sergeych.net/docs/kiloparsec/kiloparsec/net.sergeych.kiloparsec/-kilo-server/index.html)
|
||||
when creating a server.
|
||||
|
||||
This will effectively protect against certificate chain spoofing in the case of the application installed from the
|
||||
trusted source.
|
||||
|
||||
__Important note__. The web application could not be completely secured this way unless is loaded from the IP-address,
|
||||
as the DNS could be spoofed the same, especially when used with `Cloudflare` or other CDN that can transparently
|
||||
substitute the whole site. For applications, we strongly recommend not using CDN except your own, controlled ones. You
|
||||
generally can't neither detect nor repel [MITM attack] performed from _any single cloudflare 'ray'_.
|
||||
|
||||
## See also:
|
||||
|
||||
- [Source documentation](https://code.sergeych.net/docs/kiloparsec/)
|
||||
- [Project's WIKI](https://gitea.sergeych.net/SergeychWorks/kiloparsec/wiki)
|
||||
|
||||
# Details
|
||||
|
||||
It is not compatible with Parsec family and no more based on the Universa crypto library. To better fit
|
||||
the modern state of threats and rate of cyber crimes, KiloParsec uses more encryption and random key exchange on each
|
||||
and every connection (while parsec caches session keys to avoid time-consuming keys exchange). For the same reason,
|
||||
keys cryptography for session is shifted to use ed25519 curves, which are supposed to provide agreeable strength with
|
||||
enough speed to protect every connection with unique new keys. Also, we completely get rid of SHA2.
|
||||
|
||||
Kiloparsec also uses a denser binary format [bipack](https://gitea.sergeych.net/SergeychWorks/mp_bintools), no more
|
||||
key-values,
|
||||
which reveals much less on the inner data structure, providing advanced
|
||||
typed RPC interfaces with kotlinx.serialization. There is also Rust
|
||||
implementation [bipack_ru](https://gitea.sergeych.net/DiWAN/bipack_ru).
|
||||
The architecture allows connecting the same functional interfaces to several various type channels at once.
|
||||
|
||||
Also, the difference from parsecs is that there are no more unencrypted layer commands available to users.
|
||||
All RPC is performed over the encrypted connection.
|
||||
|
||||
# Technical description
|
||||
|
||||
Kiloparsec is a full-duplex fully async (coroutine-based) Remote Procedure Call protocol with typed parameters
|
||||
and support for serializing exceptions (e.g. exception thrown while executing remote command will be caught and
|
||||
rethrown at the caller context).
|
||||
|
||||
Kiloparsec is not REST, it _has advanced session mechanisms_ and built-in authentication based on the same curve keys.
|
||||
Integrated tools to prevent MITM attacks include also non-transferred independently generated token that is calculated
|
||||
independently on the ends and is never transferred with the network. Comparing it somehow (visually, with QR code, etc.)
|
||||
could add a very robust guarantee of the connection safety and ingenuity.
|
||||
|
||||
Kiloparsec has a built-in completely asynchronous (coroutine-based top-down) transport layer based on TCP (JVM only as
|
||||
for
|
||||
now) and the same async Websocket-based transport based on KTOR. Websocket client is multiplatform, though the server is
|
||||
JVM only insofar.
|
||||
|
||||
# Licensing
|
||||
|
||||
This is a work in progress, not yet moved to the public domain;
|
||||
you need to obtain a license from https://8-rays.dev or [Sergey Chernov]. For open source projects it will most be free
|
||||
on some special terms.
|
||||
|
||||
It will be moved to open source; we also guarantee that it will be moved to open source immediately if the software
|
||||
export restrictions are lifted. We do not support such practices here at 8-rays.dev.
|
||||
|
||||
[MITM]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack
|
||||
|
||||
[Sergey Chernov]: https://t.me/real_sergeych
|
||||
[^1]: On some new Xiaomi phones we found problems with websocket binary frames, probably in ktor; use text frames
|
||||
otherwise.
|
||||
|
||||
## Numeric transport priority (0.7.2-SNAPSHOT)
|
||||
|
||||
`call(command, args, priority = 100)` and `push(command, args, priority = 0)`
|
||||
select numeric outgoing priority. Responses inherit request priority, calls remain
|
||||
concurrent, and the framed wire extension interoperates with legacy peers.
|
||||
See [priority API, compatibility and limits](PRIORITY.md).
|
||||
|
||||
|
||||
## Connection health and WebSocket keepalive (0.7.2-SNAPSHOT)
|
||||
|
||||
Encrypted application heartbeat is opt-in: `KiloClientConnection.run()` does not
|
||||
start it. Pass `ConnectionHealth` explicitly only when periodic probes are needed.
|
||||
`ReconnectionOptions` configures connection deadlines, retry delay, and an optional
|
||||
finite attempt budget; `reconnectExhausted` signals when that budget ends.
|
||||
|
||||
The server WebSocket helper retains its existing defaults and signature. Its new
|
||||
configuration overload permits applications to disable the transport pinger:
|
||||
|
||||
```kotlin
|
||||
setupWebsocketServer(api, path = "/rpc", serverKey = key,
|
||||
configureWebSockets = { pingPeriodMillis = 0 }) { Session() }
|
||||
```
|
||||
|
||||
Without heartbeat, applications must handle transport closure and bound real
|
||||
request response times. A successful socket write does not prove peer reachability.
|
||||
Proxy/NAT inactivity timeouts remain independent of these settings.
|
||||
|
||||
## Publishing
|
||||
|
||||
`./gradlew publishAllPublicationsToMavenRepository` publishes the configured
|
||||
version to the private Gitea Maven registry using the existing local credential
|
||||
file. Never commit that credential. This project can compile and publish its Apple
|
||||
KLIB variants from Linux; final Apple application linking and runtime qualification
|
||||
still require the appropriate Apple toolchain and devices. Check publication
|
||||
output and the registry rather than assuming a target was skipped.
|
||||
|
||||
`bin/pubdocs` generates Dokka and uploads it to the existing Kiloparsec documentation
|
||||
site. For development-only Maven Local publication without configuring the remote
|
||||
repository, pass `-PlocalPublishingOnly=true`.
|
||||
|
||||
## Verify Kotlin and TypeScript together
|
||||
|
||||
Requires JDK 21, Node.js 22+, and the Android SDK configuration used by this
|
||||
multiplatform build. Install the module's pinned npm dependencies once:
|
||||
|
||||
```sh
|
||||
npm ci --prefix typescript --ignore-scripts
|
||||
bin/test-all.sh
|
||||
```
|
||||
|
||||
The script runs JVM and Kotlin/JS Node tests, TypeScript unit tests, then cross-language tests against
|
||||
a disposable localhost JVM server built from this checkout. It does not publish
|
||||
or contact deployment hosts. Additional Gradle options can be passed to the script.
|
||||
For TypeScript-only commands and examples, see [the module README](typescript/README.md).
|
||||
|
||||
## Experimental concealed protocol
|
||||
|
||||
A separate Kotlin Multiplatform flavor conceals the handshake and RPC metadata using
|
||||
Elligator, authenticated encrypted records and configurable padding. It is under
|
||||
active qualification and uses explicit endpoints. See the
|
||||
[implementation guide](CONCEALED-PROTOCOL-IMPLEMENTATION.md),
|
||||
[wire draft](CONCEALED-PROTOCOL-WIRE.md) and
|
||||
[design decisions](CONCEALED-PROTOCOL-DESIGN.md). The existing protocol and lightweight
|
||||
TypeScript client remain available.
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Kiloparsec 0.9.0-SNAPSHOT
|
||||
|
||||
This snapshot adds an opt-in Kotlin Multiplatform concealed transport flavor alongside the existing protocol 2. The lightweight TypeScript client remains at 0.8.0 and uses protocol 2. Existing endpoints do not switch flavors automatically.
|
||||
|
||||
The new flavor protects handshake and RPC metadata with an Elligator bootstrap, independently authenticated fresh session DH, encrypted records and configurable padding. Raw TCP, UDP and binary WebSocket carriers integrate with the existing RPC engine, initialization authorization hooks and bounded priority queue. UDP records decrypt independently and retain replay protection; TCP uses a continuous authenticated stream. Client establishment attempts are finite and do not automatically reissue application operations.
|
||||
|
||||
Server HELO authentication failures perform one discarded attempt with a mutated copy of a separate throwaway key, under the existing crypto admission limit. This configurable behavior cannot change authentication outcomes or send a response. It is supplementary failure-path work, without a general timing-concealment claim.
|
||||
|
||||
The existing transport's encrypted-response and complete-block decoding boundaries also use strict BiPack decoding, avoiding an empty-response/null hazard exposed by Native testing.
|
||||
|
||||
Validation passed: **127 JVM, 90 Kotlin/JS Node and 94 Linux Native tests**. This includes independent Python composition fixtures, 10,000 malformed record cases, 12,800 UDP records under impairment, bounded admission, 60 seconds of duplex/priority load, and actual TCP/UDP payload captures. Android, Wasm and all configured native compilation checks passed. See the [qualification record](CONCEALED-PROTOCOL-QUALIFICATION.md) for measurements, publication verification and precise coverage limits.
|
||||
|
||||
Use `net.sergeych:kiloparsec:0.9.0-SNAPSHOT` from the existing private Maven repository. The build requires Kotlin **2.4.0** and currently consumes `crypto2:0.9.6-SNAPSHOT`. No additional crypto runtime library was introduced. Initial API use requires `@OptIn(ExperimentalConcealedProtocol::class)`; [examples and configuration](CONCEALED-PROTOCOL-IMPLEMENTATION.md) are available.
|
||||
|
||||
SNAPSHOT was selected because this is the first distribution of the concealed wire flavor, it consumes a snapshot dependency, and browser/mobile/Apple runtimes have not been qualified. Independent fixtures verify our composition using trusted primitives; they do not constitute an external cryptographic audit or prove traffic indistinguishability. The API and concealed wire format may still change during qualification. Bootstrap-private-key compromise exposes the initial flights, while the separate authenticated fresh session DH protects client identity and application traffic, subject to ephemeral-secret erasure.
|
||||
@@ -1,209 +0,0 @@
|
||||
# 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`.
|
||||
|
||||
Follow-up design: [Concealed protocol design record](CONCEALED-PROTOCOL-DESIGN.md), including agreed decisions D1–D14, corrections C1–C8, and open questions Q1–Q14. This is separate future work; the review issues below retain their existing status.
|
||||
|
||||
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.
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
set -e
|
||||
# Dokka needs more Metaspace than the default Gradle daemon on this KMP project.
|
||||
./gradlew '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g' dokkaGeneratePublicationHtml
|
||||
rsync -avz ./build/dokka/* code.sergeych.net:/bigstore/sergeych_pub/code/docs/kiloparsec
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/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
|
||||
+43
-188
@@ -1,228 +1,83 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBuildType
|
||||
import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform") version "2.4.0"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0"
|
||||
id("com.android.library") version "8.7.2" apply true
|
||||
kotlin("multiplatform") version "1.9.20"
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20"
|
||||
`maven-publish`
|
||||
id("org.jetbrains.dokka") version "2.2.0"
|
||||
}
|
||||
|
||||
group = "net.sergeych"
|
||||
version = "0.9.0-SNAPSHOT"
|
||||
version = "0.1.0-SNAPSHOT"
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
maven("https://maven.universablockchain.com/")
|
||||
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||
maven("https://gitea.sergeych.net/api/packages/YoungBlood/maven")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
jvm()
|
||||
js {
|
||||
browser()
|
||||
nodejs {
|
||||
// Replay-window tests deliberately process more than 4096 messages.
|
||||
testTask { useMocha { timeout = "30s" } }
|
||||
}
|
||||
}
|
||||
linuxX64 {
|
||||
// Explicit qualification binary: do not add optimized test linking to ordinary builds.
|
||||
if (providers.gradleProperty("concealedNativeBenchmark").map(String::toBoolean).getOrElse(false)) {
|
||||
binaries.test("concealedBenchmark", listOf(NativeBuildType.RELEASE))
|
||||
testRuns.create("concealedBenchmark") {
|
||||
setExecutionSourceFrom(binaries.getTest("concealedBenchmark", NativeBuildType.RELEASE))
|
||||
jvm {
|
||||
jvmToolchain(8)
|
||||
withJava()
|
||||
testRuns.named("test") {
|
||||
executionTask.configure {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
}
|
||||
linuxArm64()
|
||||
|
||||
macosArm64()
|
||||
macosX64()
|
||||
iosX64()
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
|
||||
mingwX64()
|
||||
|
||||
androidTarget {
|
||||
publishLibraryVariants("release")
|
||||
js(KotlinJsCompilerType.IR) {
|
||||
browser {
|
||||
// commonWebpackConfig {
|
||||
// cssSupport {
|
||||
// enabled.set(true)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
val hostOs = System.getProperty("os.name")
|
||||
val isArm64 = System.getProperty("os.arch") == "aarch64"
|
||||
val isMingwX64 = hostOs.startsWith("Windows")
|
||||
val nativeTarget = when {
|
||||
hostOs == "Mac OS X" && isArm64 -> macosArm64("native")
|
||||
hostOs == "Mac OS X" && !isArm64 -> macosX64("native")
|
||||
hostOs == "Linux" && isArm64 -> linuxArm64("native")
|
||||
hostOs == "Linux" && !isArm64 -> linuxX64("native")
|
||||
isMingwX64 -> mingwX64("native")
|
||||
else -> throw GradleException("Host OS is not supported in Kotlin/Native.")
|
||||
}
|
||||
|
||||
val ktor_version = "3.1.1"
|
||||
|
||||
sourceSets {
|
||||
all {
|
||||
languageSettings.optIn("kotlinx.serialization.ExperimentalSerializationApi")
|
||||
languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
|
||||
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
|
||||
languageSettings.optIn("kotlin.time.ExperimentalTime")
|
||||
|
||||
}
|
||||
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
api("io.ktor:ktor-client-core:$ktor_version")
|
||||
api("net.sergeych:crypto2:0.9.6-SNAPSHOT")
|
||||
// Existing crypto backend, directly used by the experimental concealed channel.
|
||||
implementation("net.sergeych:multiplatform-crypto-libsodium-bindings:0.9.6")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
|
||||
|
||||
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.0")
|
||||
api("com.ionspin.kotlin:bignum:0.3.8")
|
||||
|
||||
api("net.sergeych:mp_bintools:0.0.6-SNAPSHOT")
|
||||
api("net.sergeych:mp_stools:1.4.1")
|
||||
}
|
||||
}
|
||||
val androidMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-okhttp:$ktor_version")
|
||||
}
|
||||
}
|
||||
val ktorSocketMain by creating {
|
||||
dependsOn(commonMain)
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-network:$ktor_version")
|
||||
}
|
||||
}
|
||||
androidMain.dependsOn(ktorSocketMain)
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation("org.slf4j:slf4j-simple:2.0.17")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
}
|
||||
val ktorSocketTest by creating {
|
||||
dependsOn(commonTest)
|
||||
}
|
||||
val jvmMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-server-core:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version")
|
||||
implementation("io.ktor:ktor-server-websockets:${ktor_version}")
|
||||
implementation("io.ktor:ktor-server-netty:$ktor_version")
|
||||
api("io.ktor:ktor-client-cio:$ktor_version")
|
||||
}
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val jvmTest by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val jsMain by getting {
|
||||
dependencies {
|
||||
implementation("io.ktor:ktor-client-js:$ktor_version")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.9")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
}
|
||||
}
|
||||
val jvmMain by getting
|
||||
val jvmTest by getting
|
||||
val jsMain by getting
|
||||
val jsTest by getting
|
||||
val macosArm64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val macosArm64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val macosX64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val iosX64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val iosX64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val iosArm64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val iosArm64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val linuxArm64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val linuxArm64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val linuxX64Main by getting {
|
||||
dependsOn(ktorSocketMain)
|
||||
}
|
||||
val linuxX64Test by getting {
|
||||
dependsOn(ktorSocketTest)
|
||||
}
|
||||
val nativeMain by getting
|
||||
val nativeTest by getting
|
||||
}
|
||||
}
|
||||
|
||||
publishing {
|
||||
val mavenToken by lazy {
|
||||
File("${System.getProperty("user.home")}/.gitea_token").readText()
|
||||
}
|
||||
repositories {
|
||||
if (!providers.gradleProperty("localPublishingOnly").map(String::toBoolean).getOrElse(false)) {
|
||||
maven {
|
||||
credentials(HttpHeaderCredentials::class) {
|
||||
name = "Authorization"
|
||||
value = mavenToken
|
||||
}
|
||||
url = uri("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
|
||||
authentication {
|
||||
create("Authorization", HttpHeaderAuthentication::class)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dokka {
|
||||
dokkaPublications.html {
|
||||
outputDirectory.set(layout.buildDirectory.dir("dokka"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace = "net.sergeych.kiloparsec"
|
||||
compileSdk = 34
|
||||
defaultConfig {
|
||||
minSdk = 24
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
// This task stays local: the interoperability build is run sequentially by
|
||||
// bin/test-all.sh, avoiding a nested Gradle build of the same checkout.
|
||||
tasks.register<Exec>("typescriptTest") {
|
||||
group = "verification"
|
||||
description = "Build and test the TypeScript/WASM client (run npm ci first)."
|
||||
workingDir(layout.projectDirectory.dir("typescript"))
|
||||
commandLine("npm", "test")
|
||||
}
|
||||
|
||||
// The current libsodium binding captures _sodium_init during module loading.
|
||||
// 0.7.16 exports it only after ready; pin the compatible transitive JS/Wasm runtime.
|
||||
rootProject.plugins.withType<org.jetbrains.kotlin.gradle.targets.js.yarn.YarnPlugin> {
|
||||
rootProject.the<org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension>()
|
||||
.resolution("libsodium-sumo", "0.7.15")
|
||||
}
|
||||
rootProject.plugins.withType<org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnPlugin> {
|
||||
rootProject.the<org.jetbrains.kotlin.gradle.targets.wasm.yarn.WasmYarnRootExtension>()
|
||||
.resolution("libsodium-sumo", "0.7.15")
|
||||
}
|
||||
@@ -1,20 +1 @@
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
kotlin.code.style=official
|
||||
kotlin.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
-11
@@ -1,15 +1,5 @@
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
#
|
||||
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -1,13 +1,19 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# You may use, distribute and modify this code under the
|
||||
# terms of the private license, which you must obtain from the author
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
# real dot sergeych at gmail.
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import com.ionspin.kotlin.crypto.LibsodiumInitializer
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
private var isReady = false
|
||||
private val readyAccess = Mutex()
|
||||
|
||||
/**
|
||||
* Library initialization: should be called before all other calls.
|
||||
* It is safe and with little performance penalty to call it multiple times.
|
||||
*/
|
||||
suspend fun initCrypto() {
|
||||
// faster to check with no lock
|
||||
if( !isReady) {
|
||||
readyAccess.withLock {
|
||||
// recheck with lock, it could be ready by now
|
||||
if( !isReady ) {
|
||||
LibsodiumInitializer.initialize()
|
||||
isReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
/**
|
||||
* Multi-signed data box. Use [SignedBox.invoke] to easily create
|
||||
* instances and [SignedBox.plus] to add more signatures (signing keys), and
|
||||
* [SignedBox.contains] to check for a specific key signature presence.
|
||||
*
|
||||
* It is serializable and checks integrity on deserialization. If any of seals does not
|
||||
* match the signed [message], it throws [IllegalSignatureException] _on deserialization_.
|
||||
* E.g., if you have it deserialized, it is ok, check it contains all needed keys among
|
||||
* signers.
|
||||
*
|
||||
* __The main constructor is used for deserializing only__. Don't use it directly unless you
|
||||
* know what you are doing as it may be dangerous.Use one of the above to create or change it.
|
||||
*/
|
||||
@Serializable
|
||||
class SignedBox(
|
||||
val message: UByteArray,
|
||||
private val seals: List<Seal>,
|
||||
@Transient
|
||||
private val checkOnInit: Boolean = true
|
||||
) {
|
||||
|
||||
/**
|
||||
* If this instance is not signed by a given key, return new instance signed also by this
|
||||
* key, or return unchanged (same) object if it is already signed by this key; you
|
||||
* _can't assume it always returns a copied object!_
|
||||
*/
|
||||
operator fun plus(key: Key.Signing): SignedBox =
|
||||
if (key.verifying in this) this
|
||||
else SignedBox(message, seals + Seal.create(key, message), false)
|
||||
|
||||
/**
|
||||
* Check that it is signed with a specified key.
|
||||
*/
|
||||
operator fun contains(verifyingKey: Key.Verifying): Boolean {
|
||||
return seals.any { it.key == verifyingKey }
|
||||
}
|
||||
|
||||
init {
|
||||
if (seals.isEmpty()) throw IllegalArgumentException("there should be at least one seal")
|
||||
if (checkOnInit) {
|
||||
if (!seals.all { it.verify(message) }) throw IllegalSignatureException()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A key + signature pair for [SignedBox] boxes, usually you don't use it
|
||||
* directly bug call [SignedBox] constructor or [SignedBox.plus] to
|
||||
* add seals.
|
||||
*/
|
||||
@Serializable
|
||||
data class Seal(val key: Key.Verifying, val signature: UByteArray) {
|
||||
|
||||
fun verify(message: UByteArray): Boolean = key.verify(signature, message)
|
||||
|
||||
companion object {
|
||||
fun create(key: Key.Signing, message: UByteArray): Seal {
|
||||
return Seal(key.verifying, key.sign(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance with a specific data sealed by one or more
|
||||
* keys. At least one key is required to disallow providing not-signed
|
||||
* instances, e.g. [SignedBox] is guaranteed to be properly sealed when
|
||||
* successfully instantiated.
|
||||
*
|
||||
* @param data a message to sign
|
||||
* @param keys a list of keys to sign with, should be at least one key.
|
||||
* @throws IllegalArgumentException if keys are not specified.
|
||||
*/
|
||||
operator fun invoke(data: UByteArray, vararg keys: Key.Signing): SignedBox =
|
||||
SignedBox(data, keys.map { Seal.create(it, data) }, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import net.sergeych.bintools.CRC
|
||||
|
||||
fun isValidContrail(data: UByteArray): Boolean = CRC.crc8(data.copyOfRange(1, data.size)) == data[0]
|
||||
|
||||
fun createContrail(data: UByteArray): UByteArray = ubyteArrayOf(CRC.crc8(data)) + data
|
||||
@@ -0,0 +1,75 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import com.ionspin.kotlin.crypto.signature.InvalidSignatureException
|
||||
import com.ionspin.kotlin.crypto.signature.Signature
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.crypto.Key.Signing
|
||||
|
||||
/**
|
||||
* Keys in general: public, secret and later symmetric too.
|
||||
* Keys could be compared to each other for equality and used
|
||||
* as a Map keys (not sure about js).
|
||||
*
|
||||
* Use [Signing.pair] to create new keys.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class Key {
|
||||
abstract val packed: UByteArray
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other is Key && other.packed contentEquals packed
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return packed.contentHashCode()
|
||||
}
|
||||
|
||||
override fun toString(): String = packed.encodeToBase64Url()
|
||||
|
||||
/**
|
||||
* Public key to verify signatures only
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("pvk")
|
||||
class Verifying(override val packed: UByteArray) : Key() {
|
||||
/**
|
||||
* Verify the signature and return true if it is correct.
|
||||
*/
|
||||
fun verify(signature: UByteArray, message: UByteArray): Boolean = try {
|
||||
Signature.verifyDetached(signature, message, packed)
|
||||
true
|
||||
} catch (_: InvalidSignatureException) {
|
||||
false
|
||||
}
|
||||
|
||||
override fun toString(): String = "Pub:${super.toString()}"
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret key to sign only
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("ssk")
|
||||
class Signing(override val packed: UByteArray) : Key() {
|
||||
|
||||
val verifying: Verifying by lazy {
|
||||
Verifying(Signature.ed25519SkToPk(packed))
|
||||
}
|
||||
|
||||
fun sign(message: UByteArray): UByteArray = Signature.detached(message, packed)
|
||||
override fun toString(): String = "Sct:${super.toString()}"
|
||||
|
||||
companion object {
|
||||
data class Pair(val signing: Signing, val verifying: Verifying)
|
||||
|
||||
fun pair(): Pair {
|
||||
val p = Signature.keypair()
|
||||
return Pair(Signing(p.secretKey), Verifying(p.publicKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IllegalSignatureException: RuntimeException("signed data is tampered or signature is corrupted")
|
||||
@@ -0,0 +1,85 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package net.sergeych.crypto
|
||||
|
||||
import com.ionspin.kotlin.crypto.secretbox.SecretBox
|
||||
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
|
||||
import com.ionspin.kotlin.crypto.util.LibsodiumRandom
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
|
||||
class DecryptionFailedException : RuntimeException("can't encrypt: wrong key or tampered message")
|
||||
|
||||
@Serializable
|
||||
data class WithNonce(
|
||||
val cipherData: UByteArray,
|
||||
val nonce: UByteArray,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WithFill(
|
||||
val data: UByteArray,
|
||||
val safetyFill: UByteArray? = null
|
||||
) {
|
||||
constructor(data: UByteArray, fillSize: Int) : this(data, randomBytes(fillSize))
|
||||
}
|
||||
|
||||
fun randomBytes(n: Int): UByteArray = if (n > 0) LibsodiumRandom.buf(n) else ubyteArrayOf()
|
||||
|
||||
fun randomBytes(n: UInt): UByteArray = if (n > 0u) LibsodiumRandom.buf(n.toInt()) else ubyteArrayOf()
|
||||
|
||||
/**
|
||||
* Uniform random in `0 ..< max` range
|
||||
*/
|
||||
fun randomUInt(max: UInt) = LibsodiumRandom.uniform(max)
|
||||
fun randomUInt(max: Int) = LibsodiumRandom.uniform(max.toUInt())
|
||||
|
||||
fun <T: Comparable<T>>T.limit(range: ClosedRange<T>) = when {
|
||||
this < range.start -> range.start
|
||||
this > range.endInclusive -> range.endInclusive
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun <T: Comparable<T>>T.limitMax(max: T) = if( this < max ) this else max
|
||||
fun <T: Comparable<T>>T.limitMin(min: T) = if( this > min ) this else min
|
||||
|
||||
fun randomNonce(): UByteArray = randomBytes(crypto_secretbox_NONCEBYTES)
|
||||
|
||||
/**
|
||||
* Secret-key encrypt with authentication.
|
||||
* Generates random nonce and add some random fill to protect
|
||||
* against some analysis attacks. Nonce is included in the result. To be
|
||||
* used with [decrypt].
|
||||
* @param secretKey a _secret_ key, see [SecretBox.keygen()] or like.
|
||||
* @param plain data to encrypt
|
||||
* @param fillSize number of random fill data to add. Use random value or default.
|
||||
*/
|
||||
fun encrypt(
|
||||
secretKey: UByteArray,
|
||||
plain: UByteArray,
|
||||
fillSize: Int = randomUInt((plain.size * 3 / 10).limitMin(3)).toInt()
|
||||
): UByteArray {
|
||||
val filled = BipackEncoder.encode(WithFill(plain, fillSize))
|
||||
val nonce = randomNonce()
|
||||
val encrypted = SecretBox.easy(filled.toUByteArray(), nonce, secretKey)
|
||||
return BipackEncoder.encode(WithNonce(encrypted, nonce)).toUByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a secret-key-based message, normally encrypted with [encrypt].
|
||||
* @throws DecryptionFailedException if the key is wrong or a message is tampered with (MAC
|
||||
* check failed).
|
||||
*/
|
||||
fun decrypt(secretKey: UByteArray, cipher: UByteArray): UByteArray {
|
||||
val wn: WithNonce = BipackDecoder.decode(cipher.toDataSource())
|
||||
try {
|
||||
return BipackDecoder.decode<WithFill>(
|
||||
SecretBox.openEasy(wn.cipherData, wn.nonce, secretKey).toDataSource()
|
||||
).data
|
||||
}
|
||||
catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
|
||||
throw DecryptionFailedException()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package net.sergeych.crypto
|
||||
|
||||
import net.sergeych.bintools.toDump
|
||||
import net.sergeych.mp_tools.encodeToBase64Url
|
||||
|
||||
fun UByteArray.toDump(wide: Boolean = false) = toByteArray().toDump(wide)
|
||||
|
||||
fun UByteArray.encodeToBase64Url(): String = toByteArray().encodeToBase64Url()
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import io.ktor.utils.io.*
|
||||
|
||||
object AsyncVarint {
|
||||
suspend fun encodeUnsigned(value: ULong, output: ByteWriteChannel) {
|
||||
var rest = value
|
||||
do {
|
||||
val x = (rest and 127u).toInt()
|
||||
rest = rest shr 7
|
||||
if (rest > 0u)
|
||||
output.writeByte(x or 0x80)
|
||||
else
|
||||
output.writeByte(x)
|
||||
|
||||
} while (rest > 0u)
|
||||
}
|
||||
|
||||
suspend fun decodeUnsigned(source: ByteReadChannel): ULong {
|
||||
var result: ULong = 0u
|
||||
var count = 0
|
||||
while (true) {
|
||||
val x = source.readUByte().toInt()
|
||||
result = result or ((x and 0x7F).toULong() shl count)
|
||||
if ((x and 0x80) == 0)
|
||||
break
|
||||
count += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ByteReadChannel.readUByte(): UByte = this.readByte().toUByte()
|
||||
|
||||
suspend fun ByteWriteChannel.writeByte(byte: Int) {
|
||||
this.writeByte(byte.toByte())
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Multiplatform atomically mutable value to be used in [kotlinx.coroutines],
|
||||
* with suspending mutating operations, see [mutate].
|
||||
*
|
||||
* Actual value can be either changed in a block of [mutate] when
|
||||
* new value _depends on the current value_ or with [reset].
|
||||
*
|
||||
* [value] getter is suspended because it waits until the mutation finishes
|
||||
*/
|
||||
open class AtomicAsyncValue<T>(initialValue: T) {
|
||||
private var actualValue = initialValue
|
||||
private val access = Mutex()
|
||||
|
||||
/**
|
||||
* Change the value: get the current and set to the returned, all in the
|
||||
* atomic suspend operation. All other mutating requests including assigning to [value]
|
||||
* will be blocked and queued.
|
||||
* @return result of the mutation. Note that immediate call to property [value]
|
||||
* could already return modified bu some other thread value!
|
||||
*/
|
||||
suspend fun mutate(mutator: suspend (T) -> T): T = access.withLock {
|
||||
actualValue = mutator(actualValue)
|
||||
actualValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic get or set the value. Atomic get means if there is a [mutate] in progress
|
||||
* it will wait until the mutation finishes and then return the correct result.
|
||||
*/
|
||||
suspend fun value() = access.withLock { actualValue }
|
||||
|
||||
/**
|
||||
* Set the new value without checking it. Shortcut to
|
||||
* ```mutate { value = newValue }```
|
||||
*/
|
||||
suspend fun reset(value: T) = mutate { value }
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/** Local propagation through the encrypted RPC layers; serialized on the outer transport block. */
|
||||
internal class CallPriority(val value: Int) : AbstractCoroutineContextElement(Key) {
|
||||
companion object Key : CoroutineContext.Key<CallPriority>
|
||||
}
|
||||
|
||||
/** Larger values are sent first. Existing two-argument calls remain priority zero. */
|
||||
suspend fun <A, R> RemoteInterface.call(cmd: Command<A, R>, args: A, priority: Int): R =
|
||||
withContext(CallPriority(priority)) { call(cmd, args) }
|
||||
suspend fun <R> RemoteInterface.call(cmd: Command<Unit, R>, priority: Int): R = call(cmd, Unit, priority)
|
||||
suspend fun <A> RemoteInterface.push(cmd: Command<A, Unit>, args: A, priority: Int) =
|
||||
withContext(CallPriority(priority)) { push(cmd, args) }
|
||||
suspend fun RemoteInterface.push(cmd: Command<Unit, Unit>, priority: Int) = push(cmd, Unit, priority)
|
||||
@@ -1,13 +1,3 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -15,28 +5,15 @@ import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
import net.sergeych.kiloparsec.Command.Companion.unpackCall
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
/**
|
||||
* Typesafe command definition. Command is a universal entity in kiloparsec (and also diwan): it is used
|
||||
* Typesafe command definition. Command is a universal entity in Divan: it is used
|
||||
* 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
|
||||
* deserialize results.
|
||||
*
|
||||
* To create command instances, it is recommended to use [command] that returns [CommandDelegate].
|
||||
*
|
||||
* Use [packCall] to serialize the command call with some arguments.
|
||||
*
|
||||
* Note that `Command` instances themselves are not serialized, instead, the call is serialized,
|
||||
* in the form of [Call], containing name and properly serialized arguments.
|
||||
*
|
||||
* [unpackCall] deserialized result of the [packCall] so the proper handler for the command could
|
||||
* be used. Then the result of the execution could be packed with [exec] and then unpacked with
|
||||
* [unpackResult].
|
||||
*
|
||||
*/
|
||||
open class Command<A, R>(
|
||||
class Command<A, R>(
|
||||
val name: String,
|
||||
val argsSerializer: KSerializer<A>,
|
||||
val resultSerializer: KSerializer<R>
|
||||
@@ -44,41 +21,20 @@ open class Command<A, R>(
|
||||
@Serializable
|
||||
data class Call(val name: String,val serializedArgs: UByteArray)
|
||||
|
||||
/**
|
||||
* Pack command invocation with specified arguments.
|
||||
*/
|
||||
fun packCall(args: A): UByteArray = BipackEncoder.encode(createCall(args)).toUByteArray()
|
||||
fun packCall(args: A): UByteArray = BipackEncoder.encode(
|
||||
Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray())
|
||||
).toUByteArray()
|
||||
|
||||
/**
|
||||
* Create [Call] instance for specified args vy serializing it properly
|
||||
*/
|
||||
fun createCall(args: A): Call = Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray())
|
||||
|
||||
/**
|
||||
* Unpack result, obtained by [exec].
|
||||
*/
|
||||
fun unpackResult(packedResult: UByteArray): R =
|
||||
unpack(resultSerializer, packedResult)
|
||||
|
||||
/**
|
||||
* Execute a command unpacking args.
|
||||
*
|
||||
* @param packedArgs arguments, as provided by [packCall] in the [Call] instance
|
||||
* @param handler actual code to execute the command
|
||||
* @return properly serialized result to be unpacked with [unpackResult].
|
||||
*/
|
||||
suspend fun exec(packedArgs: UByteArray, handler: suspend (A) -> R): UByteArray =
|
||||
BipackEncoder.encode(
|
||||
resultSerializer,
|
||||
handler(
|
||||
BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer))
|
||||
handler(BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer))
|
||||
).toUByteArray()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Unpack command invocation instance from [packCall]. Use [exec] to deserialize arguments and
|
||||
* perform command.
|
||||
*/
|
||||
fun unpackCall(packedCall: UByteArray): Call = BipackDecoder.decode(packedCall.toDataSource())
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
@@ -17,9 +7,6 @@ import kotlin.reflect.KProperty
|
||||
/**
|
||||
* delegate returning function that creates a [Command] in the current context which by default has the name of
|
||||
* the property.
|
||||
*
|
||||
* The Default name is a property name except the "cmd" prefix if present, which will be
|
||||
* removed automatically.
|
||||
*/
|
||||
inline fun <reified A, reified R> command(overrideName: String? = null): CommandDelegate<A, R> {
|
||||
return CommandDelegate(
|
||||
@@ -29,33 +16,19 @@ inline fun <reified A, reified R> command(overrideName: String? = null): Command
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a Push: Unit-returning command usually used with [RemoteInterface.push]
|
||||
*/
|
||||
inline fun <reified A> push(overrideName: String? = null): CommandDelegate<A, Unit> = command(overrideName)
|
||||
|
||||
/**
|
||||
* Delegate to create [Command] via property
|
||||
*/
|
||||
class CommandDelegate<A, R>(
|
||||
private val argsSerializer: KSerializer<A>,
|
||||
private val resultSerializer: KSerializer<R>,
|
||||
private val overrideName: String? = null,
|
||||
private val overrideName: String? = null
|
||||
) {
|
||||
private var name: String = ""
|
||||
operator fun getValue(nothing: Nothing?, property: KProperty<*>): Command<A, R> {
|
||||
if (name.isEmpty()) {
|
||||
name = overrideName ?: removeCmd(property.name)
|
||||
}
|
||||
return Command(
|
||||
name,
|
||||
overrideName ?: property.name,
|
||||
argsSerializer,
|
||||
resultSerializer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeCmd(name: String) =
|
||||
if (name.startsWith("cmd"))
|
||||
name.substring(3)
|
||||
else name
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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())
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
/**
|
||||
* External errors wich needs to be encoded with a specific code
|
||||
* should implement this interface so it will be serialized properly.
|
||||
*/
|
||||
interface ExceptionWithCode {
|
||||
val code: String
|
||||
val message: String?
|
||||
}
|
||||
@@ -1,75 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
|
||||
/**
|
||||
* The auto-connecting client that reconnects to the kiloparsec server,
|
||||
* [KiloServer],
|
||||
* The auto-connecting client that reconnects to the kiloparsec server
|
||||
* and maintain connection state flow. Client factory launches a disconnected
|
||||
* 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.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
* Suppose we have TCP/IP server as in the [KiloServer] usage sample. Then we can connect
|
||||
* to it providing TCP/IP connector like:
|
||||
*
|
||||
* ```kotlin
|
||||
* val client = KiloClient<Unit>() {
|
||||
* connect { connectTcpDevice("localhost:$port") }
|
||||
* }
|
||||
*
|
||||
* // now we can invoke remote commands:
|
||||
* assertEquals("unknown", client.call(cmdLoad))
|
||||
*
|
||||
* client.call(cmdSave, "foobar")
|
||||
* assertEquals("foobar", client.call(cmdLoad))
|
||||
* ```
|
||||
*
|
||||
* ## See also
|
||||
*
|
||||
* [KiloServer]
|
||||
*
|
||||
* set of coroutines to support automatic reconnection, so you _must_ [close]
|
||||
* it manually when it is not needed, otherwise it will continue to reconnect.
|
||||
*/
|
||||
class KiloClient<S>(
|
||||
val localInterface: KiloInterface<S>,
|
||||
secretKey: SigningKey? = null,
|
||||
val options: ReconnectionOptions = ReconnectionOptions(),
|
||||
localInterface: KiloInterface<S>,
|
||||
secretKey: Key.Signing? = null,
|
||||
connectionDataFactory: ConnectionDataFactory<S>,
|
||||
) : RemoteInterface,
|
||||
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)
|
||||
|
||||
@@ -78,103 +32,42 @@ class KiloClient<S>(
|
||||
* to authenticate a client on connection restore, for example.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
val connectedStateFlow = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* The verifying, or public, key identifying client sessions. It could be used to
|
||||
* restore environment on reconnection. This is what remote side, e.g. server, sees as
|
||||
* [KiloScope.remoteIdentity].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
val localIdentity: VerifyingKey? = secretKey?.verifyingKey
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private var deferredClient = CompletableDeferred<KiloClientConnection<S>>()
|
||||
|
||||
private val defMutex = Mutex()
|
||||
|
||||
private val job =
|
||||
globalLaunch {
|
||||
debug { "starting connector" }
|
||||
var attempts = 0
|
||||
try {
|
||||
while (isActive && (options.maxAttempts == null || attempts < options.maxAttempts)) {
|
||||
attempts++
|
||||
var connectedAt: kotlin.time.TimeMark? = null
|
||||
try {
|
||||
val client = kotlinx.coroutines.withTimeout(options.connectTimeoutMillis) {
|
||||
KiloClientConnection(localInterface, connectionDataFactory(), secretKey)
|
||||
}
|
||||
deferredClient.complete(client)
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
val setupDeadline = launch {
|
||||
delay(options.connectTimeoutMillis)
|
||||
throw RemoteInterface.ClosedException()
|
||||
}
|
||||
val onState: (Boolean) -> Unit = {
|
||||
_state.value = it
|
||||
if (it) {
|
||||
connectedAt = kotlin.time.TimeSource.Monotonic.markNow()
|
||||
setupDeadline.cancel()
|
||||
}
|
||||
}
|
||||
if (options.health == null) client.run(onState)
|
||||
else client.run(options.health, onState)
|
||||
setupDeadline.cancel()
|
||||
}
|
||||
} catch (t: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
debug { "connection setup timed out" }
|
||||
} catch (t: CancellationException) { throw t }
|
||||
catch (t: Throwable) { debug { "connection ended: $t" } }
|
||||
finally {
|
||||
while (isActive) {
|
||||
try {
|
||||
debug { "getting connection" }
|
||||
val kc = connectionDataFactory()
|
||||
debug { "get device and session" }
|
||||
val client = KiloClientConnection(localInterface, kc,secretKey)
|
||||
deferredClient.complete(client)
|
||||
client.run {
|
||||
_state.value = false
|
||||
resetDeferredClient()
|
||||
}
|
||||
// Short authenticated-but-dead sessions must still exhaust the budget.
|
||||
val stableMillis = options.health?.let { 3 * (it.intervalMillis + it.timeoutMillis) } ?: 30_000L
|
||||
if ((connectedAt?.elapsedNow()?.inWholeMilliseconds ?: 0) > stableMillis) attempts = 0
|
||||
if (options.maxAttempts == null || attempts < options.maxAttempts) delay(options.retryDelayMillis)
|
||||
debug { "client run finished" }
|
||||
} catch (_: RemoteInterface.ClosedException) {
|
||||
debug { "remote closed" }
|
||||
} catch (_: CancellationException) {
|
||||
debug { "cancelled" }
|
||||
} catch (t: Throwable) {
|
||||
exception { "unexpected exception" to t }
|
||||
}
|
||||
exhausted.value = true
|
||||
} finally {
|
||||
_state.value = false
|
||||
deferredClient.completeExceptionally(RemoteInterface.ClosedException())
|
||||
if (deferredClient.isActive)
|
||||
deferredClient = CompletableDeferred()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
stopped.value = true
|
||||
job.cancel()
|
||||
debug { "client is closed" }
|
||||
}
|
||||
|
||||
private suspend fun resetDeferredClient() {
|
||||
defMutex.withLock {
|
||||
if (!deferredClient.isActive && !exhausted.value && !stopped.value) {
|
||||
deferredClient = CompletableDeferred()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R =
|
||||
try {
|
||||
deferredClient.await().call(cmd, args)
|
||||
} catch (t: RemoteInterface.ClosedException) {
|
||||
resetDeferredClient()
|
||||
throw t
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
try {
|
||||
deferredClient.await().push(cmd, args)
|
||||
} catch (t: RemoteInterface.ClosedException) {
|
||||
resetDeferredClient()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
/** Perform an encrypted liveness round trip on the active connection. */
|
||||
suspend fun ping() = deferredClient.await().ping()
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R = deferredClient.await().call(cmd, args)
|
||||
|
||||
/**
|
||||
* Current session token. This is a per-connection unique random value same on the client and server part so
|
||||
@@ -184,14 +77,14 @@ class KiloClient<S>(
|
||||
suspend fun token() = deferredClient.await().token()
|
||||
|
||||
/**
|
||||
* Remote party shared key ([VerifyingPublicKey]), could be used ti ensure server is what we expected and
|
||||
* Remote party shared key ([Key.Verifying]]), could be used ti ensure server is what we expected and
|
||||
* there is no active MITM attack.
|
||||
*
|
||||
* Non-null value means the key was successfully authenticated, null means remote party did not provide
|
||||
* a key. Connection is established either with a properly authenticated key or no key at all.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
suspend fun remoteIdentity(): VerifyingPublicKey? = deferredClient.await().remoteId()
|
||||
suspend fun remoteId() = deferredClient.await().remoteId()
|
||||
|
||||
companion object {
|
||||
class Builder<S>() {
|
||||
@@ -203,44 +96,16 @@ class KiloClient<S>(
|
||||
}
|
||||
private var connectionBuilder: (suspend () -> Transport.Device)? = null
|
||||
|
||||
var reconnectionOptions: ReconnectionOptions = ReconnectionOptions()
|
||||
|
||||
var secretIdKey: SigningKey? = null
|
||||
var secretIdKey: Key.Signing? = null
|
||||
|
||||
/**
|
||||
* Build local command implementations, those callable from the server, exception
|
||||
* class handlers, and anything else [KiloInterface] allows. Usage sample:
|
||||
*
|
||||
* ```kotlin
|
||||
* val client = KiloClient {
|
||||
* connect { connectTcpDevice("localhost:$port") }
|
||||
* local {
|
||||
* on(cmdPing) {
|
||||
* "pong! $it"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* Build local command implementations (remotely callable ones), exception
|
||||
* class handlers, etc.
|
||||
*/
|
||||
fun local(f: KiloInterface<S>.() -> Unit) {
|
||||
interfaceBuilder = f
|
||||
}
|
||||
|
||||
/**
|
||||
* Import exception (error) registrations from a shared LocalInterface (usually, server side one) to
|
||||
* DRY error registration on both server and client sides.
|
||||
* Normally you need to:
|
||||
*
|
||||
* - Register all your errors in server's LocalInterface
|
||||
*
|
||||
* - In the client builder call [addErrors(serverLocalInterface]
|
||||
*
|
||||
* See also [LocalInterface.registerError] for exception registration
|
||||
*/
|
||||
fun addErrors(from: LocalInterface<*>) {
|
||||
errorProviders += from
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session object, otherwise Unit session will be used
|
||||
*/
|
||||
@@ -252,15 +117,12 @@ class KiloClient<S>(
|
||||
connectionBuilder = f
|
||||
}
|
||||
|
||||
val errorProviders = mutableListOf<LocalInterface<*>>()
|
||||
|
||||
internal fun build(): KiloClient<S> {
|
||||
val i = KiloInterface<S>()
|
||||
for (ep in errorProviders) i.addErrorProvider(ep)
|
||||
interfaceBuilder?.let { i.it() }
|
||||
val connector = connectionBuilder ?: throw IllegalArgumentException("connect handler was not set")
|
||||
return KiloClient(i, secretIdKey, reconnectionOptions) {
|
||||
KiloConnectionData(connector(), sessionBuilder())
|
||||
return KiloClient(i,secretIdKey) {
|
||||
KiloConnectionData(connector(),sessionBuilder())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,158 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
|
||||
import kotlinx.coroutines.*
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private var clientIds = 0
|
||||
|
||||
class KiloClientConnection<S>(
|
||||
private val clientInterface: KiloInterface<S>,
|
||||
private val clientInterface: LocalInterface<KiloScope<S>>,
|
||||
private val device: Transport.Device,
|
||||
private val session: S,
|
||||
private val secretIdKey: SigningKey? = null,
|
||||
private val secretIdKey: Key.Signing? = null,
|
||||
) : RemoteInterface, Loggable by LogTag("KPC:${++clientIds}") {
|
||||
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: SigningKey? = null)
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, secretIdKey: Key.Signing? = null)
|
||||
: this(localInterface, connection.device, connection.session, secretIdKey)
|
||||
|
||||
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
|
||||
|
||||
private val deferredParams = CompletableDeferred<KiloParams<S>>()
|
||||
|
||||
suspend fun remoteId(): VerifyingPublicKey? = deferredParams.await().remoteIdentity
|
||||
suspend fun remoteId(): Key.Verifying? = deferredParams.await().remoteIdentity
|
||||
|
||||
/**
|
||||
* Run the client, blocking until the device is closed, or some critical exception
|
||||
* 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.
|
||||
*/
|
||||
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)?) {
|
||||
suspend fun run(onConnectedStateChanged: ((Boolean) -> Unit)? = null) {
|
||||
coroutineScope {
|
||||
var job: Job? = null
|
||||
var connectedScope: KiloScope<S>? = null
|
||||
try {
|
||||
// in parallel: keys and connection
|
||||
val deferredKeyPair = async { SafeKeyExchange() }
|
||||
val deferredKeyPair = async { KeyExchange.keypair() }
|
||||
debug { "opening device" }
|
||||
debug { "got a transport device $device" }
|
||||
|
||||
|
||||
// client transport has no dedicated commands (unlike the server's),
|
||||
// it is a calling party:
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams, allowHandshakeCallbacks = true)
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams)
|
||||
val transport = Transport(device, l0Interface, Unit)
|
||||
|
||||
job = launch { transport.run() }
|
||||
debug { "transport started" }
|
||||
|
||||
val pair = deferredKeyPair.await()
|
||||
debug { "keypair ready (1)" }
|
||||
debug { "keypair ready" }
|
||||
|
||||
val serverHe = transport.call(L0Request, Handshake(KILO_PROTOCOL_VERSION, pair.publicKey))
|
||||
val serverHe = transport.call(L0Request, Handshake(1u, 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 = KeyExchange.clientSessionKeys(pair.publicKey, pair.secretKey, serverHe.publicKey)
|
||||
var params = KiloParams(false, transport, sk, session, null, this@KiloClientConnection)
|
||||
|
||||
// Check ID if any
|
||||
serverHe.signature?.let { s ->
|
||||
if (!s.isValid(serverAuthentication(params.token)))
|
||||
serverHe.serverSharedKey?.let { k ->
|
||||
if (serverHe.signature == null)
|
||||
throw RemoteInterface.SecurityException("missing signature")
|
||||
if (!k.verify(serverHe.signature, params.token))
|
||||
throw RemoteInterface.SecurityException("wrong signature")
|
||||
params = params.authenticated(s.publicKey)
|
||||
params = params.copy(remoteIdentity = k)
|
||||
}
|
||||
|
||||
val identity = pack(ClientIdentity(secretIdKey?.verifyingKey,
|
||||
secretIdKey?.sign(clientAuthentication(params.token))))
|
||||
val finished = unpack<ProtectedMessage>(transport.callWithId(L0ClientId,
|
||||
readResponse = params::decrypt) { params.encode(identity) })
|
||||
if (finished.kind != ProtectedKind.SERVER_FINISHED || finished.correlation != 0uL ||
|
||||
finished.priority != 0 || !finished.payload.contentEquals(identity))
|
||||
throw RemoteInterface.SecurityException("invalid server finished")
|
||||
transport.call(
|
||||
L0ClientId, params.encrypt(
|
||||
pack(
|
||||
ClientIdentity(
|
||||
secretIdKey?.verifying,
|
||||
secretIdKey?.sign(params.token)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
deferredParams.complete(params)
|
||||
kiloRemoteInterface.complete(
|
||||
KiloRemoteInterface(deferredParams, clientInterface)
|
||||
)
|
||||
connectedScope = params.scope
|
||||
clientInterface.onConnectHandlers.invokeAll(connectedScope)
|
||||
onConnectedStateChanged?.invoke(true)
|
||||
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() }
|
||||
job.join()
|
||||
|
||||
} catch (x: CancellationException) {
|
||||
info { "client is cancelled" }
|
||||
} catch (x: RemoteInterface.ClosedException) {
|
||||
debug { "connection closed/refused by remote" }
|
||||
info { "connection closed by remote" }
|
||||
} finally {
|
||||
if (!deferredParams.isCompleted) deferredParams.completeExceptionally(RemoteInterface.ClosedException())
|
||||
if (!kiloRemoteInterface.isCompleted) kiloRemoteInterface.completeExceptionally(RemoteInterface.ClosedException())
|
||||
connectedScope?.let { clientInterface.onDisconnectHandlers.invokeAll(it) }
|
||||
onConnectedStateChanged?.invoke(false)
|
||||
withContext(NonCancellable) {
|
||||
job?.cancelAndJoin()
|
||||
device.apply { runCatching { close() } }
|
||||
}
|
||||
job?.cancel()
|
||||
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
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R =
|
||||
kiloRemoteInterface.await().call(cmd, args)
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
kiloRemoteInterface.await().push(cmd, args)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <S>Collection<KiloHandler<S>>.invokeAll(scope: KiloScope<S>) =
|
||||
forEach { handler ->
|
||||
try {
|
||||
scope.handler()
|
||||
} catch (e: LocalInterface.BreakConnectionException) {
|
||||
// Includes SecurityException and application-defined rejection subclasses.
|
||||
throw e
|
||||
} catch (_: Throwable) {
|
||||
// Preserve best-effort notification for other handler failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,14 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
typealias KiloHandler<S> = KiloScope<S>.()->Unit
|
||||
/**
|
||||
* The local interface to provide functions, register errors for Kiloparsec users. Use it
|
||||
* The local interface to provice functions, register errors for Kiloparsec users. Use it
|
||||
* with [KiloClient], [KiloClientConnection], [KiloServerConnection], etc.
|
||||
*
|
||||
* Base class implementation does the following:
|
||||
*
|
||||
* - It registers common exceptions from [RemoteInterface] and kotlin/java `IllegalArgumentException` and
|
||||
* `IllegalStateException`
|
||||
* - It provides [onConnected] and [onDisconnected] handlers
|
||||
*
|
||||
* See [KiloServer] for usage sample.
|
||||
* BAse implementation registers relevant exceptions.
|
||||
*/
|
||||
open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
||||
|
||||
internal val onConnectHandlers = mutableListOf<KiloHandler<S>>()
|
||||
internal val onDisconnectHandlers = mutableListOf<KiloHandler<S>>()
|
||||
|
||||
/**
|
||||
* Registers handler [f] for [onConnected] event, to the head or the end of a handler list.
|
||||
*
|
||||
* @param addFirst if true, [f] will be added to the beginning of the list of handlers
|
||||
*/
|
||||
fun onConnected(addFirst: Boolean = false, f: KiloScope<S>.()->Unit) {
|
||||
if( addFirst ) onConnectHandlers.add(0, f) else onConnectHandlers += f
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers handler [f] for [onDisconnected] event, to the head or the end of a handler list.
|
||||
*
|
||||
* It is called with the same connection [KiloScope] as [onConnected], after an established
|
||||
* connection is closed.
|
||||
*
|
||||
* @param addFirst if true, [f] will be added to the beginning of the list of handlers
|
||||
*/
|
||||
fun onDisconnected(addFirst: Boolean = false, f: KiloScope<S>.()->Unit) {
|
||||
if( addFirst ) onDisconnectHandlers.add(0, f) else onDisconnectHandlers += f
|
||||
}
|
||||
|
||||
class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
||||
init {
|
||||
registerError { RemoteInterface.UnknownCommand(it) }
|
||||
registerError { RemoteInterface.InternalError(it) }
|
||||
registerError { RemoteInterface.UnknownCommand() }
|
||||
registerError { RemoteInterface.ClosedException(it) }
|
||||
registerError { RemoteInterface.SecurityException(it) }
|
||||
registerError { RemoteInterface.InvalidDataException(it) }
|
||||
@@ -60,3 +17,4 @@ open class KiloInterface<S> : LocalInterface<KiloScope<S>>() {
|
||||
registerError { IllegalArgumentException(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,36 @@
|
||||
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private val idCounter = AtomicCounter(0)
|
||||
|
||||
/** 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>(
|
||||
private val clientInterface: LocalInterface<KiloScope<T>>,
|
||||
private val deferredParams: CompletableDeferred<KiloParams<T>>,
|
||||
private val allowHandshakeCallbacks: Boolean = false,
|
||||
) : LocalInterface<Unit>() {
|
||||
override var logTag: String = "KL0:${idCounter.incrementAndGet()}"
|
||||
init { addErrorProvider(clientInterface) }
|
||||
// Plaintext transport errors must never use application exception constructors.
|
||||
override fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? = null
|
||||
): LocalInterface<Unit>() {
|
||||
init {
|
||||
// local interface uses the same session as a client:
|
||||
addErrorProvider(clientInterface)
|
||||
|
||||
override suspend fun executeTransportBlock(scope: Unit, block: Transport.Block.Call): PreparedPayload {
|
||||
if (block.name != L0Call.name) return super.executeTransportBlock(scope, block)
|
||||
if (!allowHandshakeCallbacks && !deferredParams.isCompleted)
|
||||
throw RemoteInterface.SecurityException("application call before authentication")
|
||||
val params = deferredParams.await()
|
||||
val frame: ProtectedMessage = try { params.decryptMessage(unpack(block.packedArgs)) }
|
||||
catch (e: DiscardPacket) { throw e }
|
||||
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid authenticated call") }
|
||||
val expected = if (block.id == 0u) ProtectedKind.PUSH else ProtectedKind.CALL
|
||||
if (frame.kind != expected || frame.correlation != block.id.toULong() || frame.priority != block.priority)
|
||||
throw RemoteInterface.SecurityException("authenticated routing mismatch")
|
||||
val call = try { Command.unpackCall(frame.payload) }
|
||||
catch (e: Throwable) { throw RemoteInterface.SecurityException("invalid command encoding") }
|
||||
val result: Transport.Block = try {
|
||||
Transport.Block.Response(0u,
|
||||
if (call.name == connectionPing.name) connectionPing.exec(call.serializedArgs) { it }
|
||||
else clientInterface.execute(params.scope, call.name, call.serializedArgs))
|
||||
} catch (e: BreakConnectionException) { throw e }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Throwable) { clientInterface.encodeError(0u, e) }
|
||||
// Push has no reply; its authenticated kind cannot be changed by rewriting id=0.
|
||||
if (frame.kind == ProtectedKind.PUSH) return 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)
|
||||
on(L0Call) {
|
||||
val params = deferredParams.await()
|
||||
val call = Command.unpackCall(params.decrypt(it))
|
||||
// tricky part: we need to encrypt a result or error: we can't allow default
|
||||
// error transport (as it is not encrypted), so we re-use transport blocks here:
|
||||
val result: Transport.Block = try {
|
||||
Transport.Block.Response(
|
||||
0u,
|
||||
clientInterface.execute(params.scope, call.name, call.serializedArgs)
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
clientInterface.encodeError(0u, t)
|
||||
}
|
||||
params.encrypt(pack(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchangeSessionKeyPair
|
||||
import com.ionspin.kotlin.crypto.secretbox.SecretBox
|
||||
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
|
||||
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
|
||||
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.bintools.toDataSource
|
||||
import net.sergeych.bipack.BipackDecoder
|
||||
import net.sergeych.bipack.Unsigned
|
||||
import net.sergeych.crypto2.*
|
||||
import net.sergeych.synctools.ProtectedOp
|
||||
import net.sergeych.synctools.invoke
|
||||
import net.sergeych.bipack.BipackEncoder
|
||||
import net.sergeych.crypto.DecryptionFailedException
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.crypto.randomBytes
|
||||
import net.sergeych.crypto.randomUInt
|
||||
import net.sergeych.tools.ProtectedOp
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
import org.komputing.khash.keccak.Keccak
|
||||
import org.komputing.khash.keccak.KeccakParameter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -32,83 +32,105 @@ import kotlin.math.roundToInt
|
||||
data class KiloParams<S>(
|
||||
val isServer: Boolean,
|
||||
val transport: RemoteInterface,
|
||||
val sessionKey: SafeKeyExchange.SessionKey,
|
||||
val sessionKeyPair: KeyExchangeSessionKeyPair,
|
||||
val scopeSession: S,
|
||||
val remoteIdentity: VerifyingPublicKey?,
|
||||
val remoteTransport: RemoteInterface,
|
||||
val remoteIdentity: Key.Verifying?,
|
||||
val remoteTransport: RemoteInterface
|
||||
) {
|
||||
@Serializable
|
||||
data class Package(
|
||||
@Unsigned
|
||||
val nonce: ULong,
|
||||
val encryptedMessage: UByteArray,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FilledData(
|
||||
val message: UByteArray,
|
||||
val fill: UByteArray,
|
||||
)
|
||||
|
||||
private var nonce = 0UL
|
||||
private var received = ReplayWindow()
|
||||
|
||||
val scope: KiloScope<S> by lazy {
|
||||
object : KiloScope<S> {
|
||||
override val session = scopeSession
|
||||
override val remote: RemoteInterface = remoteTransport
|
||||
override val sessionToken: UByteArray = token
|
||||
override val remoteIdentity: VerifyingPublicKey? = this@KiloParams.remoteIdentity
|
||||
override val remoteIdentity: Key.Verifying? = this@KiloParams.remoteIdentity
|
||||
}
|
||||
}
|
||||
|
||||
val token: UByteArray by lazy {
|
||||
blake2b("token_".encodeToUByteArray() + sessionKey.sessionTag).sliceArray(0..<SymmetricKey.nonceLength)
|
||||
val base = if (isServer) sessionKeyPair.sendKey + sessionKeyPair.receiveKey
|
||||
else sessionKeyPair.receiveKey + sessionKeyPair.sendKey
|
||||
Keccak.digest(
|
||||
base.toByteArray(), KeccakParameter.KECCAK_256
|
||||
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
|
||||
}
|
||||
|
||||
private val numericNonce = NumericNonce(token)
|
||||
private val sendBase by lazy {
|
||||
Keccak.digest(
|
||||
sessionKeyPair.sendKey.toByteArray(), KeccakParameter.KECCAK_256
|
||||
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
|
||||
}
|
||||
|
||||
private val protectedOp = ProtectedOp()
|
||||
private val receiveBase by lazy {
|
||||
Keccak.digest(
|
||||
sessionKeyPair.receiveKey.toByteArray(), KeccakParameter.KECCAK_256
|
||||
).toUByteArray().sliceArray(0..<crypto_secretbox_NONCEBYTES)
|
||||
}
|
||||
|
||||
private inline fun encodeNonce(base: UByteArray, nonce: ULong): UByteArray {
|
||||
val result = base.copyOf()
|
||||
var x = nonce
|
||||
var i = 0
|
||||
while (x > 0u) {
|
||||
result[i] = result[i] xor (x and 0xFFu).toUByte()
|
||||
x = x shr 8
|
||||
i++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private inline fun encodeSendNonce(nonce: ULong): UByteArray = encodeNonce(sendBase, nonce)
|
||||
private inline fun encodeReceiveNonce(nonce: ULong): UByteArray = encodeNonce(receiveBase, nonce)
|
||||
|
||||
|
||||
fun encrypt(plainText: String): UByteArray = encrypt(plainText.encodeToUByteArray())
|
||||
|
||||
private val proptectedOp = ProtectedOp()
|
||||
|
||||
/**
|
||||
* Encrypt using send keys and proper nonce
|
||||
*/
|
||||
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)
|
||||
0..(message.size * fillFactor).roundToInt()
|
||||
fun encrypt(message: UByteArray, fillFactor: Float = 0f): UByteArray {
|
||||
val fill: UByteArray = if (fillFactor > 0f)
|
||||
randomBytes(randomUInt((message.size * fillFactor).roundToInt()))
|
||||
else
|
||||
null
|
||||
ubyteArrayOf()
|
||||
|
||||
val n = protectedOp.invoke {
|
||||
if (nonce == ULong.MAX_VALUE) throw RemoteInterface.SecurityException("session nonce exhausted")
|
||||
nonce++
|
||||
}
|
||||
val withFill = BipackEncoder.encode(FilledData(message, fill)).toUByteArray()
|
||||
|
||||
return OutgoingData(pack(
|
||||
Package(n, sessionKey.encryptWithNonce(message, numericNonce.withULong(n), fill))
|
||||
), n)
|
||||
val n = proptectedOp { nonce++ }
|
||||
|
||||
return pack(
|
||||
Package(n, SecretBox.easy(withFill, encodeSendNonce(n), sessionKeyPair.sendKey))
|
||||
)
|
||||
}
|
||||
|
||||
/** Authenticate and consume one incoming nonce, allowing bounded out-of-order delivery. */
|
||||
fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray()
|
||||
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
|
||||
val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource())
|
||||
try {
|
||||
return unpack<FilledData>(
|
||||
SecretBox.openEasy(
|
||||
p.encryptedMessage,
|
||||
encodeReceiveNonce(p.nonce),
|
||||
sessionKeyPair.receiveKey
|
||||
)
|
||||
).message
|
||||
} catch (_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
|
||||
throw DecryptionFailedException()
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity completion must not reset counters/replay history already used by the handshake. */
|
||||
internal fun authenticated(identity: VerifyingPublicKey): KiloParams<S> = protectedOp.invoke {
|
||||
copy(remoteIdentity = identity).also {
|
||||
it.nonce = nonce
|
||||
it.received = received.copy()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun encodeMessage(message: ProtectedMessage): OutgoingData = encode(pack(message))
|
||||
|
||||
internal fun encryptMessage(message: ProtectedMessage): UByteArray = encrypt(pack(message))
|
||||
internal fun decryptMessage(message: UByteArray): ProtectedMessage =
|
||||
net.sergeych.utools.unpack(decrypt(message))
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/* 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,63 +1,33 @@
|
||||
/* Copyright (c) 2026 Sergey S. Chernov. Private license; see README. */
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
/** Full-duplex remote interface with authenticated request/result correlation. */
|
||||
private var L1IdCounter = 0
|
||||
|
||||
// todo: We don't need it deferred here
|
||||
class KiloRemoteInterface<S>(
|
||||
private val deferredParams: CompletableDeferred<KiloParams<S>>,
|
||||
private val clientInterface: LocalInterface<KiloScope<S>>,
|
||||
) : RemoteInterface {
|
||||
) : RemoteInterface, Loggable by LogTag("L1TR:${++L1IdCounter}") {
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
|
||||
val params = deferredParams.await()
|
||||
val transport = params.transport as Transport<*>
|
||||
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
|
||||
var requestId = 0u
|
||||
val call = cmd.packCall(args)
|
||||
val encoded = try {
|
||||
transport.callWithId(L0Call, readResponse = params::decrypt) { id ->
|
||||
requestId = id
|
||||
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")
|
||||
}
|
||||
val block: Transport.Block = unpack(
|
||||
params.decrypt(
|
||||
params.transport.call(L0Call, params.encrypt(cmd.packCall(args)))
|
||||
)
|
||||
)
|
||||
return when (block) {
|
||||
is Transport.Block.Response -> cmd.unpackResult(block.packedResult)
|
||||
is Transport.Block.Response -> {
|
||||
cmd.unpackResult(block.packedResult)
|
||||
}
|
||||
|
||||
is Transport.Block.Error -> clientInterface.decodeAndThrow(block)
|
||||
else -> error("unreachable")
|
||||
}
|
||||
}
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
val params = deferredParams.await()
|
||||
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
|
||||
val payload = cmd.packCall(args)
|
||||
(params.transport as Transport<*>).pushPrepared(L0Call.name, priority) {
|
||||
params.encodeMessage(ProtectedMessage(ProtectedKind.PUSH, 0uL, priority, payload))
|
||||
else -> throw RemoteInterface.Exception("unexpected block type: $block")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.crypto.Key
|
||||
|
||||
/**
|
||||
* Scope for Kiloparsec client/server commands execution, contain per-connection specific data. The scope
|
||||
@@ -37,8 +26,8 @@ interface KiloScope<S> {
|
||||
val sessionToken: UByteArray
|
||||
|
||||
/**
|
||||
* If the remote part has provided a secret key, e.g., gave non-null [SigningKey] on construction,
|
||||
* the kiloparsec checks it in the MITM-safe way and provides its [VerifyingPublicKey] shared key here.
|
||||
* If the remote part has provided a secret key, e.g., gave non-null [Key.Signing] on construction,
|
||||
* the kiloparsec checks it in the MITM-safe way and provides its [Key.Verifying] shared key here.
|
||||
* Knowing a remote party shared key, it is possible to be sure that the connection is made directly
|
||||
* to this party with no middle point intruders.
|
||||
*
|
||||
@@ -48,6 +37,6 @@ interface KiloScope<S> {
|
||||
* In spite of the above said, which means, non-null value in this field means the key is authorized, but
|
||||
* It is up to the caller to ensure it is expected key of the remote party.
|
||||
*/
|
||||
val remoteIdentity: VerifyingPublicKey?
|
||||
val remoteIdentity: Key.Verifying?
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
|
||||
private val instances = AtomicCounter()
|
||||
|
||||
/**
|
||||
* The Kiloparsec server.
|
||||
* Server accepts incoming connections and serves them using the same [clientInterface].
|
||||
*
|
||||
* ## Incoming connections
|
||||
*
|
||||
* Server collecting incoming connections provided by [connections] `Flow`. For each incoming connection
|
||||
* the Kiloparsec handshake is performed, then the session object is created, see below, and connection is
|
||||
* served with [clientInterface] until closed.
|
||||
*
|
||||
* ## Session param [S]
|
||||
*
|
||||
* After the successful handshake server creates new session for each connection calling the [sessionBuilder].
|
||||
* Then it creates a [KiloScope] so [KiloScope.session] holds this connection-specific instance. Then
|
||||
* [KiloInterface.onConnected] is called with this scope. Session can be used to hold connection state. Session
|
||||
* objects are not persistent, but could be initialized in [KiloInterface.onConnected] where the remote side
|
||||
* [KiloScope.remoteIdentity] is already verified and set.
|
||||
*
|
||||
* ## Usage:
|
||||
*
|
||||
* Create a shared library between you server and clients, to specify the interface (otherwise you can
|
||||
* share sources).
|
||||
*
|
||||
* Suppose we have session with a state:
|
||||
* ```kotlin
|
||||
* data class Session(
|
||||
* var data: String,
|
||||
* )
|
||||
*```
|
||||
*
|
||||
* And some commands to access and change it, in the shared library too:
|
||||
*
|
||||
* ```kotlin
|
||||
* val cmdSave by command<String, Unit>()
|
||||
* val cmdLoad by command<Unit, String>()
|
||||
* val cmdDrop by command<Unit, Unit>()
|
||||
* val cmdException by command<Unit, Unit>()
|
||||
* ```
|
||||
* Then the server code (TCP/IP variant) could look like:
|
||||
*
|
||||
* ```kotlin
|
||||
* // The server implementation (could be shared between server instances connected
|
||||
* // to different protocol adapters):
|
||||
*
|
||||
* val cli = KiloInterface<Session>().apply {
|
||||
* // Suppose we want to throw this exception at the caller site, so we need to register it:
|
||||
* registerError { SomeException() }
|
||||
*
|
||||
* // Session initialization. If you need a sessino to depend initially on the client's identity.
|
||||
* // you can do it here:
|
||||
* onConnected {
|
||||
* // check the remoteIdentity
|
||||
* session.data = if( remoteIdentity == somePublicVerifyingKey )
|
||||
* "known"
|
||||
* else
|
||||
* "unknown"
|
||||
* }
|
||||
* on(cmdSave) { session.data = it }
|
||||
* on(cmdLoad) {
|
||||
* session.data
|
||||
* }
|
||||
* on(cmdException) {
|
||||
* throw TestException()
|
||||
* }
|
||||
* on(cmdDrop) {
|
||||
* throw LocalInterface.BreakConnectionException()
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Create the server instance that accepts incoming TCP/IP connections on all local interfaces using the
|
||||
* // specified port:
|
||||
*
|
||||
* val server = KiloServer(cli, acceptTcpDevice(port)) {
|
||||
* // This creates a new session
|
||||
* Session("unknown")
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* See [KiloClient] to connect to the server.
|
||||
*
|
||||
* @param S the type of the server session object, returned by [sessionBuilder]. See above
|
||||
* @param clientInterface the interface available for remote calls
|
||||
* @param connections flow of incoming connections. Server stops when the flow is fully collected (normally
|
||||
* it shouldn't)
|
||||
* @param serverSecretKey the [SigningKey] used to identify this server during Kiloparsec handshake.
|
||||
* @param sessionBuilder callback that creates session objects for successful incoming connections
|
||||
*/
|
||||
class KiloServer<S>(
|
||||
private val clientInterface: KiloInterface<S>,
|
||||
private val connections: Flow<Transport.Device>,
|
||||
private val serverSecretKey: SigningKey? = null,
|
||||
private val sessionBuilder: () -> S,
|
||||
) : LogTag("KS:${instances.incrementAndGet()}") {
|
||||
|
||||
private val job = globalLaunch {
|
||||
connections.collect { device ->
|
||||
launch {
|
||||
try {
|
||||
info { "connected ${device}" }
|
||||
KiloServerConnection(clientInterface, device, sessionBuilder(), serverSecretKey)
|
||||
.apply { debug { "server connection is ready" } }
|
||||
.run()
|
||||
} catch (_: CancellationException) {
|
||||
} catch (cce: LocalInterface.BreakConnectionException) {
|
||||
info { "Closed exception caught, closing (${cce.flushSendQueue}" }
|
||||
} catch (t: Throwable) {
|
||||
exception { "unexpected while creating kiloclient" to t }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server and cancel all pending sessions. Unlike finishing the flow passed
|
||||
* for [KiloServer.connections], it will cancel all currently active sessions.
|
||||
*/
|
||||
fun close() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Server is closed either if [close] was called or all [KiloServer.connections] were collected and flow was
|
||||
* closed.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
val isClosed: Boolean get() = job.isCompleted
|
||||
}
|
||||
@@ -1,20 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.debug
|
||||
@@ -35,15 +23,15 @@ class KiloServerConnection<S>(
|
||||
private val clientInterface: KiloInterface<S>,
|
||||
private val device: Transport.Device,
|
||||
private val session: S,
|
||||
private val serverSigningKey: SigningKey? = null
|
||||
private val serverSigningKey: Key.Signing? = null
|
||||
) : RemoteInterface, Loggable by LogTag("SRV${++serverIds}") {
|
||||
|
||||
/**
|
||||
* Shortcut to construct with [KiloConnectionData] intance
|
||||
*/
|
||||
@Suppress("unused")
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSecretKey: SigningKey? = null)
|
||||
: this(localInterface, connection.device, connection.session, serverSecretKey)
|
||||
constructor(localInterface: KiloInterface<S>, connection: KiloConnectionData<S>, serverSigningKey: Key.Signing? = null)
|
||||
: this(localInterface, connection.device, connection.session, serverSigningKey)
|
||||
|
||||
private val kiloRemoteInterface = CompletableDeferred<KiloRemoteInterface<S>>()
|
||||
|
||||
@@ -55,66 +43,62 @@ class KiloServerConnection<S>(
|
||||
suspend fun run() {
|
||||
val deferredParams = CompletableDeferred<KiloParams<S>>()
|
||||
val deferredTransport = CompletableDeferred<Transport<*>>()
|
||||
var connectedScope: KiloScope<S>? = null
|
||||
|
||||
val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply {
|
||||
var params: KiloParams<S>? = null
|
||||
val handshakeLock = Mutex()
|
||||
var established = false
|
||||
val pair = SafeKeyExchange()
|
||||
on(L0Request) { request -> handshakeLock.withLock {
|
||||
if (request.version != KILO_PROTOCOL_VERSION || params != null || established)
|
||||
throw RemoteInterface.SecurityException("unsupported or repeated handshake")
|
||||
val sk = pair.serverSessionKey(request.publicKey)
|
||||
params = KiloParams(true, deferredTransport.await(), sk, session, null, this@KiloServerConnection)
|
||||
Handshake(KILO_PROTOCOL_VERSION, pair.publicKey,
|
||||
serverSigningKey?.seal(serverAuthentication(params!!.token)))
|
||||
} }
|
||||
on(L0Request) {
|
||||
val sk = KeyExchange.serverSessionKeys(
|
||||
pair.publicKey, pair.secretKey, it.publicKey
|
||||
)
|
||||
|
||||
onPrepared(L0ClientId) { encoded -> handshakeLock.withLock {
|
||||
var p = params ?: throw RemoteInterface.SecurityException("wrong handshake sequence")
|
||||
val identity = p.decrypt(encoded)
|
||||
if (established) throw RemoteInterface.SecurityException("repeated client identity")
|
||||
val ci = unpack<ClientIdentity>(identity)
|
||||
if ((ci.clientIdKey == null) != (ci.signature == null))
|
||||
throw RemoteInterface.SecurityException("incomplete client identity")
|
||||
if (ci.clientIdKey != null) {
|
||||
if (!ci.clientIdKey.verify(ci.signature!!, clientAuthentication(p.token)))
|
||||
params = KiloParams(
|
||||
true,
|
||||
deferredTransport.await(),
|
||||
sk,
|
||||
session,
|
||||
null,
|
||||
this@KiloServerConnection
|
||||
)
|
||||
|
||||
var verifying: Key.Verifying? = null
|
||||
var signature: UByteArray? = null
|
||||
if( serverSigningKey != null ) {
|
||||
verifying = serverSigningKey.verifying
|
||||
signature = serverSigningKey.sign(params!!.token)
|
||||
}
|
||||
Handshake(1u, pair.publicKey, verifying, signature)
|
||||
}
|
||||
on(L0ClientId) {
|
||||
var p = params ?: throw RemoteInterface.ClosedException("wrong handshake sequence")
|
||||
val ci = unpack<ClientIdentity>(p.decrypt(it))
|
||||
if( ci.clientIdKey != null ) {
|
||||
if( ci.signature == null )
|
||||
throw RemoteInterface.SecurityException("missing signature")
|
||||
if( !ci.clientIdKey.verify(ci.signature, params!!.token))
|
||||
throw RemoteInterface.SecurityException("wrong signature")
|
||||
p = p.authenticated(ci.clientIdKey)
|
||||
p = p.copy(remoteIdentity = ci.clientIdKey)
|
||||
params = p
|
||||
}
|
||||
established = true
|
||||
deferredParams.complete(p)
|
||||
kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams, clientInterface))
|
||||
connectedScope = p.scope
|
||||
clientInterface.onConnectHandlers.invokeAll(connectedScope)
|
||||
PreparedPayload {
|
||||
val result = p.encodeMessage(ProtectedMessage(ProtectedKind.SERVER_FINISHED, 0uL, 0, identity))
|
||||
OutgoingData(net.sergeych.utools.pack(result.bytes), result.nonce)
|
||||
}
|
||||
} }
|
||||
|
||||
kiloRemoteInterface.complete(
|
||||
KiloRemoteInterface(deferredParams, clientInterface)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val transport = Transport(device, l0Interface, Unit)
|
||||
deferredTransport.complete(transport)
|
||||
kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams,clientInterface))
|
||||
debug { "starting the transport"}
|
||||
try {
|
||||
transport.run()
|
||||
debug { "server transport finished" }
|
||||
} finally {
|
||||
connectedScope?.let { clientInterface.onDisconnectHandlers.invokeAll(it) }
|
||||
}
|
||||
debug { "starintg the transport"}
|
||||
transport.run()
|
||||
debug { "server transport finished" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val pair = KeyExchange.keypair()
|
||||
}
|
||||
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R {
|
||||
return kiloRemoteInterface.await().call(cmd, args)
|
||||
}
|
||||
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) {
|
||||
kiloRemoteInterface.await().push(cmd, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.Loggable
|
||||
import net.sergeych.mp_logger.exception
|
||||
import net.sergeych.mp_logger.info
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.firstNonNull
|
||||
import net.sergeych.utools.unpack
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
private typealias RawCommandHandler<C> = suspend (C, UByteArray) -> UByteArray
|
||||
|
||||
private val idCounter = AtomicCounter()
|
||||
|
||||
open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.incrementAndGet()}") {
|
||||
open class LocalInterface<S> {
|
||||
|
||||
private val commands = mutableMapOf<String, RawCommandHandler<S>>()
|
||||
|
||||
/**
|
||||
* Instruct the transport to immediately break the connection.
|
||||
* This exception is not passed to the remote end, instead, transport device breaks
|
||||
* connection to remote when receiving it.
|
||||
*
|
||||
* Remote interface will throw [RemoteInterface.ClosedException] as the break will be detected. As reaction time
|
||||
* it depends on the transport in use, we recommend sending some registered exception first if you need
|
||||
* to pass important data, or implement special commands on both sides.
|
||||
*
|
||||
* __Important note:__ _it is not allowed to throw [RemoteInterface.ClosedException] directly!_
|
||||
* This exception is processed internally and can't be sent over the network.
|
||||
*/
|
||||
open class BreakConnectionException(
|
||||
text: String = "break connection request",
|
||||
val flushSendQueue: Boolean = true,
|
||||
) : RuntimeException(text) {
|
||||
override val message: String?
|
||||
get() = super.message + " (flush=$flushSendQueue)"
|
||||
}
|
||||
|
||||
/**
|
||||
* New session creator. Rarely needed directlym it can be used for delegation
|
||||
* of local interfaces.
|
||||
@@ -73,38 +35,19 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
|
||||
}
|
||||
}
|
||||
|
||||
/** Override to gate dispatch before command lookup and argument deserialization. */
|
||||
open suspend fun execute(
|
||||
suspend fun execute(
|
||||
scope: S,
|
||||
name: String,
|
||||
packedArgs: UByteArray,
|
||||
): UByteArray =
|
||||
(commands[name] ?: throw RemoteInterface.UnknownCommand(name))
|
||||
(commands[name] ?: throw RemoteInterface.UnknownCommand())
|
||||
.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 errorBuilder = mutableMapOf<String, (String, UByteArray?) -> Throwable>()
|
||||
|
||||
/**
|
||||
* Register exception for automatic transmission over the kiloparsec connection using its [klass]
|
||||
* and possibly override code. In most cases it is simpler to use [registerError].
|
||||
*/
|
||||
fun <T : Throwable> registerErrorClass(
|
||||
fun <T : Throwable> registerError(
|
||||
klass: KClass<T>, code: String = klass.simpleName!!,
|
||||
exceptionBuilder: (String, UByteArray?) -> T,
|
||||
) {
|
||||
@@ -112,17 +55,10 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
|
||||
errorBuilder[code] = exceptionBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an exception to be transmitted automatically over the kiloparsec connection.
|
||||
* Example:
|
||||
* ```kotlin
|
||||
* localInterface.registerError { IllegalArgumentException(it) }
|
||||
* ```
|
||||
*/
|
||||
inline fun <reified T : Throwable> registerError(
|
||||
noinline exceptionBuilder: (String) -> T,
|
||||
) {
|
||||
registerErrorClass(T::class) { msg, _ -> exceptionBuilder(msg) }
|
||||
registerError(T::class) { msg, _ -> exceptionBuilder(msg) }
|
||||
}
|
||||
|
||||
val errorProviders = mutableListOf<LocalInterface<*>>()
|
||||
@@ -132,26 +68,18 @@ open class LocalInterface<S> : Loggable by LogTag("LocalInterface${idCounter.inc
|
||||
}
|
||||
|
||||
fun getErrorCode(t: Throwable): String? =
|
||||
(t as? ExceptionWithCode)?.code
|
||||
?: errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) }
|
||||
errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) }
|
||||
|
||||
fun encodeError(forId: UInt, t: Throwable): Transport.Block.Error =
|
||||
if (t is RemoteInterface.ClosedException) {
|
||||
exception { "Illegal attempt to send ClosedException" to t }
|
||||
encodeError(forId, RemoteInterface.InternalError("TCE"))
|
||||
}
|
||||
else
|
||||
getErrorCode(t)?.let { Transport.Block.Error(forId, it, t.message) }
|
||||
?: Transport.Block.Error(forId, "UnknownError", "${t::class.simpleName}: ${t.message}")
|
||||
getErrorCode(t)?.let { Transport.Block.Error(forId, it, t.message) }
|
||||
?: Transport.Block.Error(forId, "UnknownError", t.message)
|
||||
|
||||
open fun getErrorBuilder(code: String): ((String, UByteArray?) -> Throwable)? =
|
||||
errorBuilder[code] ?: errorProviders.firstNonNull { it.getErrorBuilder(code) }
|
||||
|
||||
fun decodeError(tbe: Transport.Block.Error): Throwable =
|
||||
getErrorBuilder(tbe.code)?.invoke(tbe.message, tbe.extra)
|
||||
?: RemoteInterface.RemoteException(tbe).also {
|
||||
info { "can't decode error ${tbe.code}: ${tbe.message}" }
|
||||
}
|
||||
?: RemoteInterface.RemoteException(tbe)
|
||||
|
||||
fun decodeAndThrow(tbe: Transport.Block.Error): Nothing {
|
||||
throw decodeError(tbe)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
/** Complete-block boundary used only after a concealed channel has authenticated its peer. */
|
||||
internal interface ProtectedRpcDevice : Transport.Device {
|
||||
val maxQueuedItems: Int
|
||||
val maxQueuedBytes: Int
|
||||
val maxOvertaking: Int
|
||||
/** Called under Transport's admission mutex. Stream protection itself happens in send(). */
|
||||
fun prepareBlock(bytes: UByteArray): OutgoingData
|
||||
/** Called after writer shutdown while admission is locked. */
|
||||
fun destroyProtection()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
class ProxyDevice(capacity: Int=1024) : Transport.Device {
|
||||
override val input = Channel<UByteArray?>(capacity)
|
||||
override val output= Channel<UByteArray?>(capacity)
|
||||
override suspend fun close() {
|
||||
runCatching { input.close() }
|
||||
runCatching { output.close() }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
@@ -25,12 +15,11 @@ interface RemoteInterface {
|
||||
|
||||
/**
|
||||
* Is thrown when the channel is closed, in an attempt to execute a command, also to all pending
|
||||
* calls (see [call]). Client code should never throw it. If command handler needs to break connection
|
||||
* it should throw [LocalInterface.BreakConnectionException]
|
||||
* calls (see [call]).
|
||||
*/
|
||||
open class ClosedException(t: String = "connection is closed") : Exception(t)
|
||||
|
||||
open class SecurityException(t: String = "invalid remote id and signature") : LocalInterface.BreakConnectionException(t)
|
||||
open class SecurityException(t: String = "invalid remote id and signature") : ClosedException(t)
|
||||
|
||||
|
||||
open class InvalidDataException(msg: String="invalid data, can't unpack") : Exception(msg)
|
||||
@@ -50,37 +39,12 @@ interface RemoteInterface {
|
||||
/**
|
||||
* Command is not supported by the remote party
|
||||
*/
|
||||
class UnknownCommand(commandName: String) : RemoteException("UnknownCommand: $commandName")
|
||||
|
||||
open class InternalError(code: String="0"): RemoteException("Internal error: $code")
|
||||
class UnknownCommand : RemoteException("UnknownCommand")
|
||||
|
||||
suspend fun <R> call(cmd: Command<Unit, R>): R = call(cmd, Unit)
|
||||
|
||||
/**
|
||||
* Call the remote procedure with specified [args] and return its result of type [R]. The calling coroutine
|
||||
* suspends until the request is performed on the remote side and the result value (also `Unit`) will be received.
|
||||
*
|
||||
* When it is not necessary to wait for the return value and/or command execution, it is recommended to
|
||||
* use [push] instead.
|
||||
*
|
||||
* @throws RemoteException if the execution caused exception on the remote size
|
||||
* @throws Exception for registered exceptions, see [LocalInterface.registerError], etc.
|
||||
* Call the remote procedure with specified args and return its result
|
||||
*/
|
||||
suspend fun <A, R> call(cmd: Command<A, R>, args: A): R
|
||||
|
||||
/**
|
||||
* Push the notification without waiting for reception or processing.
|
||||
* It returns immediately after sending data to the transport (e.g., to the network).
|
||||
* Use [call] if it is necessary to wait until the command will be received and processed by the remote.
|
||||
*
|
||||
* Push is onlu available for commands without returned value.
|
||||
*/
|
||||
suspend fun <A> push(cmd: Command<A, Unit>, args: A)
|
||||
|
||||
/**
|
||||
* Push the command with no args.
|
||||
* It returns immediately after sending data to the transport (e.g., to the network).
|
||||
* Use [call] if it is necessary to wait until the command will be received and processed by the remote.
|
||||
*/
|
||||
suspend fun push(cmd: Command<Unit,Unit>) = push(cmd,Unit)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
internal const val REPLAY_WINDOW_SIZE = 4096
|
||||
internal const val MAX_NONCE_OVERTAKING = REPLAY_WINDOW_SIZE * 2 / 3
|
||||
|
||||
/** A packet disposition, not a session failure. Never dispatched or replied to. */
|
||||
internal class DiscardPacket : RuntimeException("Duplicate or stale encrypted packet")
|
||||
|
||||
/** Fixed circular bitmap. Callers serialize check/authenticate/record as one operation. */
|
||||
internal class ReplayWindow {
|
||||
private val bits = LongArray(REPLAY_WINDOW_SIZE / 64)
|
||||
private var highest: ULong? = null
|
||||
fun copy(): ReplayWindow = ReplayWindow().also {
|
||||
bits.copyInto(it.bits)
|
||||
it.highest = highest
|
||||
}
|
||||
private fun index(n: ULong) = (n % REPLAY_WINDOW_SIZE.toULong()).toInt()
|
||||
fun containsOrStale(n: ULong): Boolean {
|
||||
val h = highest ?: return false
|
||||
if (n > h) return false
|
||||
if (h - n >= REPLAY_WINDOW_SIZE.toULong()) return true
|
||||
val i = index(n)
|
||||
return bits[i / 64] and (1L shl (i % 64)) != 0L
|
||||
}
|
||||
fun record(n: ULong) {
|
||||
val h = highest
|
||||
if (h == null || n > h) {
|
||||
if (h == null || n - h >= REPLAY_WINDOW_SIZE.toULong()) bits.fill(0)
|
||||
else {
|
||||
// Clear only newly entered positions, not every remembered nonce.
|
||||
var next = h
|
||||
repeat((n - h).toInt()) {
|
||||
val i = index(++next)
|
||||
bits[i / 64] = bits[i / 64] and (1L shl (i % 64)).inv()
|
||||
}
|
||||
}
|
||||
highest = n
|
||||
}
|
||||
val i = index(n)
|
||||
bits[i / 64] = bits[i / 64] or (1L shl (i % 64))
|
||||
}
|
||||
}
|
||||
|
||||
/** Local-only metadata; does not change wire encoding. */
|
||||
internal data class OutgoingData(val bytes: UByteArray, val nonce: ULong? = null)
|
||||
internal fun interface PreparedPayload { fun encode(): OutgoingData }
|
||||
internal class UnauthenticatedOuterError : RemoteInterface.SecurityException("Unauthenticated outer error")
|
||||
@@ -1,18 +1,6 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -25,14 +13,14 @@ import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.serializer
|
||||
import net.sergeych.bipack.Unsigned
|
||||
import net.sergeych.kiloparsec.adapter.PriorityBlockWriter
|
||||
import net.sergeych.crypto.toDump
|
||||
import net.sergeych.kiloparsec.Transport.Device
|
||||
import net.sergeych.mp_logger.*
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
/**
|
||||
* Kiloparsec channel that operates some block [Device] exporting a given [localInterface]
|
||||
* Divan channel that operates some block [Device] exporting a given [localInterface]
|
||||
* to remote callers. [LocalInterface] allows session managing, transmitting exceptions
|
||||
* in a scure and multiplatform way and provide local command execution (typed RPC)
|
||||
*/
|
||||
@@ -53,7 +41,7 @@ class Transport<S>(
|
||||
* Input blocks. When the device is disconnected, it should send one null to this channel
|
||||
* to notify the owner. When [close] is called, the channel should be closed.
|
||||
*/
|
||||
val input: ReceiveChannel<UByteArray>
|
||||
val input: ReceiveChannel<UByteArray?>
|
||||
|
||||
/**
|
||||
* Send a binary block to a remote party where it should be received and put into [input]
|
||||
@@ -66,27 +54,12 @@ class Transport<S>(
|
||||
* possible. This method must not throw exceptions.
|
||||
*/
|
||||
suspend fun close()
|
||||
|
||||
suspend fun flush() {}
|
||||
|
||||
/** Adapter admission; legacy devices retain their existing output-channel behavior. */
|
||||
suspend fun send(block: UByteArray) { output.send(block) }
|
||||
}
|
||||
|
||||
@Serializable(TransportBlockSerializer::class)
|
||||
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
|
||||
data class Call(
|
||||
@Unsigned
|
||||
val id: UInt,
|
||||
val name: String,
|
||||
val packedArgs: UByteArray
|
||||
) : Block() {
|
||||
data class Call(val id: UInt, val name: String, val packedArgs: UByteArray) : Block() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Call) return false
|
||||
@@ -106,140 +79,44 @@ class Transport<S>(
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Response(@Unsigned val forId: UInt, val packedResult: UByteArray) : Block()
|
||||
data class Response(val forId: UInt, val packedResult: UByteArray) : Block()
|
||||
|
||||
@Serializable
|
||||
data class Error(@Unsigned val forId: UInt, val code: String, val text: String? = null, val extra: UByteArray? = null) :
|
||||
data class Error(val forId: UInt, val code: String, val text: String? = null, val extra: UByteArray? = null) :
|
||||
Block() {
|
||||
val message by lazy { text ?: "remote exception: $code" }
|
||||
}
|
||||
}
|
||||
|
||||
private val outgoing = Mutex()
|
||||
private val writerReady = CompletableDeferred<PriorityBlockWriter>()
|
||||
private val responseReaders = mutableMapOf<UInt, (UByteArray) -> UByteArray>()
|
||||
private val access = Mutex()
|
||||
private var lastId = 0u
|
||||
private val calls = mutableMapOf<UInt, CompletableDeferred<UByteArray>>()
|
||||
var isClosed: Boolean = false
|
||||
|
||||
/**
|
||||
* Send a call block for a command and packed args and return packed result if it is not an error. It suspends
|
||||
* until receiving answer from the remote side, even if returns `Unit`.
|
||||
* Send a call block for a command and packed args and return packed result if it is not an error
|
||||
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
|
||||
* @throws RemoteInterface.ClosedException
|
||||
*/
|
||||
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray =
|
||||
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 {
|
||||
private suspend fun sendCallBlock(name: String, packedArgs: UByteArray): UByteArray {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
|
||||
val id: UInt
|
||||
val priority = currentCoroutineContext()[CallPriority]?.value ?: 0
|
||||
val b: Block
|
||||
val deferred = CompletableDeferred<UByteArray>()
|
||||
|
||||
// We need to shield calls and lastID with mutex, but nothing more:
|
||||
access.withLock {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
// the order is important: first id in use MUST BE >= 1, not zero:
|
||||
if (lastId == UInt.MAX_VALUE) throw RemoteInterface.ClosedException("RPC ID space exhausted")
|
||||
id = ++lastId
|
||||
calls[id] = deferred
|
||||
if (readResponse != null) responseReaders[id] = readResponse
|
||||
b = Block.Call(++lastId, name, packedArgs)
|
||||
calls[b.id] = deferred
|
||||
}
|
||||
|
||||
// Suspend on actual channel backpressure; never poll or delay a successful send.
|
||||
try {
|
||||
sendPrepared {
|
||||
val payload = packedArgs(id)
|
||||
OutgoingData(pack<Block>(Block.Call(id, name, payload.bytes).also { it.priority = priority }), payload.nonce)
|
||||
}
|
||||
return deferred.await()
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
throw RemoteInterface.ClosedException()
|
||||
} finally {
|
||||
// Cancellation must not retain a response waiter for the connection lifetime.
|
||||
withContext(NonCancellable) { access.withLock { calls.remove(id); responseReaders.remove(id) } }
|
||||
}
|
||||
}
|
||||
// now we have mutex freed so we can call:
|
||||
val r = device.output.trySend(pack(b).also { debug { ">>>\n${it.toDump()}" } })
|
||||
if (!r.isSuccess) deferred.completeExceptionally(RemoteInterface.ClosedException())
|
||||
|
||||
/**
|
||||
* Send a call block for a command and packed args and return packed result if it is not an error. It suspends
|
||||
* until receiving answer from the remote side, even if returns `Unit`.
|
||||
* @throws RemoteInterface.RemoteException if the remote call caused an exception. Normally use [call] instead.
|
||||
* @throws RemoteInterface.ClosedException
|
||||
*/
|
||||
private suspend fun sendPushBlock(name: String, packedArgs: UByteArray) {
|
||||
if (isClosed) throw RemoteInterface.ClosedException()
|
||||
|
||||
// All push blocks have the same id == 0:
|
||||
val b = Block.Call(0u, name, packedArgs).also { it.priority = currentCoroutineContext()[CallPriority]?.value ?: 0 }
|
||||
val r = runCatching { send(b) }
|
||||
when(val e = r.exceptionOrNull()) {
|
||||
null -> return
|
||||
is RemoteInterface.ClosedException, is CancellationException, is RemoteInterface.RemoteException
|
||||
-> throw e
|
||||
else -> throw RemoteInterface.ClosedException()
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun callWithId(
|
||||
cmd: Command<UByteArray, UByteArray>,
|
||||
readResponse: (UByteArray) -> UByteArray = { it },
|
||||
build: (UInt) -> OutgoingData,
|
||||
): UByteArray = sendBoundCallBlock(cmd.name, { raw ->
|
||||
try {
|
||||
// utools.unpack treats empty bytes as null for Unit interoperability. Here the
|
||||
// result is a non-null encoded byte array; a null-backed UByteArray can crash Native.
|
||||
readResponse(net.sergeych.bipack.BipackDecoder.decode(cmd.resultSerializer, raw.asByteArray()))
|
||||
}
|
||||
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 raw = prepare()
|
||||
val priority = net.sergeych.kiloparsec.adapter.blockPriority(raw.bytes)
|
||||
val frame = (device as? ProtectedRpcDevice)?.prepareBlock(raw.bytes) ?: raw
|
||||
writer.enqueue(frame.bytes, frame.nonce, priority)
|
||||
}
|
||||
awaitSent()
|
||||
}
|
||||
|
||||
/** Freeze new admission before draining; used by authenticated channel shutdown. */
|
||||
internal suspend fun drainForClose() {
|
||||
outgoing.withLock { isClosed = true }
|
||||
writerReady.await().flush()
|
||||
}
|
||||
|
||||
internal suspend fun destroyProtection() = outgoing.withLock {
|
||||
isClosed = true
|
||||
(device as? ProtectedRpcDevice)?.destroyProtection()
|
||||
}
|
||||
|
||||
internal suspend fun abortSecurity() {
|
||||
isClosed = true
|
||||
device.close()
|
||||
// it returns packed result or throws a proper error:
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,10 +127,6 @@ class Transport<S>(
|
||||
return unpack(cmd.resultSerializer, result)
|
||||
}
|
||||
|
||||
override suspend fun <A>push(cmd: Command<A,Unit>,args: A) {
|
||||
sendPushBlock(cmd.name, pack(cmd.argsSerializer, args))
|
||||
}
|
||||
|
||||
/**
|
||||
* Start running the transport. This function suspends until the transport is closed
|
||||
* normally or by error. If you need to cancel it prematurely, cancel the coroutine
|
||||
@@ -263,133 +136,77 @@ class Transport<S>(
|
||||
*/
|
||||
suspend fun run() {
|
||||
coroutineScope {
|
||||
// One ordered submission to the carrier at a time: adapter queues cannot
|
||||
// undo the nonce-aware scheduling performed here.
|
||||
val protected = device as? ProtectedRpcDevice
|
||||
val writer = PriorityBlockWriter(this,
|
||||
maxQueuedItems = protected?.maxQueuedItems ?: 128,
|
||||
maxQueuedBytes = protected?.maxQueuedBytes ?: (32 * 1024 * 1024),
|
||||
maxOvertaking = protected?.maxOvertaking ?: MAX_NONCE_OVERTAKING,
|
||||
continueOnWriteFailure = protected == null) { device.send(it) }
|
||||
writerReady.complete(writer)
|
||||
debug { "awaiting incoming blocks" }
|
||||
// todo: rewrite it to close the job with no exceptions at all, always
|
||||
while (isActive && !isClosed) {
|
||||
try {
|
||||
device.input.receive().let { packed ->
|
||||
val b = net.sergeych.bipack.BipackDecoder.decode<Block>(packed.asByteArray())
|
||||
device.input.receive()?.let { packed ->
|
||||
debug { "<<<\n${packed.toDump()}" }
|
||||
val b = unpack<Block>(packed)
|
||||
debug { "<<$ $b" }
|
||||
debug { "access state: ${access.isLocked}" }
|
||||
when (b) {
|
||||
is Block.Error -> access.withLock {
|
||||
val error = if (responseReaders.containsKey(b.forId)) UnauthenticatedOuterError() else localInterface.decodeError(b)
|
||||
val error = localInterface.decodeError(b)
|
||||
warning { "decoded error: ${error::class.simpleName}: $error" }
|
||||
responseReaders.remove(b.forId)
|
||||
calls.remove(b.forId)?.completeExceptionally(error)
|
||||
calls.remove(b.forId)?.completeExceptionally(localInterface.decodeError(b))
|
||||
?: warning { "error handler not found for ${b.forId}" }
|
||||
info { "error processed" }
|
||||
info { "error processed"}
|
||||
}
|
||||
|
||||
is Block.Response -> access.withLock {
|
||||
val pending = calls[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)
|
||||
}
|
||||
calls.remove(b.forId)?.let {
|
||||
debug { "activating wait handle for ${b.forId}" }
|
||||
it.complete(b.packedResult)
|
||||
}
|
||||
?: warning { "wait handle not found for ${b.forId}" }
|
||||
}
|
||||
|
||||
is Block.Call -> launch {
|
||||
try {
|
||||
val result = localInterface.executeTransportBlock(commandContext, b)
|
||||
if (b.id != 0u) {
|
||||
try {
|
||||
sendPrepared {
|
||||
val encoded = result.encode()
|
||||
OutgoingData(pack<Block>(Block.Response(b.id, encoded.bytes).also { it.priority = b.priority }), encoded.nonce)
|
||||
}
|
||||
} 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) {
|
||||
// handler forced close
|
||||
warning { "handler requested closing of the connection (${x.flushSendQueue}" }
|
||||
isClosed = true
|
||||
if (x.flushSendQueue) { writer.flush(); device.flush() }
|
||||
device.close()
|
||||
} catch (x: CancellationException) {
|
||||
throw x
|
||||
send(
|
||||
Block.Response(
|
||||
b.id,
|
||||
localInterface.execute(commandContext, b.name, b.packedArgs)
|
||||
)
|
||||
)
|
||||
} catch (x: RemoteInterface.ClosedException) {
|
||||
// strange case: handler throws closed?
|
||||
error { "not supported: command handler for $b has thrown ClosedException" }
|
||||
send(Block.Error(b.id, "UnexpectedException", x.message))
|
||||
} catch (x: RemoteInterface.RemoteException) {
|
||||
send(Block.Error(b.id, x.code, x.text, x.extra).also { it.priority = b.priority })
|
||||
send(Block.Error(b.id, x.code, x.text, x.extra))
|
||||
} catch (t: Throwable) {
|
||||
send(Block.Error(b.id, "UnknownError", t.message).also { it.priority = b.priority })
|
||||
send(Block.Error(b.id, "UnknownError", t.message))
|
||||
}
|
||||
.also { debug { "command executed: ${b.name}" } }
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
debug { "remote channel close received" }
|
||||
isClosed = true
|
||||
}
|
||||
// debug { "input step performed closed=$isClosed active=$isActive" }
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
info { "closed send channel" }
|
||||
isClosed = true
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
info { "closed receive channel" }
|
||||
isClosed = true
|
||||
} catch (cce: LocalInterface.BreakConnectionException) {
|
||||
info { "closing connection by local request ($cce)" }
|
||||
device.close()
|
||||
} catch (_: RemoteInterface.ClosedException) {
|
||||
// it is ok: we just exit the coroutine normally
|
||||
// and mark we're closing
|
||||
isClosed = true
|
||||
} catch (_: CancellationException) {
|
||||
info { "loop is cancelled with CancellationException" }
|
||||
info { "loop is cancelled" }
|
||||
isClosed = true
|
||||
} catch (t: Throwable) {
|
||||
exception { "channel closed on error" to t }
|
||||
info { "isa? $isActive / $isClosed" }
|
||||
runCatching { device.close() }
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
debug { "leaving transport loop" }
|
||||
access.withLock {
|
||||
debug { "access lock obtained" }
|
||||
isClosed = true
|
||||
debug { "closing device $device, calls in queue ${calls.size}" }
|
||||
runCatching { device.close() }
|
||||
for (c in calls.values)
|
||||
c.completeExceptionally(RemoteInterface.ClosedException())
|
||||
for (c in calls.values) c.completeExceptionally(RemoteInterface.ClosedException())
|
||||
calls.clear()
|
||||
responseReaders.clear()
|
||||
writer.close()
|
||||
debug { "calls clear has been called" }
|
||||
// Closing a device also releases dispatch children waiting on unfinished handshakes.
|
||||
currentCoroutineContext().cancelChildren()
|
||||
}
|
||||
debug { "no more active: $isActive / ${calls.size}" }
|
||||
}
|
||||
debug { "exiting transport loop" }
|
||||
info { "exiting transport loop" }
|
||||
}
|
||||
|
||||
private suspend fun send(block: Block) {
|
||||
try {
|
||||
sendPrepared { OutgoingData(pack(block)) }
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
throw RemoteInterface.ClosedException()
|
||||
}
|
||||
device.output.send(pack(block))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -414,26 +231,14 @@ object TransportBlockSerializer : KSerializer<Transport.Block> {
|
||||
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 {
|
||||
val block = when (val id = decoder.decodeByte().toInt()) {
|
||||
override fun deserialize(decoder: Decoder): Transport.Block =
|
||||
when( val id = decoder.decodeByte().toInt()) {
|
||||
0 -> decoder.decodeSerializableValue(serializer<Transport.Block.Call>())
|
||||
1 -> decoder.decodeSerializableValue(serializer<Transport.Block.Error>())
|
||||
2 -> decoder.decodeSerializableValue(serializer<Transport.Block.Response>())
|
||||
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,6 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
internal data class ConnectionId(
|
||||
val address: NetworkAddress,
|
||||
val id: UInt,
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.kiloparsec.KiloClientConnection
|
||||
import net.sergeych.kiloparsec.KiloInterface
|
||||
import net.sergeych.kiloparsec.ProxyDevice
|
||||
import net.sergeych.mp_logger.*
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private val instanceCounter = AtomicCounter()
|
||||
|
||||
class DatagramClient<S>(
|
||||
private val connector: DatagramConnector,
|
||||
val serverAddress: NetworkAddress,
|
||||
private val localInterface: KiloInterface<S>,
|
||||
private val clientSigningKey: Key.Signing? = null,
|
||||
private val createSession: () -> S,
|
||||
) : LogTag("DGC:${instanceCounter.next()}") {
|
||||
|
||||
private var sessionId = 0u
|
||||
private var isClosed = false
|
||||
val job = globalLaunch {
|
||||
while (isActive && !isClosed) {
|
||||
openSession()
|
||||
pumpSession()
|
||||
debug { "closing session $sessionId" }
|
||||
sessionId = 0u
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pumpSession() {
|
||||
try {
|
||||
val proxyDevice = ProxyDevice()
|
||||
val client = KiloClientConnection<S>(localInterface, proxyDevice, createSession(), clientSigningKey)
|
||||
coroutineScope {
|
||||
fun stop() {
|
||||
cancel()
|
||||
}
|
||||
launch {
|
||||
while (isActive) {
|
||||
if (sessionId == 0u) {
|
||||
error { "invalid zero sessionId in pump" }
|
||||
stop()
|
||||
} else {
|
||||
val data = proxyDevice.output.receive()
|
||||
if (data == null) {
|
||||
warning { "kiloconnection send empty frame: closing" }
|
||||
stop()
|
||||
} else {
|
||||
send(OuterPacket(sessionId, data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while (isActive) {
|
||||
val packet: OuterPacket = unpack(connector.incoming.receive().message)
|
||||
if (packet.id == 0u) {
|
||||
val result: DatagramResult = unpack(packet.payload)
|
||||
if (result is DatagramResult.Error) {
|
||||
if (result.error == DatagramError.Closed) {
|
||||
info { "connection is closed on the remote part" }
|
||||
} else {
|
||||
warning { "unexpected error while pumping: ${result.error}" }
|
||||
}
|
||||
} else {
|
||||
warning { "unexpected result: $result" }
|
||||
}
|
||||
stop()
|
||||
} else {
|
||||
proxyDevice.input.send(packet.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
proxyDevice.close()
|
||||
} catch (x: CancellationException) {
|
||||
throw x
|
||||
} catch (x: Exception) {
|
||||
x.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun openSession() {
|
||||
while (!isClosed) {
|
||||
send(OuterPacket(0u, ubyteArrayOf()))
|
||||
val packet: OuterPacket = unpack(connector.incoming.receive().message)
|
||||
if (packet.id == 0u) {
|
||||
when (val result: DatagramResult = unpack(packet.payload)) {
|
||||
is DatagramResult.Error -> {
|
||||
warning { "error opening connection: ${result.error}" }
|
||||
}
|
||||
|
||||
is DatagramResult.Ok -> {
|
||||
debug { "connection established, our id is ${result.id}" }
|
||||
sessionId = result.id
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warning { "received unexpected openSession id: ${packet.id}" }
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun send(packet: OuterPacket) {
|
||||
connector.send(pack(packet), serverAddress)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
enum class DatagramError {
|
||||
Closed,
|
||||
NotExists,
|
||||
Busy
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class DatagramResult {
|
||||
@Serializable
|
||||
@SerialName("ok")
|
||||
data class Ok(val id: UInt) : DatagramResult()
|
||||
|
||||
@Serializable
|
||||
@SerialName("err")
|
||||
data class Error(val error: DatagramError) : DatagramResult()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Instant
|
||||
import net.sergeych.crypto.Key
|
||||
import net.sergeych.kiloparsec.KiloInterface
|
||||
import net.sergeych.kiloparsec.KiloServerConnection
|
||||
import net.sergeych.kiloparsec.ProxyDevice
|
||||
import net.sergeych.mp_logger.LogTag
|
||||
import net.sergeych.mp_logger.debug
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
import net.sergeych.utools.now
|
||||
import net.sergeych.utools.pack
|
||||
import net.sergeych.utools.unpack
|
||||
|
||||
private val instanceCounter = AtomicCounter()
|
||||
|
||||
private class Connection<S>(
|
||||
val server: DatagramServer<S>,
|
||||
val connection: KiloServerConnection<S>,
|
||||
val device: ProxyDevice,
|
||||
val connectionId: ConnectionId,
|
||||
var lastActiveAt: Instant = now(),
|
||||
) {
|
||||
|
||||
suspend fun proceedFrame(frame: OuterPacket) {
|
||||
lastActiveAt = now()
|
||||
if (frame.payload.isEmpty()) {
|
||||
// ping
|
||||
server.sendData(connectionId,ubyteArrayOf())
|
||||
} else
|
||||
device.input.send(frame.payload)
|
||||
}
|
||||
|
||||
private val job = globalLaunch {
|
||||
while (isActive) {
|
||||
val x = device.output.receive() ?: break
|
||||
server.sendData(connectionId, x)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun close() {
|
||||
job.cancel()
|
||||
server.remove(connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
class DatagramServer<S>(
|
||||
private val connector: DatagramConnector,
|
||||
private val localInterface: KiloInterface<S>,
|
||||
private val serverSigningKey: Key.Signing? = null,
|
||||
private val createSession: () -> S,
|
||||
) : LogTag("DGS:${instanceCounter.next()}") {
|
||||
|
||||
private val job = globalLaunch {
|
||||
while (isActive) {
|
||||
try {
|
||||
val datagram = connector.incoming.receive()
|
||||
val outer = unpack<OuterPacket>(datagram.message)
|
||||
if (outer.id == 0u)
|
||||
setupNewConnection(datagram.address, outer)
|
||||
else
|
||||
proceedExistingConnection(datagram.address, outer)
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val access = Mutex()
|
||||
private val connections = mutableMapOf<ConnectionId, Connection<S>>()
|
||||
|
||||
private fun findFreeId(address: NetworkAddress): UInt {
|
||||
val existingIds = connections.keys.filter { it.address == address }.map { it.id }.toSet()
|
||||
debug { "existing connections from $address: $existingIds" }
|
||||
var i = 0u
|
||||
while (i in existingIds) i++
|
||||
debug { "found the free id: $i" }
|
||||
return i
|
||||
}
|
||||
|
||||
private suspend fun setupNewConnection(address: NetworkAddress, packet: OuterPacket) {
|
||||
val id = access.withLock {
|
||||
val connectionId = ConnectionId(address, findFreeId(address))
|
||||
val proxy = ProxyDevice()
|
||||
connections[connectionId] = Connection(
|
||||
this,
|
||||
KiloServerConnection(localInterface, proxy, createSession(), serverSigningKey),
|
||||
proxy,
|
||||
connectionId,
|
||||
)
|
||||
connectionId
|
||||
}
|
||||
sendResult(id,DatagramResult.Ok(id.id))
|
||||
}
|
||||
|
||||
private suspend fun proceedExistingConnection(address: NetworkAddress, outerPacket: OuterPacket) {
|
||||
val connectionId = ConnectionId(address, outerPacket.id)
|
||||
val c = access.withLock { connections[connectionId] }
|
||||
if (c == null) {
|
||||
sendResult(connectionId, DatagramResult.Error(DatagramError.NotExists))
|
||||
} else {
|
||||
c.proceedFrame(outerPacket)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Important. Closing the server also closes the connector which is supposed to be "owned"
|
||||
* exclusively by `DatagramServer`
|
||||
*/
|
||||
fun close() {
|
||||
job.cancel()
|
||||
runCatching {
|
||||
connector.close()
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun remove(connectionId: ConnectionId) {
|
||||
access.withLock {
|
||||
connections.remove(connectionId)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal suspend fun sendData(connectionId: ConnectionId,data: UByteArray) {
|
||||
connector.send(pack(OuterPacket(connectionId.id, data)), connectionId.address)
|
||||
}
|
||||
|
||||
internal suspend fun sendResult(connectionId: ConnectionId, result: DatagramResult) {
|
||||
connector.send(pack(OuterPacket(0u, pack(result))), connectionId.address)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
|
||||
/**
|
||||
* Transport device for inet protocol family with graceful shutdown on [close]
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class InetTransportDevice(
|
||||
inputChannel: Channel<UByteArray>,
|
||||
outputChannel: Channel<UByteArray>,
|
||||
val remoteAddress: NetworkAddress,
|
||||
doClose: (suspend ()->Unit)? = null,
|
||||
doFlush: (suspend ()->Unit)? = null,
|
||||
) : ProxyDevice(inputChannel, outputChannel, doClose, doFlush) {
|
||||
override fun toString(): String = "@$remoteAddress"
|
||||
}
|
||||
@@ -1,23 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
|
||||
/**
|
||||
* Multiplatform internet address.
|
||||
* Multiplatform implementation of an internet address.
|
||||
* Notice to implementors. It must provide correct and effective [equals] and [hashCode].
|
||||
*/
|
||||
data class NetworkAddress(
|
||||
val host: String,
|
||||
interface NetworkAddress {
|
||||
val host: String
|
||||
val port: Int
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "$host:$port"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplatform datagram abstraction
|
||||
*/
|
||||
interface Datagram {
|
||||
/**
|
||||
* Received message
|
||||
*/
|
||||
val message: UByteArray
|
||||
|
||||
/**
|
||||
* Address from where the message was sent
|
||||
*/
|
||||
val address: NetworkAddress
|
||||
|
||||
/**
|
||||
* Send a datagram in response, e.g., to the [address].
|
||||
* This method is optimized per single per-datagram use. If you need to send many datagram, use [DatagramConnector].
|
||||
*/
|
||||
suspend fun respondWith(message: UByteArray)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
interface DatagramConnector: AutoCloseable {
|
||||
|
||||
val incoming: ReceiveChannel<Datagram>
|
||||
suspend fun send(message: UByteArray, networkAddress: NetworkAddress)
|
||||
@Suppress("unused")
|
||||
suspend fun send(message: UByteArray, datagramAddress: String) {
|
||||
send(message, networkAddressOf(datagramAddress))
|
||||
}
|
||||
|
||||
suspend fun send(message: UByteArray,host: String,port: Int) =
|
||||
send(message, NetworkAddress(host,port))
|
||||
override fun close()
|
||||
}
|
||||
|
||||
expect fun networkAddressOf(address: String): NetworkAddress
|
||||
expect fun NetworkAddress(host: String,port: Int): NetworkAddress
|
||||
@@ -1,25 +0,0 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import net.sergeych.kiloparsec.MAX_NONCE_OVERTAKING
|
||||
|
||||
/** Externally synchronized. Ordering values are per-stream nonces or local admission ordinals. */
|
||||
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,9 @@
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class OuterPacket(
|
||||
val id: UInt,
|
||||
val payload: UByteArray,
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
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,
|
||||
maxOvertaking: Int = net.sergeych.kiloparsec.MAX_NONCE_OVERTAKING,
|
||||
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>(maxOvertaking.toULong())
|
||||
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, priority: Int = blockPriority(bytes)): suspend () -> Unit {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val entry = Pending(bytes, priority, 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() }
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.channels.SendChannel
|
||||
import kotlinx.coroutines.delay
|
||||
import net.sergeych.kiloparsec.Transport
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
|
||||
private val counter = AtomicCounter()
|
||||
open class ProxyDevice(
|
||||
private val inputChannel: Channel<UByteArray>,
|
||||
private val outputChannel: Channel<UByteArray>,
|
||||
private val doClose: (suspend ()->Unit)? = null,
|
||||
private val doFlush: (suspend ()->Unit)? = null,
|
||||
): Transport.Device {
|
||||
|
||||
internal var prioritizedSend: (suspend (UByteArray) -> Unit)? = null
|
||||
internal var priorityWriter: PriorityBlockWriter? = null
|
||||
override suspend fun send(block: UByteArray) {
|
||||
prioritizedSend?.let { it(block); return }
|
||||
val writer = priorityWriter
|
||||
if (writer == null) output.send(block) else writer.send(block)
|
||||
}
|
||||
|
||||
override val input: ReceiveChannel<UByteArray> = inputChannel
|
||||
override val output: SendChannel<UByteArray> = outputChannel
|
||||
|
||||
override suspend fun close() {
|
||||
priorityWriter?.close()
|
||||
kotlin.runCatching { doClose?.invoke() }
|
||||
runCatching { inputChannel.close() }
|
||||
runCatching { outputChannel.close() }
|
||||
}
|
||||
|
||||
override suspend fun flush() {
|
||||
priorityWriter?.flush()
|
||||
doFlush?.invoke()
|
||||
var cnt = 10
|
||||
while(!outputChannel.isEmpty) {
|
||||
if (cnt-- < 0) break
|
||||
delay(50)
|
||||
}
|
||||
super.flush()
|
||||
}
|
||||
|
||||
private val id = counter.incrementAndGet()
|
||||
|
||||
override fun toString(): String = "PX$id"
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec.adapter
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.websocket.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.coroutines.channels.ClosedSendChannelException
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.io.IOException
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.mp_logger.*
|
||||
import net.sergeych.mp_tools.decodeBase64Compact
|
||||
import net.sergeych.mp_tools.encodeToBase64Compact
|
||||
import net.sergeych.mp_tools.globalLaunch
|
||||
import net.sergeych.tools.AtomicCounter
|
||||
|
||||
private val counter = AtomicCounter()
|
||||
|
||||
/**
|
||||
* Shortcut to create websocket client. Use [websocketTransportDevice] with [KiloClient]
|
||||
* for fine-grained control.
|
||||
*/
|
||||
fun <S> websocketClient(
|
||||
path: String,
|
||||
clientInterface: KiloInterface<S> = KiloInterface(),
|
||||
secretKey: SigningKey? = null,
|
||||
useTextFrames: Boolean = false,
|
||||
sessionMaker: () -> S = {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
Unit as S
|
||||
},
|
||||
): KiloClient<S> {
|
||||
return KiloClient(clientInterface, secretKey) {
|
||||
KiloConnectionData(websocketTransportDevice(path, useTextFrames), sessionMaker())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create kilopaarsec transport over websocket (ws or wss).
|
||||
* @param path websocket path (must start with ws:// or wss:// and contain a path part)
|
||||
* @param client optional caller-owned client. When omitted, a client with the
|
||||
* [WebSockets] plugin is created for this device and closed with it.
|
||||
*/
|
||||
fun websocketTransportDevice(
|
||||
path: String,
|
||||
useTextFrames: Boolean = false,
|
||||
client: HttpClient? = null,
|
||||
): Transport.Device {
|
||||
|
||||
val ownsClient = client == null
|
||||
val actualClient = client ?: HttpClient { install(WebSockets) }
|
||||
|
||||
val log = LogTag("WSTD")
|
||||
var u = Url(path)
|
||||
log.debug { "Creating websocket transport device at $u" }
|
||||
if (u.encodedPath.length <= 1) {
|
||||
log.debug {"Correcting path as up = ${u.encodedPath}" }
|
||||
u = URLBuilder(u).apply {
|
||||
encodedPath = "/kp"
|
||||
}.build()
|
||||
}
|
||||
log.debug { "Url to process is $u" }
|
||||
|
||||
val input = Channel<UByteArray>()
|
||||
val output = Channel<UByteArray>()
|
||||
val writerReady = CompletableDeferred<PriorityBlockWriter>()
|
||||
val closeHandle = CompletableDeferred<Boolean>()
|
||||
|
||||
val connectionJob = globalLaunch {
|
||||
val log = LogTag("KC:${counter.incrementAndGet()}")
|
||||
try {
|
||||
actualClient.webSocket({
|
||||
url.protocol = u.protocol
|
||||
url.host = u.host
|
||||
url.port = u.port
|
||||
url.encodedPath = u.encodedPath
|
||||
url.parameters.appendAll(u.parameters)
|
||||
log.info { "kiloparsec server URL: $url" }
|
||||
}) {
|
||||
log.info { "connected to the server" }
|
||||
// println("SENDING!!!")
|
||||
// send("Helluva")
|
||||
val writer = PriorityBlockWriter(this) { block ->
|
||||
if (useTextFrames) send(Frame.Text(block.asByteArray().encodeToBase64Compact()))
|
||||
else send(block.asByteArray())
|
||||
flush() // Do not turn the WebSocket outgoing FIFO into an unbounded look-ahead.
|
||||
}
|
||||
writerReady.complete(writer)
|
||||
launch {
|
||||
try {
|
||||
for (block in output) writer.send(block)
|
||||
log.info { "input is closed, closing the websocket" }
|
||||
if (closeHandle.isActive) closeHandle.complete(true)
|
||||
} catch (_: ClosedSendChannelException) {
|
||||
log.info { "send channel closed" }
|
||||
} catch (_: CancellationException) {
|
||||
} catch (t: Throwable) {
|
||||
log.info { "unexpected exception in websock sender: ${t.stackTraceToString()}" }
|
||||
closeHandle.completeExceptionally(t)
|
||||
}
|
||||
writer.close()
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
}
|
||||
launch {
|
||||
try {
|
||||
for (f in incoming) {
|
||||
when (f) {
|
||||
is Frame.Binary -> input.send(f.readBytes().toUByteArray())
|
||||
is Frame.Text -> input.send(f.readText().decodeBase64Compact().toUByteArray())
|
||||
else -> log.warning { "ignoring unexpected frame of type ${f.frameType}" }
|
||||
}
|
||||
}
|
||||
if (closeHandle.isActive) closeHandle.complete(true)
|
||||
} catch (_: CancellationException) {
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
log.warning { "receive channel closed unexpectedly" }
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
} catch (t: Throwable) {
|
||||
log.exception { "unexpected error" to t }
|
||||
if (closeHandle.isActive) closeHandle.complete(false)
|
||||
}
|
||||
}
|
||||
if (!closeHandle.await()) {
|
||||
log.warning { "Client is closing with error" }
|
||||
throw RemoteInterface.ClosedException()
|
||||
}
|
||||
runCatching { output.close() }
|
||||
runCatching { input.close() }
|
||||
runCatching { close() }
|
||||
}
|
||||
} catch (x: IOException) {
|
||||
if ("refused" in x.toString()) log.debug { "connection refused" }
|
||||
else log.warning { "unexpected IO error $x" }
|
||||
runCatching { output.close() }
|
||||
runCatching { input.close() }
|
||||
} finally {
|
||||
writerReady.completeExceptionally(RemoteInterface.ClosedException())
|
||||
if (ownsClient) actualClient.close()
|
||||
}
|
||||
log.info { "closing connection" }
|
||||
}
|
||||
// Wait for connection be established or failed
|
||||
val device = ProxyDevice(input, output, doClose = {
|
||||
// we need to explicitly close the coroutine job, or it can hang for a long time
|
||||
// leaking resources.
|
||||
runCatching { output.close() }
|
||||
runCatching { input.close() }
|
||||
closeHandle.complete(true)
|
||||
connectionJob.cancel()
|
||||
// job.cancel()
|
||||
})
|
||||
device.prioritizedSend = { block ->
|
||||
val writer = writerReady.await()
|
||||
device.priorityWriter = writer
|
||||
writer.send(block)
|
||||
}
|
||||
return device
|
||||
}
|
||||
@@ -1,29 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
|
||||
*
|
||||
* You may use, distribute and modify this code under the
|
||||
* terms of the private license, which you must obtain from the author
|
||||
*
|
||||
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
|
||||
* real dot sergeych at gmail.
|
||||
*/
|
||||
|
||||
package net.sergeych.kiloparsec
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.sergeych.crypto2.SafeKeyExchange
|
||||
import net.sergeych.crypto2.Seal
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.crypto.Key
|
||||
|
||||
// L0 commands - key exchange and check:
|
||||
@Serializable
|
||||
data class Handshake(val version: UInt, val publicKey: SafeKeyExchange.PublicKey,
|
||||
val signature: Seal? = null)
|
||||
data class Handshake(val version: UInt, val publicKey: UByteArray,
|
||||
val serverSharedKey: Key.Verifying? = null,
|
||||
val signature: UByteArray? = null)
|
||||
|
||||
@Serializable
|
||||
data class ClientIdentity(val clientIdKey: VerifyingPublicKey?, val signature: UByteArray?)
|
||||
data class ClientIdentity(val clientIdKey: Key.Verifying?, val signature: UByteArray?)
|
||||
|
||||
// Level 0 command: request key exchange
|
||||
internal val L0Request by command<Handshake, Handshake>()
|
||||
internal val L0ClientId by command<UByteArray, UByteArray>()
|
||||
internal val L0ClientId by command<UByteArray, Unit>()
|
||||
internal val L0Call by command<UByteArray,UByteArray>()
|
||||
@@ -1,109 +0,0 @@
|
||||
@file:OptIn(ExperimentalConcealedProtocol::class)
|
||||
package net.sergeych.kiloparsec.concealed
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.crypto2.EncryptingPublicKey
|
||||
import net.sergeych.crypto2.SigningKey
|
||||
import net.sergeych.crypto2.VerifyingPublicKey
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.kiloparsec.concealed.handshake.HandshakeFailure
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
/**
|
||||
* One client establishment, with finite retries using a fresh carrier and DH keys per attempt.
|
||||
* Once connected, carrier loss ends this run. Application calls are never silently replayed.
|
||||
*/
|
||||
@ExperimentalConcealedProtocol
|
||||
class ConcealedClient<S>(
|
||||
private val local: KiloInterface<S>,
|
||||
private val session: S,
|
||||
private val serverEncryptionKey: EncryptingPublicKey,
|
||||
private val trustedServerIdentity: VerifyingPublicKey,
|
||||
private val clientIdentity: SigningKey,
|
||||
val options: ConcealedOptions = ConcealedOptions(),
|
||||
private val connect: suspend () -> ConcealedCarrier,
|
||||
) : RemoteInterface {
|
||||
private val ready = CompletableDeferred<ConcealedConnection<S>>()
|
||||
private var connection: ConcealedConnection<S>? = null
|
||||
private var runJob: Job? = null
|
||||
private val runAccess = Mutex()
|
||||
private var used = false
|
||||
private var stopped = false
|
||||
|
||||
suspend fun awaitReady() { ready.await().awaitReady() }
|
||||
suspend fun remoteId() = ready.await().remoteId()
|
||||
suspend fun token() = ready.await().token()
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R = ready.await().call(cmd, args)
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) = ready.await().push(cmd, args)
|
||||
|
||||
suspend fun run(onConnectedStateChanged: ((Boolean) -> Unit)? = null): Unit = coroutineScope {
|
||||
runAccess.withLock { check(!used) { "Run a client only once" }; used = true }
|
||||
runJob = currentCoroutineContext()[Job]
|
||||
// Avoid Native narrowing a mutable Throwable to the concrete fallback exception type.
|
||||
var last: Throwable? = null
|
||||
try {
|
||||
repeat(options.handshake.maxAttempts) { index ->
|
||||
if (stopped) throw RemoteInterface.ClosedException()
|
||||
currentCoroutineContext().ensureActive()
|
||||
val started = TimeSource.Monotonic.markNow()
|
||||
var carrier: ConcealedCarrier? = null
|
||||
var established = false
|
||||
try {
|
||||
carrier = withTimeout(options.handshake.timeoutMillis) { connect() }
|
||||
val remaining = options.handshake.timeoutMillis - started.elapsedNow().inWholeMilliseconds
|
||||
if (remaining <= 0) throw RemoteInterface.ClosedException("Connection attempt deadline exceeded")
|
||||
val c = ConcealedConnection.client(local, carrier, session, serverEncryptionKey,
|
||||
trustedServerIdentity, clientIdentity,
|
||||
options.copy(handshake = options.handshake.copy(timeoutMillis = remaining)))
|
||||
connection = c
|
||||
c.run { connected ->
|
||||
if (connected) { established = true; ready.complete(c) }
|
||||
onConnectedStateChanged?.invoke(connected)
|
||||
}
|
||||
// An established run is never restarted/replayed by this helper.
|
||||
if (established) return@coroutineScope
|
||||
last = c.terminalFailure ?: RemoteInterface.ClosedException()
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
last = e
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: HandshakeFailure) { throw e }
|
||||
catch (e: LocalInterface.BreakConnectionException) { throw e }
|
||||
catch (e: Throwable) {
|
||||
if (established) throw e
|
||||
last = e
|
||||
} finally {
|
||||
withContext(NonCancellable) { carrier?.close() }
|
||||
connection = null
|
||||
}
|
||||
if (index + 1 < options.handshake.maxAttempts) {
|
||||
val base = options.handshake.retryDelayMillis
|
||||
val cap = options.handshake.maxRetryDelayMillis
|
||||
val factor = 1L shl minOf(index, 20)
|
||||
val backoff = if (base > cap / factor) cap else minOf(cap, base * factor)
|
||||
val jitter = 0.8 + kotlin.random.Random.nextDouble() * 0.4
|
||||
delay((backoff * jitter).toLong())
|
||||
}
|
||||
}
|
||||
throw last ?: RemoteInterface.ClosedException()
|
||||
} catch (e: Throwable) {
|
||||
ready.completeExceptionally(e)
|
||||
throw e
|
||||
} finally {
|
||||
ready.completeExceptionally(last ?: RemoteInterface.ClosedException())
|
||||
runJob = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun close() {
|
||||
stopped = true
|
||||
try { connection?.close() }
|
||||
finally {
|
||||
ready.completeExceptionally(RemoteInterface.ClosedException())
|
||||
// close during a connector/backoff must also release its pending coroutine.
|
||||
runJob?.cancel(CancellationException("Concealed client closed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
@file:OptIn(ExperimentalConcealedProtocol::class)
|
||||
package net.sergeych.kiloparsec.concealed
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.crypto2.*
|
||||
import net.sergeych.kiloparsec.*
|
||||
import net.sergeych.kiloparsec.concealed.handshake.*
|
||||
|
||||
/** Share one instance between a listener's connections to share duplicate/admission bounds. */
|
||||
@ExperimentalConcealedProtocol
|
||||
class ConcealedServerIdentity(
|
||||
val encryptionKey: DecryptingSecretKey,
|
||||
val signingKey: SigningKey,
|
||||
recentDhCapacity: Int = 4096,
|
||||
cryptoConcurrency: Int = 4,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private val crypto = Semaphore(cryptoConcurrency.also { require(it > 0) })
|
||||
private val helloFailureNoise = HelloFailureNoise()
|
||||
private val recent = ArrayDeque<UByteArray>()
|
||||
private val capacity = recentDhCapacity.also { require(it >= 0) }
|
||||
init { require(encryptionKey.keyBytes.size == 32 && signingKey.verifyingKey.keyBytes.size == 32) }
|
||||
internal suspend fun <T> cryptographicOperation(operation: () -> T): T {
|
||||
if (!crypto.tryAcquire()) throw RemoteInterface.ClosedException("Handshake capacity reached")
|
||||
try { return operation() } finally { crypto.release() }
|
||||
}
|
||||
internal fun <T> helloAuthentication(
|
||||
frame: UByteArray, maximum: Int, headerOnly: Boolean, enabled: Boolean, operation: () -> T,
|
||||
): T = if (!enabled) operation() else helloFailureNoise.run(operation) { decoy ->
|
||||
ServerHandshake.discardHelloDecryption(frame, decoy, maximum, headerOnly)
|
||||
}
|
||||
internal suspend fun remember(key: UByteArray): Boolean = mutex.withLock {
|
||||
if (capacity == 0) return@withLock true
|
||||
if (recent.any { it.contentEquals(key) }) return@withLock false
|
||||
if (recent.size == capacity) recent.removeFirst()
|
||||
recent.addLast(key.copyOf())
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit experimental flavor over raw carriers. The original Kilo connections remain available.
|
||||
* Run one coroutine per connection; calls made before readiness suspend and fail if setup fails.
|
||||
*/
|
||||
@ExperimentalConcealedProtocol
|
||||
class ConcealedConnection<S> private constructor(
|
||||
private val local: KiloInterface<S>,
|
||||
private val carrier: ConcealedCarrier,
|
||||
private val session: S,
|
||||
private val clientKey: SigningKey?,
|
||||
private val serverEncryption: EncryptingPublicKey?,
|
||||
private val trustedServer: VerifyingPublicKey?,
|
||||
private val serverIdentity: ConcealedServerIdentity?,
|
||||
val options: ConcealedOptions,
|
||||
) : RemoteInterface {
|
||||
private val ready = CompletableDeferred<Transport<KiloScope<S>>>()
|
||||
private val identity = CompletableDeferred<VerifyingPublicKey>()
|
||||
private val token = CompletableDeferred<UByteArray>()
|
||||
private val runAccess = Mutex()
|
||||
private var device: ProtectedChannelDevice? = null
|
||||
private var transport: Transport<KiloScope<S>>? = null
|
||||
private var used = false
|
||||
private var stopping = false
|
||||
private var connectedScope: KiloScope<S>? = null
|
||||
var terminalFailure: Throwable? = null
|
||||
private set
|
||||
|
||||
suspend fun remoteId(): VerifyingPublicKey = identity.await()
|
||||
suspend fun token(): UByteArray = token.await().copyOf()
|
||||
suspend fun awaitReady() { ready.await() }
|
||||
override suspend fun <A, R> call(cmd: Command<A, R>, args: A): R = ready.await().call(cmd, args)
|
||||
override suspend fun <A> push(cmd: Command<A, Unit>, args: A) = ready.await().push(cmd, args)
|
||||
|
||||
suspend fun run(onConnectedStateChanged: ((Boolean) -> Unit)? = null) = coroutineScope {
|
||||
runAccess.withLock { check(!used) { "Run a connection only once" }; used = true }
|
||||
val inbox = CarrierInbox(carrier)
|
||||
var material: HandshakeSession? = null
|
||||
var clientHandshake: ClientHandshake? = null
|
||||
var serverHandshake: ServerHandshake? = null
|
||||
var rpcJob: Job? = null
|
||||
var readJob: Job? = null
|
||||
try {
|
||||
if (stopping) throw RemoteInterface.ClosedException()
|
||||
var ack: UByteArray? = null
|
||||
var accepted: UByteArray? = null
|
||||
withTimeout(options.handshake.timeoutMillis) {
|
||||
if (serverIdentity == null) {
|
||||
val h = ClientHandshake(serverEncryption!!.keyBytes, trustedServer!!, clientKey!!, carrier.kind, options)
|
||||
clientHandshake = h
|
||||
carrier.send(h.hello)
|
||||
val (_, clientAck) = inbox.verifiedFlight(44, flightLimit(options, carrier.kind), h::serverHelloSize, h::receiveServerHello)
|
||||
carrier.send(clientAck)
|
||||
material = if (carrier.kind == ConcealedCarrier.Kind.STREAM) {
|
||||
inbox.verifiedFlight(44, flightLimit(options, carrier.kind), h::acceptanceSize, h::receiveAcceptance).second
|
||||
} else {
|
||||
var result: HandshakeSession? = null
|
||||
var retries = 0
|
||||
while (result == null) {
|
||||
val factor = 1L shl minOf(retries, 2)
|
||||
val wait = if (options.udp.ackRetryMillis > 4_000 / factor) 4_000
|
||||
else options.udp.ackRetryMillis * factor
|
||||
result = withTimeoutOrNull(wait) {
|
||||
inbox.verifiedFlight(44, flightLimit(options, carrier.kind), h::acceptanceSize, h::receiveAcceptance).second
|
||||
}
|
||||
if (result == null) {
|
||||
if (retries++ >= options.udp.maxAckRetries) throw RemoteInterface.ClosedException("Handshake acceptance timed out")
|
||||
carrier.send(clientAck)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
} else {
|
||||
val maximum = flightLimit(options, carrier.kind)
|
||||
val (_, h) = inbox.verifiedFlight(76, maximum, { prefix ->
|
||||
serverIdentity.cryptographicOperation {
|
||||
serverIdentity.helloAuthentication(prefix, maximum, true, options.handshake.decoyOnHelloAuthenticationFailure) {
|
||||
ServerHandshake.helloSize(prefix, serverIdentity.encryptionKey.keyBytes, maximum)
|
||||
}
|
||||
}
|
||||
}) { helo ->
|
||||
serverIdentity.cryptographicOperation {
|
||||
serverIdentity.helloAuthentication(helo, maximum, false, options.handshake.decoyOnHelloAuthenticationFailure) {
|
||||
ServerHandshake(serverIdentity.encryptionKey.keyBytes, serverIdentity.signingKey, carrier.kind, options, helo)
|
||||
}
|
||||
}
|
||||
}
|
||||
serverHandshake = h
|
||||
if (!serverIdentity.remember(h.clientDhPublicKey)) {
|
||||
// No response for repeated HELO; caller retries with fresh keys.
|
||||
awaitCancellation()
|
||||
}
|
||||
carrier.send(h.response)
|
||||
val (clientAck, _) = inbox.verifiedFlight(44, maximum, h::ackSize, h::verifyAck)
|
||||
material = h.session
|
||||
ack = clientAck
|
||||
// Run existing policy hooks with verified credentials before acceptance.
|
||||
val verified = createScope(h.session)
|
||||
local.onConnectHandlers.invokeAll(verified)
|
||||
connectedScope = verified
|
||||
accepted = h.accept()
|
||||
carrier.send(accepted)
|
||||
}
|
||||
}
|
||||
val active = material!!
|
||||
val scope = connectedScope ?: createScope(active)
|
||||
val protected = ProtectedChannelDevice(carrier, inbox, active, options, ack, accepted)
|
||||
device = protected
|
||||
val raw = Transport(protected, local, scope)
|
||||
transport = raw
|
||||
readJob = protected.start(this)
|
||||
rpcJob = launch { raw.run() }
|
||||
if (serverIdentity == null) {
|
||||
local.onConnectHandlers.invokeAll(scope)
|
||||
connectedScope = scope
|
||||
}
|
||||
identity.complete(active.peerIdentity)
|
||||
token.complete(active.token.copyOf())
|
||||
ready.complete(raw)
|
||||
onConnectedStateChanged?.invoke(true)
|
||||
rpcJob.join()
|
||||
terminalFailure = protected.failure
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Throwable) { terminalFailure = e; throw e }
|
||||
finally {
|
||||
val failure = terminalFailure ?: RemoteInterface.ClosedException()
|
||||
ready.completeExceptionally(failure)
|
||||
identity.completeExceptionally(failure)
|
||||
token.completeExceptionally(failure)
|
||||
withContext(NonCancellable) {
|
||||
carrier.close()
|
||||
rpcJob?.cancelAndJoin()
|
||||
readJob?.cancelAndJoin()
|
||||
transport?.destroyProtection()
|
||||
}
|
||||
clientHandshake?.destroy(); serverHandshake?.destroy()
|
||||
material?.let { it.sending.destroy(); it.receiving.destroy(); it.token.fill(0u) }
|
||||
connectedScope?.let { local.onDisconnectHandlers.invokeAll(it) }
|
||||
onConnectedStateChanged?.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createScope(material: HandshakeSession): KiloScope<S> = object : KiloScope<S> {
|
||||
override val session = this@ConcealedConnection.session
|
||||
override val remote: RemoteInterface = this@ConcealedConnection
|
||||
override val sessionToken = material.token.copyOf()
|
||||
override val remoteIdentity = material.peerIdentity
|
||||
}
|
||||
|
||||
/** Drain admitted writes within a deadline, send authenticated CLOSE, then release all waiters. */
|
||||
suspend fun close() = withContext(NonCancellable) {
|
||||
stopping = true
|
||||
try {
|
||||
withTimeoutOrNull(options.close.timeoutMillis) {
|
||||
transport?.drainForClose()
|
||||
device?.sendClose()
|
||||
}
|
||||
} catch (_: Exception) { /* Graceful close is best effort; always release the carrier. */ }
|
||||
finally { carrier.close() }
|
||||
ready.completeExceptionally(RemoteInterface.ClosedException())
|
||||
identity.completeExceptionally(RemoteInterface.ClosedException())
|
||||
token.completeExceptionally(RemoteInterface.ClosedException())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun <S> client(
|
||||
local: KiloInterface<S>, carrier: ConcealedCarrier, session: S,
|
||||
serverEncryptionKey: EncryptingPublicKey, trustedServerIdentity: VerifyingPublicKey,
|
||||
clientIdentity: SigningKey, options: ConcealedOptions = ConcealedOptions(),
|
||||
) = ConcealedConnection(local, carrier, session, clientIdentity, serverEncryptionKey, trustedServerIdentity, null, options)
|
||||
|
||||
fun <S> server(
|
||||
local: KiloInterface<S>, carrier: ConcealedCarrier, session: S,
|
||||
serverIdentity: ConcealedServerIdentity, options: ConcealedOptions = ConcealedOptions(),
|
||||
) = ConcealedConnection(local, carrier, session, null, null, null, serverIdentity, options)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user