commit 770555024fe7c6e09e8d28637bf228c000a10089 Author: sergeych Date: Sat Nov 11 23:52:18 2023 +0300 initial commit (moved from divan) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b63da45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/artifacts/kiloparsec_js_0_1_0_SNAPSHOT.xml b/.idea/artifacts/kiloparsec_js_0_1_0_SNAPSHOT.xml new file mode 100644 index 0000000..3d4983a --- /dev/null +++ b/.idea/artifacts/kiloparsec_js_0_1_0_SNAPSHOT.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/artifacts/kiloparsec_jvm_0_1_0_SNAPSHOT.xml b/.idea/artifacts/kiloparsec_jvm_0_1_0_SNAPSHOT.xml new file mode 100644 index 0000000..54493e1 --- /dev/null +++ b/.idea/artifacts/kiloparsec_jvm_0_1_0_SNAPSHOT.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/build/libs + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..919ce1f --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..f9163b4 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..df543e3 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..e805548 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..239935e --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..fdb5b8f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,83 @@ +import org.jetbrains.kotlin.gradle.plugin.KotlinJsCompilerType + +plugins { + kotlin("multiplatform") version "1.9.20" + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20" + `maven-publish` +} + +group = "net.sergeych" +version = "0.1.0-SNAPSHOT" + +repositories { + mavenCentral() + mavenLocal() + maven("https://maven.universablockchain.com/") +} + +kotlin { + jvm { + jvmToolchain(11) + withJava() + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } + js(KotlinJsCompilerType.IR) { + browser { +// commonWebpackConfig { +// cssSupport { +// enabled.set(true) +// } +// } + } + } + 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.") + } + + + sourceSets { + all { + languageSettings.optIn("kotlinx.serialization.ExperimentalSerializationApi") + languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + languageSettings.optIn("kotlin.ExperimentalUnsignedTypes") + } + + val commonMain by getting { + dependencies { + 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 commonTest by getting { + dependencies { + implementation(kotlin("test")) + 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 nativeMain by getting + val nativeTest by getting + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..06febab --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# 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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d659e67 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0" +} + +rootProject.name = "kiloparsec" \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto/InitCrypto.kt b/src/commonMain/kotlin/net/sergeych/crypto/InitCrypto.kt new file mode 100644 index 0000000..ef32a0a --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto/InitCrypto.kt @@ -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 + } + } + } +} + + diff --git a/src/commonMain/kotlin/net/sergeych/crypto/SignedBox.kt b/src/commonMain/kotlin/net/sergeych/crypto/SignedBox.kt new file mode 100644 index 0000000..d0cb7e7 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto/SignedBox.kt @@ -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, + @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) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto/contrail.kt b/src/commonMain/kotlin/net/sergeych/crypto/contrail.kt new file mode 100644 index 0000000..108c726 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto/contrail.kt @@ -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 \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto/keys.kt b/src/commonMain/kotlin/net/sergeych/crypto/keys.kt new file mode 100644 index 0000000..48b0633 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto/keys.kt @@ -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") diff --git a/src/commonMain/kotlin/net/sergeych/crypto/tools.kt b/src/commonMain/kotlin/net/sergeych/crypto/tools.kt new file mode 100644 index 0000000..5391bb7 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto/tools.kt @@ -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.limit(range: ClosedRange) = when { + this < range.start -> range.start + this > range.endInclusive -> range.endInclusive + else -> this +} + +fun >T.limitMax(max: T) = if( this < max ) this else max +fun >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( + SecretBox.openEasy(wn.cipherData, wn.nonce, secretKey).toDataSource() + ).data + } + catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) { + throw DecryptionFailedException() + } +} diff --git a/src/commonMain/kotlin/net/sergeych/crypto/utools.kt b/src/commonMain/kotlin/net/sergeych/crypto/utools.kt new file mode 100644 index 0000000..8ae980a --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto/utools.kt @@ -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() \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/Command.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/Command.kt new file mode 100644 index 0000000..e2a8f08 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/Command.kt @@ -0,0 +1,40 @@ +package net.sergeych.kiloparsec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import net.sergeych.bintools.toDataSource +import net.sergeych.bipack.BipackDecoder +import net.sergeych.bipack.BipackEncoder +import net.sergeych.utools.unpack + +/** + * 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. + */ +class Command( + val name: String, + val argsSerializer: KSerializer, + val resultSerializer: KSerializer +) { + @Serializable + data class Call(val name: String,val serializedArgs: UByteArray) + + fun packCall(args: A): UByteArray = BipackEncoder.encode( + Call(name, BipackEncoder.encode(argsSerializer, args).toUByteArray()) + ).toUByteArray() + + fun unpackResult(packedResult: UByteArray): R = + unpack(resultSerializer, packedResult) + + suspend fun exec(packedArgs: UByteArray, handler: suspend (A) -> R): UByteArray = + BipackEncoder.encode( + resultSerializer, + handler(BipackDecoder.decode(packedArgs.toDataSource(), argsSerializer)) + ).toUByteArray() + + companion object { + fun unpackCall(packedCall: UByteArray): Call = BipackDecoder.decode(packedCall.toDataSource()) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/CommandDelegate.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/CommandDelegate.kt new file mode 100644 index 0000000..8d91a33 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/CommandDelegate.kt @@ -0,0 +1,34 @@ +package net.sergeych.kiloparsec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer +import kotlin.reflect.KProperty + +/** + * delegate returning function that creates a [Command] in the current context which by default has the name of + * the property. + */ +inline fun command(overrideName: String? = null): CommandDelegate { + return CommandDelegate( + serializer(), + serializer(), + overrideName + ) +} + +/** + * Delegate to create [Command] via property + */ +class CommandDelegate( + private val argsSerializer: KSerializer, + private val resultSerializer: KSerializer, + private val overrideName: String? = null +) { + operator fun getValue(nothing: Nothing?, property: KProperty<*>): Command { + return Command( + overrideName ?: property.name, + argsSerializer, + resultSerializer + ) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/ConnectionFactory.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/ConnectionFactory.kt new file mode 100644 index 0000000..8ab4966 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/ConnectionFactory.kt @@ -0,0 +1,14 @@ +package net.sergeych.kiloparsec + +/** + * Minimal data to create kiloparsec connection: transport device and a new session object. + */ +data class KiloConnectionData( + val device: Transport.Device, + val session: S +) + +/** + * callback that creates new [Transport.Device] and session objects for Kiloparsec connections. + */ +typealias ConnectionDataFactory = suspend ()->KiloConnectionData diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClient.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClient.kt new file mode 100644 index 0000000..6e5a41e --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClient.kt @@ -0,0 +1,139 @@ +package net.sergeych.kiloparsec + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +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 + * and maintain connection state flow. Client factory launches a disconnected + * 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( + localInterface: KiloInterface, + secretKey: Key.Signing? = null, + connectionDataFactory: ConnectionDataFactory, +) : RemoteInterface, + Loggable by LogTag("CLIF") { + + val _state = MutableStateFlow(false) + + /** + * State flow that shows the current state of an auto-connecting client. Use it + * to authenticate a client on connection restore, for example. + */ + @Suppress("unused") + val state = _state.asStateFlow() + + private var deferredClient = CompletableDeferred>() + + private val job = + globalLaunch { + debug { "starting connector" } + 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 + } + debug { "client run finished" } + } catch (_: RemoteInterface.ClosedException) { + debug { "remote closed" } + } catch (_: CancellationException) { + debug { "cancelled" } + } catch (t: Throwable) { + exception { "unexpected exception" to t } + } + _state.value = false + if (deferredClient.isActive) + deferredClient = CompletableDeferred() + } + } + + fun close() { + job.cancel() + } + + override suspend fun call(cmd: Command, 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 + * it could be used as a nonce to pair MITM and like attacks, be sure that the server is actually + * working, etc. + */ + suspend fun token() = deferredClient.await().token() + + /** + * 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 remoteId() = deferredClient.await().remoteId() + + companion object { + class Builder() { + + private var interfaceBuilder: (KiloInterface.() -> Unit)? = null + private var sessionBuilder: (() -> S) = { + @Suppress("UNCHECKED_CAST") + Unit as S + } + private var connectionBuilder: (suspend () -> Transport.Device)? = null + + var secretIdKey: Key.Signing? = null + + /** + * Build local command implementations (remotely callable ones), exception + * class handlers, etc. + */ + fun local(f: KiloInterface.() -> Unit) { + interfaceBuilder = f + } + + /** + * Create a new session object, otherwise Unit session will be used + */ + fun session(f: () -> S) { + sessionBuilder = f + } + + fun connect(f: suspend () -> Transport.Device) { + connectionBuilder = f + } + + internal fun build(): KiloClient { + val i = KiloInterface() + interfaceBuilder?.let { i.it() } + val connector = connectionBuilder ?: throw IllegalArgumentException("connect handler was not set") + return KiloClient(i,secretIdKey) { + KiloConnectionData(connector(),sessionBuilder()) + } + } + } + + /** + * Call the secure remote command when the secure connection is established. Note that + * it might fail on disconnect. We do not automatically repeat command on disconnect + * as actual services might need identification on reconnecting. + */ + operator fun invoke(f: Builder.() -> Unit): KiloClient { + return Builder().also { it.f() }.build() + } + } +} diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt new file mode 100644 index 0000000..64fa898 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloClientConnection.kt @@ -0,0 +1,102 @@ +package net.sergeych.kiloparsec + +import com.ionspin.kotlin.crypto.keyexchange.KeyExchange +import kotlinx.coroutines.* +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 + +private var clientIds = 0 + +class KiloClientConnection( + private val clientInterface: LocalInterface>, + private val device: Transport.Device, + private val session: S, + private val secretIdKey: Key.Signing? = null, +) : RemoteInterface, Loggable by LogTag("KPC:${++clientIds}") { + + constructor(localInterface: KiloInterface, connection: KiloConnectionData, secretIdKey: Key.Signing? = null) + : this(localInterface, connection.device, connection.session, secretIdKey) + + private val kiloRemoteInterface = CompletableDeferred>() + + private val deferredParams = CompletableDeferred>() + + 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) { + coroutineScope { + var job: Job? = null + try { + // in parallel: keys and connection + 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) + val transport = Transport(device, l0Interface, Unit) + + job = launch { transport.run() } + debug { "transport started" } + + val pair = deferredKeyPair.await() + debug { "keypair ready" } + + val serverHe = transport.call(L0Request, Handshake(1u, pair.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.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.copy(remoteIdentity = k) + } + + transport.call( + L0ClientId, params.encrypt( + pack( + ClientIdentity( + secretIdKey?.verifying, + secretIdKey?.sign(params.token) + ) + ) + ) + ) + deferredParams.complete(params) + kiloRemoteInterface.complete( + KiloRemoteInterface(deferredParams, clientInterface) + ) + onConnectedStateChanged?.invoke(true) + job.join() + + } catch (x: CancellationException) { + info { "client is cancelled" } + } catch (x: RemoteInterface.ClosedException) { + info { "connection closed by remote" } + } finally { + onConnectedStateChanged?.invoke(false) + job?.cancel() + device.apply { runCatching { close() } } + } + } + } + + suspend fun token() = deferredParams.await().token + override suspend fun call(cmd: Command, args: A): R = + kiloRemoteInterface.await().call(cmd, args) +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloInterface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloInterface.kt new file mode 100644 index 0000000..6b58cec --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloInterface.kt @@ -0,0 +1,20 @@ +package net.sergeych.kiloparsec + +/** + * The local interface to provice functions, register errors for Kiloparsec users. Use it + * with [KiloClient], [KiloClientConnection], [KiloServerConnection], etc. + * + * BAse implementation registers relevant exceptions. + */ +class KiloInterface : LocalInterface>() { + init { + registerError { RemoteInterface.UnknownCommand() } + registerError { RemoteInterface.ClosedException(it) } + registerError { RemoteInterface.SecurityException(it) } + registerError { RemoteInterface.InvalidDataException(it) } + registerError { RemoteInterface.RemoteException(it) } + registerError { IllegalStateException() } + registerError { IllegalArgumentException(it) } + } +} + diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt new file mode 100644 index 0000000..6eaa7ad --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloL0Interface.kt @@ -0,0 +1,36 @@ +package net.sergeych.kiloparsec + +import kotlinx.coroutines.CompletableDeferred +import net.sergeych.utools.pack + +/** + * 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( + private val clientInterface: LocalInterface>, + private val deferredParams: CompletableDeferred>, +): LocalInterface() { + init { + // local interface uses the same session as a client: + addErrorProvider(clientInterface) + + on(L0Call) { + val params = deferredParams.await() + val call = Command.unpackCall(params.decrypt(it)) + // tricky part: we need to encrypt a result or error: we can't allow default + // error transport (as it is not encrypted), so we re-use transport blocks here: + val result: Transport.Block = try { + Transport.Block.Response( + 0u, + clientInterface.execute(params.scope, call.name, call.serializedArgs) + ) + } catch (t: Throwable) { + clientInterface.encodeError(0u, t) + } + params.encrypt(pack(result)) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt new file mode 100644 index 0000000..a709e66 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloParams.kt @@ -0,0 +1,136 @@ +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.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 + +/** + * Parameters used in secured local and remote interfaces, etc. Actual values + * are calculated in [KiloServerConnection] and [KiloClientConnection] by + * exchanging keys, then used to encrypt/decrypt calls and results and create + * [KiloScope] with a proper session object. + * + * __important: parameters' calculation algorithms are different on the server and + * client side, so you need to provide proper [isServer] value!__ + */ +data class KiloParams( + val isServer: Boolean, + val transport: RemoteInterface, + val sessionKeyPair: KeyExchangeSessionKeyPair, + val scopeSession: S, + val remoteIdentity: Key.Verifying?, + val remoteTransport: RemoteInterface +) { + @Serializable + data class Package( + val nonce: ULong, + val encryptedMessage: UByteArray, + ) + + @Serializable + data class FilledData( + val message: UByteArray, + val fill: UByteArray, + ) + + private var nonce = 0UL + + val scope: KiloScope by lazy { + object : KiloScope { + override val session = scopeSession + override val remote: RemoteInterface = remoteTransport + override val sessionToken: UByteArray = token + override val remoteIdentity: Key.Verifying? = this@KiloParams.remoteIdentity + } + } + + val token: UByteArray by lazy { + val base = if (isServer) sessionKeyPair.sendKey + sessionKeyPair.receiveKey + else sessionKeyPair.receiveKey + sessionKeyPair.sendKey + Keccak.digest( + base.toByteArray(), KeccakParameter.KECCAK_256 + ).toUByteArray().sliceArray(0.. 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 { + val fill: UByteArray = if (fillFactor > 0f) + randomBytes(randomUInt((message.size * fillFactor).roundToInt())) + else + ubyteArrayOf() + + val withFill = BipackEncoder.encode(FilledData(message, fill)).toUByteArray() + + val n = proptectedOp { nonce++ } + + return pack( + Package(n, SecretBox.easy(withFill, encodeSendNonce(n), sessionKeyPair.sendKey)) + ) + } + + fun decryptString(cipherText: UByteArray): String = decrypt(cipherText).decodeFromUByteArray() + fun decrypt(encryptedMessage: UByteArray): UByteArray { + val p: Package = BipackDecoder.decode(encryptedMessage.toDataSource()) + try { + return unpack( + SecretBox.openEasy( + p.encryptedMessage, + encodeReceiveNonce(p.nonce), + sessionKeyPair.receiveKey + ) + ).message + } catch (_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) { + throw DecryptionFailedException() + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt new file mode 100644 index 0000000..8509e2a --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloRemoteInterface.kt @@ -0,0 +1,33 @@ +package net.sergeych.kiloparsec + +import kotlinx.coroutines.CompletableDeferred +import net.sergeych.mp_logger.LogTag +import net.sergeych.mp_logger.Loggable +import net.sergeych.utools.unpack + +private var L1IdCounter = 0 + +// todo: We don't need it deferred here +class KiloRemoteInterface( + private val deferredParams: CompletableDeferred>, + private val clientInterface: LocalInterface>, +) : RemoteInterface, Loggable by LogTag("L1TR:${++L1IdCounter}") { + + override suspend fun call(cmd: Command, args: A): R { + val params = deferredParams.await() + val block: Transport.Block = unpack( + params.decrypt( + params.transport.call(L0Call, params.encrypt(cmd.packCall(args))) + ) + ) + return when (block) { + is Transport.Block.Response -> { + cmd.unpackResult(block.packedResult) + } + + is Transport.Block.Error -> clientInterface.decodeAndThrow(block) + else -> throw RemoteInterface.Exception("unexpected block type: $block") + } + } +} + diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloScope.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloScope.kt new file mode 100644 index 0000000..6280f33 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloScope.kt @@ -0,0 +1,42 @@ +package net.sergeych.kiloparsec + +import net.sergeych.crypto.Key + +/** + * Scope for Kiloparsec client/server commands execution, contain per-connection specific data. The scope + * is used to call command implementation you add to the [KiloInterface] when constructing [KiloClient] + * [KiloClientConnection] or [KiloServerConnection]. + */ +interface KiloScope { + /** + * Session object. Any data provided by the caller when creating a new connection + */ + val session: S + + /** + * The secure (L1) interface to call remote commands + */ + val remote: RemoteInterface + + /** + * Unique per-connection token which is the same on the server and client side, though is never + * transmitted (derived from Diffie-Hellman key exchange or like process). It can be used as a + * safe nonce or seed to test connection integrity without sending check data over the channel. + */ + val sessionToken: UByteArray + + /** + * 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. + * + * Note that if the key was provided but authentication failed, the connection __will not be established__, + * throwing [RemoteInterface.SecurityException]. + * + * 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: Key.Verifying? +} + diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt new file mode 100644 index 0000000..5d5c41d --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/KiloServerConnection.kt @@ -0,0 +1,104 @@ +package net.sergeych.kiloparsec + +import com.ionspin.kotlin.crypto.keyexchange.KeyExchange +import kotlinx.coroutines.CompletableDeferred +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.utools.unpack + +private var serverIds: Int = 0 + +/** + * A single kiloparsec server connection. Create it and call [run] and wait until it ends, or cancel + * parent coroutine context to stop. + * + * @param clientInterface local commands, serializable exceptions declarations, etc. usually it is an + * reusable object between connection + * @param device connected device to operate over + * @param session local session object. Use Unit for no session + */ +class KiloServerConnection( + private val clientInterface: KiloInterface, + private val device: Transport.Device, + private val session: S, + private val serverSigningKey: Key.Signing? = null +) : RemoteInterface, Loggable by LogTag("SRV${++serverIds}") { + + /** + * Shortcut to construct with [KiloConnectionData] intance + */ + @Suppress("unused") + constructor(localInterface: KiloInterface, connection: KiloConnectionData, serverSigningKey: Key.Signing? = null) + : this(localInterface, connection.device, connection.session, serverSigningKey) + + private val kiloRemoteInterface = CompletableDeferred>() + + /** + * Run the transport processing loop. This method suspends and only returns when the connection + * is closed, normally or exceptionally. Cancel the scope where it was called to safely + * stop processing, otherwise close the [device]. + */ + suspend fun run() { + val deferredParams = CompletableDeferred>() + val deferredTransport = CompletableDeferred>() + + val l0Interface = KiloL0Interface(clientInterface, deferredParams).apply { + var params: KiloParams? = null + on(L0Request) { + val sk = KeyExchange.serverSessionKeys( + pair.publicKey, pair.secretKey, it.publicKey + ) + + 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(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.copy(remoteIdentity = ci.clientIdKey) + params = p + } + deferredParams.complete(p) + kiloRemoteInterface.complete( + KiloRemoteInterface(deferredParams, clientInterface) + ) + } + } + + val transport = Transport(device, l0Interface, Unit) + deferredTransport.complete(transport) + kiloRemoteInterface.complete(KiloRemoteInterface(deferredParams,clientInterface)) + debug { "starintg the transport"} + transport.run() + debug { "server transport finished" } + } + + companion object { + val pair = KeyExchange.keypair() + } + + override suspend fun call(cmd: Command, args: A): R { + return kiloRemoteInterface.await().call(cmd, args) + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt new file mode 100644 index 0000000..f126c49 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/LocalInterface.kt @@ -0,0 +1,89 @@ +package net.sergeych.kiloparsec + +import net.sergeych.utools.firstNonNull +import kotlin.reflect.KClass + +private typealias RawCommandHandler = suspend (C, UByteArray) -> UByteArray + +open class LocalInterface { + + private val commands = mutableMapOf>() + + /** + * New session creator. Rarely needed directlym it can be used for delegation + * of local interfaces. + */ +// var sessionMaker: suspend () -> S = { +// @Suppress("UNCHECKED_CAST") +// Unit as? S ?: throw IllegalStateException("newSession handler is not set") +// } +// private set + +// /** +// * Builder-style method to create session. Sets the [sessionMaker] actually. +// */ +// fun createSession(sessionMaker: suspend () -> S) { +// this.sessionMaker = sessionMaker +// } + + /** + * Define a command body to be executed locally. + */ + fun on(command: Command, handler: suspend S.(A) -> R) { + commands[command.name] = { cxt, args -> + command.exec(args) { handler(cxt, it) } + } + } + + suspend fun execute( + scope: S, + name: String, + packedArgs: UByteArray, + ): UByteArray = + (commands[name] ?: throw RemoteInterface.UnknownCommand()) + .invoke(scope, packedArgs) + + + private val errorByClass = mutableMapOf, String>() + private val errorBuilder = mutableMapOf Throwable>() + + fun registerError( + klass: KClass, code: String = klass.simpleName!!, + exceptionBuilder: (String, UByteArray?) -> T, + ) { + errorByClass[klass] = code + errorBuilder[code] = exceptionBuilder + } + + inline fun registerError( + noinline exceptionBuilder: (String) -> T, + ) { + registerError(T::class) { msg, _ -> exceptionBuilder(msg) } + } + + val errorProviders = mutableListOf>() + + fun > addErrorProvider(provider: I) { + errorProviders += provider + } + + fun getErrorCode(t: Throwable): String? = + errorByClass[t::class] ?: errorProviders.firstNonNull { it.getErrorCode(t) } + + fun encodeError(forId: UInt, t: Throwable): Transport.Block.Error = + 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) + + fun decodeAndThrow(tbe: Transport.Block.Error): Nothing { + throw decodeError(tbe) + } + + +} diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/RemoteInterface.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/RemoteInterface.kt new file mode 100644 index 0000000..1589098 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/RemoteInterface.kt @@ -0,0 +1,50 @@ +package net.sergeych.kiloparsec + +/** + * Kiloparsec interface to call remote methods. + * + * It is used as for secure layer (L1) as for underlying + * service layer L0. When using [KiloClientConnection], [KiloServerConnection] and [KiloClient] + * it always implement secure layer, L1. + */ +interface RemoteInterface { + /** + * General channel exception + */ + open class Exception(text: String, reason: Throwable? = null) : RuntimeException(text, reason) + + /** + * Is thrown when the channel is closed, in an attempt to execute a command, also to all pending + * calls (see [call]). + */ + open class ClosedException(t: String = "connection is closed") : Exception(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) + + /** + * Remote call caused an exception thrown while executing it in the remote party. Note that it + * does not mean the channel state is bad or closed. + */ + open class RemoteException( + val code: String, + val text: String = "remote exception: $code", + val extra: UByteArray? = null + ) : Exception(text) { + constructor(remoteError: Transport.Block.Error) : this(remoteError.code, remoteError.message, remoteError.extra) + } + + /** + * Command is not supported by the remote party + */ + class UnknownCommand : RemoteException("UnknownCommand") + + suspend fun call(cmd: Command): R = call(cmd, Unit) + + /** + * Call the remote procedure with specified args and return its result + */ + suspend fun call(cmd: Command, args: A): R +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt new file mode 100644 index 0000000..d8be03a --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/Transport.kt @@ -0,0 +1,244 @@ +package net.sergeych.kiloparsec + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializer +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 + +/** + * 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) + */ +class Transport( + private val device: Device, + private val localInterface: LocalInterface, + private val commandContext: S, +) : Loggable by LogTag("TR:$device"), RemoteInterface { + + + /** + * Channel operates using an abstract device, that performs binary block exchange implementing + * this interface. + */ + interface Device { + + /** + * 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 + + /** + * Send a binary block to a remote party where it should be received and put into [input] + * channel. If the device is closed, it should close this channel, also by [close]. + */ + val output: SendChannel + + /** + * Close input and output and free any resources. The output channel should be flushed if + * possible. This method must not throw exceptions. + */ + suspend fun close() + } + + @Serializable(TransportBlockSerializer::class) + sealed class Block { + @Serializable + 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 + if (id != other.id) return false + if (name != other.name) return false + if (!(packedArgs contentEquals other.packedArgs)) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + packedArgs.contentHashCode() + return result + } + } + + @Serializable + data class Response(val forId: UInt, val packedResult: UByteArray) : Block() + + @Serializable + 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 access = Mutex() + private var lastId = 0u + private val calls = mutableMapOf>() + var isClosed: Boolean = false + + /** + * 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 { + if (isClosed) throw RemoteInterface.ClosedException() + + val b: Block + val deferred = CompletableDeferred() + + // We need to shield calls and lastID with mutex, but nothing more: + access.withLock { + if (isClosed) throw RemoteInterface.ClosedException() + b = Block.Call(++lastId, name, packedArgs) + calls[b.id] = deferred + } + + // 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()) + + // it returns packed result or throws a proper error: + return deferred.await() + } + + /** + * Call the remote procedure with specified args and return its result + */ + override suspend fun call(cmd: Command, args: A): R { + val result = sendCallBlock(cmd.name, pack(cmd.argsSerializer, args)) + return unpack(cmd.resultSerializer, result) + } + + /** + * 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 + * it is started in. This approach allows using transport with lifespan connected to the + * calling coroutine which greatly simplifies its usage in popular asyn platofrms like + * a ktor client and server, compose multiplatform, etc. + */ + suspend fun run() { + coroutineScope { + debug { "awaiting incoming blocks" } + while (isActive && !isClosed) { + try { + device.input.receive()?.let { packed -> + debug { "<<<\n${packed.toDump()}" } + val b = unpack(packed) + debug { "<<$ $b" } + debug { "access state: ${access.isLocked}" } + when (b) { + is Block.Error -> access.withLock { + val error = localInterface.decodeError(b) + warning { "decoded error: ${error::class.simpleName}: $error" } + calls.remove(b.forId)?.completeExceptionally(localInterface.decodeError(b)) + ?: warning { "error handler not found for ${b.forId}" } + info { "error processed"} + } + + is Block.Response -> access.withLock { + 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 { + 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)) + } catch (t: Throwable) { + send(Block.Error(b.id, "UnknownError", t.message)) + } + .also { debug { "command executed: ${b.name}" } } + } + } + } ?: run { + debug { "remote channel close received" } + isClosed = true + } + } catch (_: 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 + } + } + access.withLock { + isClosed = true + for (c in calls.values) c.completeExceptionally(RemoteInterface.ClosedException()) + calls.clear() + } + debug { "no more active: $isActive / ${calls.size}" } + } + info { "exiting transport loop" } + } + + private suspend fun send(block: Block) { + device.output.send(pack(block)) + } + +} + +object TransportBlockSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TransportBlock", PrimitiveKind.INT) + + override fun serialize(encoder: Encoder, value: Transport.Block) { + when (value) { + is Transport.Block.Call -> { + encoder.encodeByte(0) + encoder.encodeSerializableValue(serializer(), value) + } + + is Transport.Block.Error -> { + encoder.encodeByte(1) + encoder.encodeSerializableValue(serializer(), value) + } + + is Transport.Block.Response -> { + encoder.encodeByte(2) + encoder.encodeSerializableValue(serializer(), value) + } + } + } + + + override fun deserialize(decoder: Decoder): Transport.Block = + when( val id = decoder.decodeByte().toInt()) { + 0 -> decoder.decodeSerializableValue(serializer()) + 1 -> decoder.decodeSerializableValue(serializer()) + 2 -> decoder.decodeSerializableValue(serializer()) + else -> throw RemoteInterface.InvalidDataException("wrong block type: $id") + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt b/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt new file mode 100644 index 0000000..577831f --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/kiloparsec/commands.kt @@ -0,0 +1,18 @@ +package net.sergeych.kiloparsec + +import kotlinx.serialization.Serializable +import net.sergeych.crypto.Key + +// L0 commands - key exchange and check: +@Serializable +data class Handshake(val version: UInt, val publicKey: UByteArray, + val serverSharedKey: Key.Verifying? = null, + val signature: UByteArray? = null) + +@Serializable +data class ClientIdentity(val clientIdKey: Key.Verifying?, val signature: UByteArray?) + +// Level 0 command: request key exchange +internal val L0Request by command() +internal val L0ClientId by command() +internal val L0Call by command() diff --git a/src/commonMain/kotlin/net/sergeych/tools/ProtectedOp.kt b/src/commonMain/kotlin/net/sergeych/tools/ProtectedOp.kt new file mode 100644 index 0000000..be9720c --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/tools/ProtectedOp.kt @@ -0,0 +1,21 @@ +package net.sergeych.tools + +/** + * Multiplatform interface to perform a regular (not suspend) operation + * protected by a platform mutex (where necessary). Get real implementation + * with [ProtectedOp] + */ +interface ProtectedOpImplementation { + /** + * Call [f] iin mutually exclusive mode, it means that only one invocation + * can be active at a time, all the rest are waiting until the current operation + * will finish. + */ + operator fun invoke(f: ()->T): T +} + + +/** + * Get the platform-depended implementation of a mutex-protected operation. + */ +expect fun ProtectedOp(): ProtectedOpImplementation \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/utools/collections.kt b/src/commonMain/kotlin/net/sergeych/utools/collections.kt new file mode 100644 index 0000000..b33eba9 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/utools/collections.kt @@ -0,0 +1,12 @@ +package net.sergeych.utools + +/** + * Scan the collection and return the first non-null result of the [predicate] on it. + * If all the elements give null with predicate call, returns null. + * + * Note that collection is scanned only to the first non-null predicate result. + */ +fun Collection.firstNonNull(predicate: (T)->R?): R? { + for( x in this ) predicate(x)?.let { return it } + return null +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/utools/packing.kt b/src/commonMain/kotlin/net/sergeych/utools/packing.kt new file mode 100644 index 0000000..0b5ac85 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/utools/packing.kt @@ -0,0 +1,46 @@ +package net.sergeych.utools + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer +import net.sergeych.bintools.toDataSource +import net.sergeych.bipack.BipackDecoder +import net.sergeych.bipack.BipackEncoder + +/** + * Effectively pack anyk nullable object. The result could be effectively packed + * in turn as a part of a more complex structure. + * + * To avoid packing non-null mark, + * we use a zero-size array, which, if in turn encoded, packs into a single + * zero byte. Thus, we avoid extra byte spending for unnecessary null + * check. + */ +inline fun pack(element: T?): UByteArray = pack(serializer(), element) + +/** + * Unpack nullable data packed with [pack] + */ +inline fun unpack(encoded: UByteArray): T = + unpack(serializer(), encoded) + +/** + * Effectively pack anyk nullable object. The result could be effectively packed + * in turn as a part of a more complex structure. + * + * To avoid packing non-null mark, + * we use a zero-size array, which, if in turn encoded, packs into a single + * zero byte. Thus, we avoid extra byte spending for unnecessary null + * check. + */ +fun pack(serializer: KSerializer, element: T?): UByteArray = + if (element == null) ubyteArrayOf() + else BipackEncoder.encode(serializer,element).toUByteArray() + + +/** + * Unpack nullable data packed with [pack] + */ +@Suppress("UNCHECKED_CAST") +fun unpack(serializer: KSerializer, encoded: UByteArray): T = + if (encoded.isEmpty()) null as T + else BipackDecoder.decode(encoded.toByteArray().toDataSource(),serializer) diff --git a/src/commonMain/kotlin/net/sergeych/utools/time.kt b/src/commonMain/kotlin/net/sergeych/utools/time.kt new file mode 100644 index 0000000..6c0ae50 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/utools/time.kt @@ -0,0 +1,12 @@ +@file:Suppress("unused") + +package net.sergeych.utools + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +fun now(): Instant = Clock.System.now() +fun nowToSeconds(): Instant = Clock.System.now().truncateToSeconds() + +fun Instant.truncateToSeconds(): Instant = + Instant.fromEpochSeconds(toEpochMilliseconds()/1000) \ No newline at end of file diff --git a/src/commonMain/kotlin/org/komputing/khash/keccak/Keccak.kt b/src/commonMain/kotlin/org/komputing/khash/keccak/Keccak.kt new file mode 100644 index 0000000..6a45600 --- /dev/null +++ b/src/commonMain/kotlin/org/komputing/khash/keccak/Keccak.kt @@ -0,0 +1,183 @@ +package org.komputing.khash.keccak + +import com.ionspin.kotlin.bignum.integer.BigInteger +import org.komputing.khash.keccak.extensions.fillWith +import kotlin.math.min + +object Keccak { + + private val BIT_65 = BigInteger.ONE shl (64) + private val MAX_64_BITS = BIT_65 - BigInteger.ONE + + fun digest(value: ByteArray, parameter: KeccakParameter): ByteArray { + val uState = IntArray(200) + val uMessage = convertToUInt(value) + + var blockSize = 0 + var inputOffset = 0 + + // Absorbing phase + while (inputOffset < uMessage.size) { + blockSize = min(uMessage.size - inputOffset, parameter.rateInBytes) + for (i in 0 until blockSize) { + uState[i] = uState[i] xor uMessage[i + inputOffset] + } + + inputOffset += blockSize + + if (blockSize == parameter.rateInBytes) { + doF(uState) + blockSize = 0 + } + } + + // Padding phase + uState[blockSize] = uState[blockSize] xor parameter.d + if (parameter.d and 0x80 != 0 && blockSize == parameter.rateInBytes - 1) { + doF(uState) + } + + uState[parameter.rateInBytes - 1] = uState[parameter.rateInBytes - 1] xor 0x80 + doF(uState) + + // Squeezing phase + val byteResults = mutableListOf() + var tOutputLen = parameter.outputLengthInBytes + while (tOutputLen > 0) { + blockSize = min(tOutputLen, parameter.rateInBytes) + for (i in 0 until blockSize) { + byteResults.add(uState[i].toByte().toInt().toByte()) + } + + tOutputLen -= blockSize + if (tOutputLen > 0) { + doF(uState) + } + } + + return byteResults.toByteArray() + } + + private fun doF(uState: IntArray) { + val lState = Array(5) { Array(5) { BigInteger.ZERO } } + + for (i in 0..4) { + for (j in 0..4) { + val data = IntArray(8) + val index = 8 * (i + 5 * j) + uState.copyInto(data, 0, index, index + data.size) + lState[i][j] = convertFromLittleEndianTo64(data) + } + } + roundB(lState) + + uState.fillWith(0) + for (i in 0..4) { + for (j in 0..4) { + val data = convertFrom64ToLittleEndian(lState[i][j]) + data.copyInto(uState, 8 * (i + 5 * j)) + } + } + } + + /** + * Permutation on the given state. + */ + private fun roundB(state: Array>) { + var lfsrState = 1 + for (round in 0..23) { + val c = arrayOfNulls(5) + val d = arrayOfNulls(5) + + // θ step + for (i in 0..4) { + c[i] = state[i][0].xor(state[i][1]).xor(state[i][2]).xor(state[i][3]).xor(state[i][4]) + } + + for (i in 0..4) { + d[i] = c[(i + 4) % 5]!!.xor(c[(i + 1) % 5]!!.leftRotate64(1)) + } + + for (i in 0..4) { + for (j in 0..4) { + state[i][j] = state[i][j].xor(d[i]!!) + } + } + + // ρ and π steps + var x = 1 + var y = 0 + var current = state[x][y] + for (i in 0..23) { + val tX = x + x = y + y = (2 * tX + 3 * y) % 5 + + val shiftValue = current + current = state[x][y] + + state[x][y] = shiftValue.leftRotate64Safely((i + 1) * (i + 2) / 2) + } + + // χ step + for (j in 0..4) { + val t = arrayOfNulls(5) + for (i in 0..4) { + t[i] = state[i][j] + } + + for (i in 0..4) { + // ~t[(i + 1) % 5] + val invertVal = t[(i + 1) % 5]!!.xor(MAX_64_BITS) + // t[i] ^ ((~t[(i + 1) % 5]) & t[(i + 2) % 5]) + state[i][j] = t[i]!!.xor(invertVal.and(t[(i + 2) % 5]!!)) + } + } + + // ι step + for (i in 0..6) { + lfsrState = (lfsrState shl 1 xor (lfsrState shr 7) * 0x71) % 256 + // pow(2, i) - 1 + val bitPosition = (1 shl i) - 1 + if (lfsrState and 2 != 0) { + state[0][0] = state[0][0].xor(BigInteger.ONE shl bitPosition) + } + } + } + } + + /** + * Converts the given [data] array to an [IntArray] containing UInt values. + */ + private fun convertToUInt(data: ByteArray) = IntArray(data.size) { + data[it].toInt() and 0xFF + } + + /** + * Converts the given [data] array containing the little endian representation of a number to a [BigInteger]. + */ + private fun convertFromLittleEndianTo64(data: IntArray): BigInteger { + val value = data.map { it.toString(16) } + .map { if (it.length == 2) it else "0$it" } + .reversed() + .joinToString("") + return BigInteger.parseString(value, 16) + } + + /** + * Converts the given [BigInteger] to a little endian representation as an [IntArray]. + */ + private fun convertFrom64ToLittleEndian(uLong: BigInteger): IntArray { + val asHex = uLong.toString(16) + val asHexPadded = "0".repeat((8 * 2) - asHex.length) + asHex + return IntArray(8) { + ((7 - it) * 2).let { pos -> + asHexPadded.substring(pos, pos + 2).toInt(16) + } + } + } + + private fun BigInteger.leftRotate64Safely(rotate: Int) = leftRotate64(rotate % 64) + + private fun BigInteger.leftRotate64(rotate: Int) = (this shr (64 - rotate)).add(this shl rotate).mod(BIT_65) +} diff --git a/src/commonMain/kotlin/org/komputing/khash/keccak/KeccakParameter.kt b/src/commonMain/kotlin/org/komputing/khash/keccak/KeccakParameter.kt new file mode 100644 index 0000000..57570e9 --- /dev/null +++ b/src/commonMain/kotlin/org/komputing/khash/keccak/KeccakParameter.kt @@ -0,0 +1,22 @@ +@file:Suppress("unused") + +package org.komputing.khash.keccak + +/** + * Parameters defining the FIPS 202 standard. + */ +enum class KeccakParameter(val rateInBytes: Int,val outputLengthInBytes: Int, val d: Int) { + + KECCAK_224(144, 28, 0x01), + KECCAK_256(136, 32, 0x01), + KECCAK_384(104, 48, 0x01), + KECCAK_512(72, 64, 0x01), + + SHA3_224(144, 28, 0x06), + SHA3_256(136, 32, 0x06), + SHA3_384(104, 48, 0x06), + SHA3_512(72, 64, 0x06), + + SHAKE128(168, 32, 0x1F), + SHAKE256(136, 64, 0x1F) +} diff --git a/src/commonMain/kotlin/org/komputing/khash/keccak/extensions/IntArrayExtensions.kt b/src/commonMain/kotlin/org/komputing/khash/keccak/extensions/IntArrayExtensions.kt new file mode 100644 index 0000000..c36c21a --- /dev/null +++ b/src/commonMain/kotlin/org/komputing/khash/keccak/extensions/IntArrayExtensions.kt @@ -0,0 +1,42 @@ +package org.komputing.khash.keccak.extensions + +/** + * Assigns the specified int value to each element of the specified + * range in the specified array of ints. The range to be filled + * extends from index fromIndex, inclusive, to index + * toIndex, exclusive. (If fromIndex==toIndex, the + * range to be filled is empty.) + * + * @param fromIndex the index of the first element (inclusive) to be + * filled with the specified value + * @param toIndex the index of the last element (exclusive) to be + * filled with the specified value + * @param value the value to be stored in all elements of the array + * @throws IllegalArgumentException if fromIndex > toIndex + * @throws ArrayIndexOutOfBoundsException if fromIndex < 0 or + * toIndex > a.length + */ +internal fun IntArray.fillWith(value: Int, fromIndex: Int = 0, toIndex: Int = this.size) { + if (fromIndex > toIndex) { + throw IllegalArgumentException( + "fromIndex($fromIndex) > toIndex($toIndex)" + ) + } + + if (fromIndex < 0) { + throw ArrayIndexOutOfBoundsException(fromIndex) + } + if (toIndex > this.size) { + throw ArrayIndexOutOfBoundsException(toIndex) + } + + for (i in fromIndex until toIndex) + this[i] = value +} + +/** + * Constructs a new [ArrayIndexOutOfBoundsException] + * class with an argument indicating the illegal index. + * @param index the illegal index. + */ +internal class ArrayIndexOutOfBoundsException(index: Int) : Throwable("Array index out of range: $index") diff --git a/src/commonMain/kotlin/org/komputing/khash/keccak/extensions/PublicExtensions.kt b/src/commonMain/kotlin/org/komputing/khash/keccak/extensions/PublicExtensions.kt new file mode 100644 index 0000000..d68f972 --- /dev/null +++ b/src/commonMain/kotlin/org/komputing/khash/keccak/extensions/PublicExtensions.kt @@ -0,0 +1,19 @@ +@file:Suppress("unused") +package org.komputing.khash.keccak.extensions + +import org.komputing.khash.keccak.Keccak +import org.komputing.khash.keccak.KeccakParameter + +/** + * Computes the proper Keccak digest of [this] byte array based on the given [parameter] + */ +fun ByteArray.digestKeccak(parameter: KeccakParameter): ByteArray { + return Keccak.digest(this, parameter) +} + +/** + * Computes the proper Keccak digest of [this] string based on the given [parameter] + */ +fun String.digestKeccak(parameter: KeccakParameter): ByteArray { + return Keccak.digest(encodeToByteArray(), parameter) +} diff --git a/src/commonTest/kotlin/KeysTest.kt b/src/commonTest/kotlin/KeysTest.kt new file mode 100644 index 0000000..7570847 --- /dev/null +++ b/src/commonTest/kotlin/KeysTest.kt @@ -0,0 +1,58 @@ +import com.ionspin.kotlin.crypto.secretbox.SecretBox +import com.ionspin.kotlin.crypto.util.decodeFromUByteArray +import com.ionspin.kotlin.crypto.util.encodeToUByteArray +import kotlinx.coroutines.test.runTest +import net.sergeych.crypto.* +import net.sergeych.utools.pack +import net.sergeych.utools.unpack +import kotlin.test.* + +class KeysTest { + @Test + fun testCreationAndMap() = runTest { + initCrypto() + val (stk,pbk) = Key.Signing.pair() + + val x = mapOf( stk to "STK!", pbk to "PBK!") + assertEquals("STK!", x[stk]) + val s1 = Key.Signing(stk.packed) + assertEquals(stk, s1) + assertEquals("STK!", x[s1]) + assertEquals("PBK!", x[pbk]) + + val data = "8 rays dev!".encodeToUByteArray() + val data1 = "8 rays dev!".encodeToUByteArray() + val s = SignedBox.Seal.create(stk, data) + assertTrue(s.verify(data)) + + data1[0] = 0x01u + assertFalse(s.verify(data1)) + val p2 = Key.Signing.pair() + val p3 = Key.Signing.pair() + + val ms = SignedBox(data, s1) + p2.signing + + // non tampered: + val ms1 = unpack(pack(ms)) + assertContentEquals(data, ms1.message) + assertTrue(pbk in ms1) + assertTrue(p2.verifying in ms1) + assertTrue(p3.verifying !in ms1) + + assertThrows { + unpack(pack(ms).also { it[3] = 1u }) + } + } + + @Test + fun secretEncryptTest() = runTest { + initCrypto() + val key = SecretBox.keygen() + val key1 = SecretBox.keygen() + assertEquals("hello", decrypt(key, encrypt(key, "hello".encodeToUByteArray())).decodeFromUByteArray()) + assertThrows { + decrypt(key, encrypt(key1, "hello".encodeToUByteArray())).decodeFromUByteArray() + } + } + +} \ No newline at end of file diff --git a/src/commonTest/kotlin/PackTest.kt b/src/commonTest/kotlin/PackTest.kt new file mode 100644 index 0000000..42522f4 --- /dev/null +++ b/src/commonTest/kotlin/PackTest.kt @@ -0,0 +1,43 @@ +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import net.sergeych.crypto.initCrypto +import net.sergeych.kiloparsec.Transport +import net.sergeych.utools.nowToSeconds +import net.sergeych.utools.pack +import net.sergeych.utools.unpack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.microseconds + +class PackTest { + inline fun check(x: T?) { + assertEquals(x, unpack(pack(x))) + } + @Test + fun testNullPack() = runTest { + initCrypto() + val d = pack("Hello") + assertEquals(6, d.size) + check(1) + check(2L) + check(1.00) + check("hello") + check(null) + check(null) + } + @Test + fun testTimePack() = runTest { + initCrypto() + val t1 = nowToSeconds() + val t2 = t1 + 1.microseconds + assertEquals(t1, unpack(pack(t2))) + } + + @Test + fun packBlocks() { + val b1 = Transport.Block.Call(1u, "foobar", ubyteArrayOf(1u,2u,3u)) + val p1 = pack(b1 as Transport.Block) + val b2 = unpack(p1) + assertEquals(b1,b2) + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/ToolsTest.kt b/src/commonTest/kotlin/ToolsTest.kt new file mode 100644 index 0000000..c01e476 --- /dev/null +++ b/src/commonTest/kotlin/ToolsTest.kt @@ -0,0 +1,20 @@ +import kotlinx.coroutines.test.runTest +import net.sergeych.crypto.createContrail +import net.sergeych.crypto.initCrypto +import net.sergeych.crypto.isValidContrail +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ToolsTest { + @Test + fun testContrails() = runTest { + initCrypto() + val c = createContrail(ubyteArrayOf(1u, 2u, 3u, 4u, 5u)) + assertEquals(134u, c[0]) + assertTrue { isValidContrail(c) } + c[2] = 11u + assertFalse { isValidContrail(c) } + } +} \ No newline at end of file diff --git a/src/commonTest/kotlin/TransportTest.kt b/src/commonTest/kotlin/TransportTest.kt new file mode 100644 index 0000000..1920e34 --- /dev/null +++ b/src/commonTest/kotlin/TransportTest.kt @@ -0,0 +1,288 @@ +import com.ionspin.kotlin.crypto.keyexchange.KeyExchange +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.test.runTest +import net.sergeych.crypto.Key +import net.sergeych.crypto.initCrypto +import net.sergeych.kiloparsec.command +import net.sergeych.kiloparsec.* +import net.sergeych.mp_logger.Log +import kotlin.test.* + +private var dcnt = 0 +fun createTestDevice(): Pair { + val p1 = Channel(256) + val p2 = Channel(256) + val id = ++dcnt + val d1 = object : Transport.Device { + override val input: ReceiveChannel = p1 + override val output: SendChannel = p2 + override suspend fun close() { + p2.close() + } + + override fun toString(): String { + return "D1:$id" + } + } + val d2 = object : Transport.Device { + override val input: ReceiveChannel = p2 + override val output: SendChannel = p1 + override suspend fun close() { + p1.close() + } + + override fun toString(): String { + return "D2:$id" + } + } + return d1 to d2 +} + +class TransportTest { + + + @Test + fun testTransportL0AndEncryption() = runTest { + initCrypto() + val cmdPing by command() + val cmdSlow by command() + + Log.connectConsole() + Log.defaultLevel = Log.Level.DEBUG + val (d1, d2) = createTestDevice() + val l1 = LocalInterface().apply { + on(cmdPing) { + "p1: $it" + } + } + val l2 = LocalInterface().apply { + on(cmdPing) { + "p2: $it" + } + on(cmdSlow) { + delay(100) + "done" + } + } + val t1 = Transport(d1, l1, Unit) + val t2 = Transport(d2, l2, Unit) + + val clip = KeyExchange.keypair() + val serp = KeyExchange.keypair() + val clisk = KeyExchange.clientSessionKeys(clip.publicKey, clip.secretKey, serp.publicKey) + val sersk = KeyExchange.serverSessionKeys(serp.publicKey, serp.secretKey, clip.publicKey) + val pser = KiloParams(true, t1, sersk, Unit, null, t1) + val pcli = KiloParams(false, t2, clisk, Unit, null, t2) + + assertContentEquals(pcli.token, pser.token) + assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!") + assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!") + assertEquals(pser.decryptString(pcli.encrypt("hello2!")), "hello2!") + assertEquals(pser.decryptString(pcli.encrypt("hello3!")), "hello3!") + assertEquals(pser.decryptString(pcli.encrypt("hello!")), "hello!") + + // test nonce increment + assertFalse { pcli.encrypt("once") contentEquals pcli.encrypt("once") } + + assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!") + assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!") + assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!") + assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!") + assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!") + assertEquals(pcli.decryptString(pser.encrypt("hello!")), "hello!") + + + coroutineScope { + val j1 = launch { t1.run() } + val j2 = launch { t2.run() } + launch { + assertThrows { + t1.call(cmdSlow, "foo1") + } + } + assertEquals("p2: foo", t1.call(cmdPing, "foo")) + assertEquals("p1: bar", t2.call(cmdPing, "bar")) + assertEquals("p2: foo", t1.call(cmdPing, "foo")) + j1.cancelAndJoin() + j2.cancelAndJoin() + } + d1.close() + d2.close() + } + + @Test + fun testConnection() = runTest { + initCrypto() + + val cmdPing by command() + val cmdPush by command() + val cmdGetToken by command() + Log.connectConsole() +// Log.defaultLevel = Log.Level.DEBUG + val (d1, d2) = createTestDevice() + val serverInterface = KiloInterface().apply { + on(cmdPing) { + "pong! [$it]" + } + on(cmdGetToken) { + sessionToken + } + registerError { IllegalStateException() } + registerError { IllegalArgumentException(it) } + } + val kiloServerConnection = KiloServerConnection(serverInterface, d1, "server session") + launch { kiloServerConnection.run() } + + val clientInterface = KiloInterface().apply { + on(cmdPush) { + "server push: $it" + } + on(cmdPing) { + "client pong: $it" + } + } + val client = KiloClientConnection(clientInterface, d2, "client session") + launch { client.run() } + assertEquals("pong! [hello]", client.call(cmdPing, "hello")) + assertEquals("pong! [foo]", client.call(cmdPing, "foo")) + assertEquals("client pong: foo", kiloServerConnection.call(cmdPing, "foo")) + assertEquals("server push: bar", kiloServerConnection.call(cmdPush, "bar")) + + assertContentEquals(client.token(), client.call(cmdGetToken)) + d1.close() + d2.close() + } + + @Test + fun testClient() = runTest { + initCrypto() + + val cmdPing by command() + val cmdPush by command() + val cmdGetToken by command() + val cmdGetClientId by command() + val cmdChainCallServer1 by command() + val cmdChainCallClient1 by command() + val cmdChainCallServer2 by command() + val cmdChainCallClient2 by command() + Log.connectConsole() +// Log.defaultLevel = Log.Level.DEBUG + val (d1, d2) = createTestDevice() + + val serverId = Key.Signing.pair() + val clientId = Key.Signing.pair() + + val serverInterface = KiloInterface().apply { + on(cmdPing) { + "pong! [$it]" + } + on(cmdGetToken) { + sessionToken + } + on(cmdGetClientId) { + remoteIdentity + } + on(cmdChainCallServer1) { + remote.call(cmdChainCallClient1, it + "-s1") + } + on(cmdChainCallServer2) { + remote.call(cmdChainCallClient2, "$it-s2") + } + registerError { IllegalStateException() } + registerError { IllegalArgumentException(it) } + } + val kiloServerConnection = KiloServerConnection(serverInterface, d1, "server session", serverId.signing) + launch { kiloServerConnection.run() } + + var cnt = 0 + val client = KiloClient { + session { "client session!" } + secretIdKey = clientId.signing + local { + on(cmdPush) { + "server push: $it" + } + on(cmdPing) { + "client pong: $it" + } + on(cmdChainCallClient1) { + remote.call(cmdChainCallServer2,"$it-c1") + } + on(cmdChainCallClient2) { "$it-c2" } + } + connect { + if (cnt++ > 0) { + cancel() + fail("connect called once again") + } + d2 + } + } + assertEquals("pong! [hello]", client.call(cmdPing, "hello")) + assertEquals("pong! [foo]", client.call(cmdPing, "foo")) + assertEquals("client pong: foo", kiloServerConnection.call(cmdPing, "foo")) + assertEquals("server push: bar", kiloServerConnection.call(cmdPush, "bar")) + + assertEquals("**-s1-c1-s2-c2", client.call(cmdChainCallServer1, "**")) + d1.close() + d2.close() + client.close() +// assertEquals(1, connectionCounter) + } + + @Test + fun testAuthentication() = runTest { + initCrypto() + + val cmdPing by command() + val cmdPush by command() + val cmdGetToken by command() + Log.connectConsole() + Log.defaultLevel = Log.Level.DEBUG + val (d1, d2) = createTestDevice() + val serverInterface = KiloInterface().apply { + on(cmdPing) { + "pong! [$it]" + } + on(cmdGetToken) { + sessionToken + } + registerError { IllegalStateException() } + registerError { IllegalArgumentException(it) } + } + val kiloServerConnection = KiloServerConnection(serverInterface, d1, "server session") + launch { kiloServerConnection.run() } + + var cnt = 0 + val client = KiloClient { + session { "client session!" } + local { + on(cmdPush) { + "server push: $it" + } + on(cmdPing) { + "client pong: $it" + } + } + connect { + if (cnt++ > 0) { + cancel() + fail("connect called once again") + } + d2 + } + } + assertEquals("pong! [hello]", client.call(cmdPing, "hello")) + assertEquals("pong! [foo]", client.call(cmdPing, "foo")) + assertEquals("client pong: foo", kiloServerConnection.call(cmdPing, "foo")) + assertEquals("server push: bar", kiloServerConnection.call(cmdPush, "bar")) + + assertContentEquals(client.call(cmdGetToken), client.token()) + client.close() + d1.close() + d2.close() + } +} diff --git a/src/commonTest/kotlin/assertThrows.kt b/src/commonTest/kotlin/assertThrows.kt new file mode 100644 index 0000000..dc63eeb --- /dev/null +++ b/src/commonTest/kotlin/assertThrows.kt @@ -0,0 +1,13 @@ +import kotlin.test.fail + +inline fun assertThrows(f: ()->Unit): T { + val name = T::class.simpleName + try { + f() + fail("expected to throw $name but threw nothing") + } + catch(x: Throwable) { + if( x is T ) return x + fail("expected to throw $name but instead threw ${x::class.simpleName}: $x") + } +} \ No newline at end of file diff --git a/src/jsMain/kotlin/net/sergeych/tools/ProtectedOp.js.kt b/src/jsMain/kotlin/net/sergeych/tools/ProtectedOp.js.kt new file mode 100644 index 0000000..acd7d7d --- /dev/null +++ b/src/jsMain/kotlin/net/sergeych/tools/ProtectedOp.js.kt @@ -0,0 +1,6 @@ +package net.sergeych.tools + +actual fun ProtectedOp(): ProtectedOpImplementation = object : ProtectedOpImplementation { + // JS targets are inherently single-threaded, so we do noting: + override fun invoke(f: () -> T): T = f() +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/net/sergeych/tools/ProtectedOp.jvm.kt b/src/jvmMain/kotlin/net/sergeych/tools/ProtectedOp.jvm.kt new file mode 100644 index 0000000..1114558 --- /dev/null +++ b/src/jvmMain/kotlin/net/sergeych/tools/ProtectedOp.jvm.kt @@ -0,0 +1,8 @@ +package net.sergeych.tools + +actual fun ProtectedOp(): ProtectedOpImplementation = object : ProtectedOpImplementation { + private val lock = Object() + override fun invoke(f: () -> T): T { + synchronized(lock) { return f() } + } +} \ No newline at end of file diff --git a/src/jvmTest/kotlin/net/sergeych/kiloparsec/ClientTest.kt b/src/jvmTest/kotlin/net/sergeych/kiloparsec/ClientTest.kt new file mode 100644 index 0000000..eeb5866 --- /dev/null +++ b/src/jvmTest/kotlin/net/sergeych/kiloparsec/ClientTest.kt @@ -0,0 +1,10 @@ +package net.sergeych.kiloparsec + +import kotlin.test.Test + +class ClientTest { + @Test + fun testClient() { + // Todo + } +} \ No newline at end of file diff --git a/src/nativeMain/kotlin/net/sergeych/tools/ProtectedOp.native.kt b/src/nativeMain/kotlin/net/sergeych/tools/ProtectedOp.native.kt new file mode 100644 index 0000000..c772602 --- /dev/null +++ b/src/nativeMain/kotlin/net/sergeych/tools/ProtectedOp.native.kt @@ -0,0 +1,13 @@ +package net.sergeych.tools + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +actual fun ProtectedOp(): ProtectedOpImplementation = object : ProtectedOpImplementation { + private val lock = SynchronizedObject() + override fun invoke(f: () -> T): T { + synchronized(lock) { + return f() + } + } +} \ No newline at end of file