Compare commits

...

9 Commits

27 changed files with 223 additions and 100 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ build/
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea
*.iws
*.iml
*.ipr

7
.idea/.gitignore generated vendored
View File

@ -1,10 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
/artifacts/crypto2_js_0_1_0_SNAPSHOT.xml
/artifacts/crypto2_jvm_0_1_0_SNAPSHOT.xml

View File

@ -1,6 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" name="crypto2-js-0.1.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="crypto2-js-0.1.1-SNAPSHOT.jar" />
</artifact>
</component>

View File

@ -1,8 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" name="crypto2-js-1.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="crypto2-js-1.0-SNAPSHOT.jar">
<element id="module-output" name="crypto2.jsMain" />
</root>
</artifact>
</component>

View File

@ -1,6 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" name="crypto2-jvm-0.1.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="crypto2-jvm-0.1.1-SNAPSHOT.jar" />
</artifact>
</component>

View File

@ -1,8 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" name="crypto2-jvm-1.0-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="crypto2-jvm-1.0-SNAPSHOT.jar">
<element id="module-output" name="crypto2.jvmMain" />
</root>
</artifact>
</component>

View File

@ -1,8 +0,0 @@
<component name="ArtifactManager">
<artifact type="jar" name="crypto2-wasm-js-0.1.1-SNAPSHOT">
<output-path>$PROJECT_DIR$/build/libs</output-path>
<root id="archive" name="crypto2-wasm-js-0.1.1-SNAPSHOT.jar">
<element id="module-output" name="crypto2.wasmJsMain" />
</root>
</artifact>
</component>

View File

@ -1,5 +1,29 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<DBN-PSQL>
<case-options enabled="true">
<option name="KEYWORD_CASE" value="lower" />
<option name="FUNCTION_CASE" value="lower" />
<option name="PARAMETER_CASE" value="lower" />
<option name="DATATYPE_CASE" value="lower" />
<option name="OBJECT_CASE" value="preserve" />
</case-options>
<formatting-settings enabled="false" />
</DBN-PSQL>
<DBN-SQL>
<case-options enabled="true">
<option name="KEYWORD_CASE" value="lower" />
<option name="FUNCTION_CASE" value="lower" />
<option name="PARAMETER_CASE" value="lower" />
<option name="DATATYPE_CASE" value="lower" />
<option name="OBJECT_CASE" value="preserve" />
</case-options>
<formatting-settings enabled="false">
<option name="STATEMENT_SPACING" value="one_line" />
<option name="CLAUSE_CHOP_DOWN" value="chop_down_if_statement_long" />
<option name="ITERATION_ELEMENTS_WRAPPING" value="chop_down_if_not_single" />
</formatting-settings>
</DBN-SQL>
<ScalaCodeStyleSettings>
<option name="MULTILINE_STRING_CLOSING_QUOTES_ON_NEW_LINE" value="true" />
</ScalaCodeStyleSettings>

2
.idea/gradle.xml generated
View File

@ -5,6 +5,7 @@
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="/usr/local/Cellar/gradle/7.6/libexec" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
@ -12,5 +13,6 @@
</option>
</GradleProjectSettings>
</option>
<option name="parallelModelFetch" value="true" />
</component>
</project>

View File

@ -1,7 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ReplaceUntilWithRangeUntil" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="StructuralWrap" enabled="false" level="TYPO" enabled_by_default="false" />
</profile>
</component>

6
.idea/kotlinc.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.20" />
</component>
</project>

3
.idea/misc.xml generated
View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17 (5)" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View File

@ -2,6 +2,16 @@
Kotlin Multiplatform cryptographic primitives using modern strong cryptography.
## v0.9.0 for kotlin 2.2.21, new kotlin time compatible
The primary goal was to fix kotlin-caused incompatibilities with kotlinx.datetime.Instant and Clock; the upgrade shoud be in-place
replacement providing calling code ises `kotlin.time.Instant` and
`kotlin.time.Clock` respectively. No other changes are needed.
Also we start to add small syntax sugar methods.
## v.0.8.4 is built for all platform, IOS and wasmJS included
Cryptographic API works exactly the same and compiles to any platform supported listed below with no change in source code.
All primitives meant to send over the network or store are `kotlinx.serialization` compatible, serializers included.
@ -21,7 +31,7 @@ repositories {
maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven")
}
dependencies {
import("net.sergeych:crypto2:0.7.1-SNAPSHOT")
import("net.sergeych:crypto2:0.8.4")
}
```

View File

@ -13,14 +13,14 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("multiplatform") version "2.0.21"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
kotlin("multiplatform") version "2.2.21"
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21"
id("org.jetbrains.dokka") version "1.9.20"
`maven-publish`
}
group = "net.sergeych"
version = "0.8.3-SNAPSHOT"
version = "0.9.1-SNAPSHOT"
repositories {
mavenCentral()
@ -31,12 +31,8 @@ repositories {
}
kotlin {
jvm {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
jvmToolchain(21)
jvm()
js {
browser()
nodejs()
@ -44,12 +40,12 @@ kotlin {
linuxX64()
linuxArm64()
// macosX64()
// macosArm64()
// iosX64()
// iosArm64()
// iosSimulatorArm64()
// mingwX64()
macosX64()
macosArm64()
iosX64()
iosArm64()
iosSimulatorArm64()
mingwX64()
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
@ -60,26 +56,27 @@ kotlin {
languageSettings.optIn("kotlinx.serialization.ExperimentalSerializationApi")
languageSettings.optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
languageSettings.optIn("kotlin.ExperimentalUnsignedTypes")
languageSettings.optIn("kotlin.time.ExperimentalTime")
}
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("net.sergeych:multiplatform-crypto-libsodium-bindings:0.9.6")
implementation(project.dependencies.platform("org.kotlincrypto.hash:bom:0.5.1"))
implementation("org.kotlincrypto.hash:sha3")
api("com.ionspin.kotlin:bignum:0.3.9")
api("net.sergeych:mp_bintools:0.1.12-SNAPSHOT")
api("com.ionspin.kotlin:bignum:0.3.10")
api("net.sergeych:mp_bintools:0.3.2")
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.slf4j:slf4j-simple:2.0.9")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
}
}
val native by creating {
@ -115,6 +112,14 @@ publishing {
}
}
tasks.named<Test>("jvmTest") {
// Ignore Kotlin synthetic classes generated from files that look like tests
exclude("**/*TestKt.class")
exclude("**/*TestsKt.class")
exclude("**/*AssertThrowsKt.class")
exclude("**/*Test_toolsKt.class")
}
tasks.dokkaHtml.configure {
outputDirectory.set(buildDir.resolve("dokka"))
dokkaSourceSets {

View File

@ -9,3 +9,7 @@
#
kotlin.code.style=official
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true

View File

@ -10,6 +10,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -321,6 +321,27 @@ sealed class Container {
}.build()
}
/**
* Decrypt the container with a password. It scans all key ids for
* these with `KDP` params, e.g., derived from password, and try to
* derive keys from the password and decrypt the container. If there are
* no derivable keys, or all of them failed to decrypt, returns null.
* It could be long operation if there are multiple derivable keys with heavy
* KDF. See [PBKD] and [KDF] for more.
*
* @return decrypted data or null
*/
@Suppress("unused")
fun decryptWithPassword(password: String): UByteArray? {
for( id in this.keyIds ) {
id.kdp?.let { kdp ->
decryptWith(kdp.deriveKey(password))?.let { return it }
}
}
return null
}
companion object {
/**

View File

@ -60,6 +60,12 @@ sealed class Multikey {
*/
abstract fun check(keys: Iterable<VerifyingPublicKey>): Boolean
/**
* Step towards automated picking necessary keys.
* Return all the keys mentioned in this multikey condition.
*/
abstract fun mentionedKeys(): Set<VerifyingPublicKey>
/**
* Check that [verifyingKeys] satisfy the multikey condition
*/
@ -97,6 +103,10 @@ sealed class Multikey {
}
return false
}
override fun mentionedKeys(): Set<VerifyingPublicKey> {
return validKeys
}
}
/**
@ -119,6 +129,10 @@ sealed class Multikey {
}
return false
}
override fun mentionedKeys(): Set<VerifyingPublicKey> {
return validKeys.flatMap { it.mentionedKeys() }.toSet()
}
}
/**
@ -129,6 +143,7 @@ sealed class Multikey {
@Serializable
object AnyKey : Multikey() {
override fun check(keys: Iterable<VerifyingPublicKey>): Boolean = true
override fun mentionedKeys(): Set<VerifyingPublicKey> = emptySet()
}

View File

@ -10,7 +10,7 @@
package net.sergeych.crypto2
import kotlinx.datetime.Instant
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import net.sergeych.bipack.BipackEncoder
import net.sergeych.bipack.decodeFromBipack

View File

@ -10,7 +10,7 @@
package net.sergeych.crypto2
import kotlinx.datetime.Instant
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import net.sergeych.bipack.BipackDecoder

View File

@ -10,7 +10,7 @@
package net.sergeych.crypto2
import kotlinx.datetime.Instant
import kotlin.time.Instant
interface SigningKey: KeyInstance {
val verifyingKey: VerifyingPublicKey

View File

@ -11,7 +11,7 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.signature.Signature
import kotlinx.datetime.Instant
import kotlin.time.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

View File

@ -46,13 +46,54 @@ class SymmetricKey(
data class WithNonce(
val cipherData: UByteArray,
val nonce: UByteArray,
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as WithNonce
if (!cipherData.contentEquals(other.cipherData)) return false
if (!nonce.contentEquals(other.nonce)) return false
return true
}
override fun hashCode(): Int {
var result = cipherData.contentHashCode()
result = 31 * result + nonce.contentHashCode()
return result
}
}
override fun encryptWithNonce(plainData: UByteArray, nonce: UByteArray, randomFill: IntRange?): UByteArray {
require(nonce.size == nonceLength)
return SecretBox.easy(WithFill.encode(plainData, randomFill), nonce, keyBytes)
}
/**
* Compact authenticated encryption with a caller-provided nonce.
*
* This method is meant for storage formats where the nonce is derived or stored elsewhere
* and every byte of encoded output matters, for example short encrypted file names.
* The output is the raw SecretBox ciphertext only: [plainData] size plus 16 authentication
* bytes. The nonce is not stored in the output and must be reproduced exactly for
* [decryptCompactWithNonce].
*
* Unlike [encryptWithNonce], this method does not wrap [plainData] with [WithFill]. For
* short payloads this usually saves only 1 byte over [encryptWithNonce], because both
* methods already require the caller to manage the nonce separately. Use it only when this
* small size gain is important or when the surrounding format explicitly requires raw
* SecretBox output.
*
* Never reuse the same nonce with the same key for different plaintext. Reusing a
* `(key, nonce)` pair with SecretBox breaks the security of the stream cipher.
*/
fun encryptCompactWithNonce(plainData: UByteArray, nonce: UByteArray): UByteArray {
require(nonce.size == nonceLength)
return SecretBox.easy(plainData, nonce, keyBytes)
}
override val nonceBytesLength: Int = nonceLength
override val id by lazy {
@ -64,6 +105,22 @@ class SymmetricKey(
WithFill.decode(SecretBox.openEasy(cipherData, nonce, keyBytes))
}
/**
* Compact authenticated decryption with a caller-provided nonce.
*
* Decrypts data produced by [encryptCompactWithNonce]. The nonce is not read from
* [cipherData] and must be the exact same nonce that was used for encryption. Use this
* method only for compact formats that intentionally omit both the nonce and the [WithFill]
* wrapper.
*
* @throws DecryptionFailedException if the key, nonce, or cipher data is invalid.
*/
fun decryptCompactWithNonce(cipherData: UByteArray, nonce: UByteArray): UByteArray =
protectDecryption {
require(nonce.size == nonceLength)
SecretBox.openEasy(cipherData, nonce, keyBytes)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SymmetricKey) return false
@ -85,4 +142,4 @@ class SymmetricKey(
val keyLength = crypto_secretbox_KEYBYTES
}
}
}

View File

@ -12,8 +12,8 @@
package net.sergeych.utools
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlin.time.Clock
import kotlin.time.Instant
fun now(): Instant = Clock.System.now()
fun nowToSeconds(): Instant = Clock.System.now().truncateToSeconds()

View File

@ -10,25 +10,17 @@
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Clock
import kotlin.time.Clock
import net.sergeych.crypto2.Hash
import net.sergeych.crypto2.initCrypto
import kotlin.random.Random
import kotlin.random.nextUBytes
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE")
suspend fun <T> sw(label: String, f: suspend () -> T): T {
val t1 = Clock.System.now()
val result = f()
val t2 = Clock.System.now()
// println("$label: ${t2 - t1}")
return result
}
class HashTest {
@Test
fun testEqualMethods() {
@ -77,3 +69,13 @@ class HashTest {
}
@Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE")
suspend fun <T> sw(label: String, f: suspend () -> T): T {
val t1 = Clock.System.now()
val result = f()
val t2 = Clock.System.now()
// println("$label: ${t2 - t1}")
return result
}

View File

@ -11,7 +11,6 @@
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
@ -118,6 +117,44 @@ class KeysTest {
assertContentEquals(src, k1.decrypt(k1.encrypt(src, 7..117)))
}
@Test
fun symmetricKeyCompactTest() = runTest {
initCrypto()
val key = SymmetricKey.new()
val otherKey = SymmetricKey.new()
val src = "readme.md".encodeToUByteArray()
val nonce = key.randomNonce()
val cipher = key.encryptCompactWithNonce(src, nonce)
assertEquals(src.size + 16, cipher.size)
assertTrue(cipher.size < key.encryptWithNonce(src, nonce).size)
assertTrue(cipher.size < key.encrypt(src).size)
assertContentEquals(src, key.decryptCompactWithNonce(cipher, nonce))
assertThrows<DecryptionFailedException> {
otherKey.decryptCompactWithNonce(cipher, nonce)
}
assertThrows<DecryptionFailedException> {
val n2 = nonce.copyOf()
n2[4] = n2[4].inv()
key.decryptCompactWithNonce(cipher, n2)
}
assertThrows<DecryptionFailedException> {
val c2 = cipher.copyOf()
c2[3] = c2[3].inv()
key.decryptCompactWithNonce(c2, nonce)
}
assertThrows<IllegalArgumentException> {
key.encryptCompactWithNonce(src, nonce.dropLast(1).toUByteArray())
}
assertThrows<DecryptionFailedException> {
key.decryptCompactWithNonce(cipher, nonce.dropLast(1).toUByteArray())
}
}
@Test
fun keyExchangeTest() = runTest {
initCrypto()

View File

@ -9,7 +9,7 @@
*/
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import kotlin.time.Instant
import net.sergeych.crypto2.initCrypto
import net.sergeych.utools.nowToSeconds
import net.sergeych.utools.pack