Compare commits

...

18 Commits

Author SHA1 Message Date
84a3e82216 added HKDF, version will be 0.9.3 2026-06-07 13:12:03 +03:00
d402afe1bd version bump to 0.9.2 2026-05-31 00:09:29 +03:00
ff494ab4ef Fix salt derivation and KDF byte derivation 2026-05-30 15:48:48 +03:00
2d55af4e3b fixed kdfForSizeInBytes unneeded parameter 2026-05-30 12:09:07 +03:00
a77fdd79c1 Added kdfForSizeInBytes method to KDF with tests and a new factory method to UniversalRing. 2026-05-30 12:07:22 +03:00
d6b171229f Added support for SafeKeyExchange serialization/deserialization and associated tests. 2026-05-29 15:43:44 +03:00
cd57627cdb fixed key exchange docs error 2026-05-29 15:11:43 +03:00
5c3ff72123 +KDF derive from bytes 2026-05-29 13:22:52 +03:00
d0f1fffe6d Add seeded private key generation 2026-05-29 12:51:14 +03:00
8015a4310b Added support for compact encryption/decryption with caller-provided nonce in SymmetricKey and accompanying tests. Bumped version to 0.9.1-SNAPSHOT. 2026-05-28 18:40:17 +03:00
13dff8d760 v0.9.0: kotlin 2.2.21, kotlin.time fix for deprecated methods in kotlinx.datetime. Should work ok on mac/ios 2025-12-16 19:13:27 +01:00
3bd06ac7ff added Container.decryptWithPassword 2025-12-04 12:14:07 +01:00
ca9ab0f7a0 0.8.5: upgrade kotlin & dependencies 2025-10-24 18:05:25 +02:00
b86ac6f00b 0.8.4 for all platforms 2025-03-27 00:17:59 +03:00
9d338d2f13 merging 2025-03-27 00:14:32 +03:00
5228be33ee fixed README.md 2025-03-26 23:45:31 +03:00
528439f61d 0.8.3 for all platforms 2025-03-26 23:38:40 +03:00
7c97a843e7 Merge pull request 'multiplatform-crypto-libsodium-bindings version with all targets' (#11) from YoungBlood/crypto2:master into master
Reviewed-on: sergeych/crypto2#11
2025-03-25 21:05:37 +03:00
36 changed files with 919 additions and 134 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>

2
.idea/misc.xml generated
View File

@ -3,7 +3,7 @@
<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")
}
```
@ -100,6 +110,10 @@ Keys could be associated with tags. Keyrings are used primarily to store keys in
Using very strong Argon_v2id, and adjustable complexity. Allows storing password key derivation parameters (included in the generated symmetric keys) to re-derive keys later, allows multiple keys derivation. All structures meant to be stored are serializable.
KDF convenience constructors accept salt bytes directly. When the provided salt is not exactly the Argon salt size, it is deterministically normalized with `Hash.Blake2b.deriveSaltFormBytes(...)` before the KDF is created; exact-size salts are used as is.
`KDF.deriveFromBytes(source)` derives exactly the configured KDF key size. Create the KDF with the required key size first, for example through `KDF.createDefault(...)` or `Complexity.kdfForSizeInBytes(...)`.
## Unified keys hierarchy
Allows the application code to use proper key abstraction and work with more key types in the future, e.g. `SigningKey`, `VerifyingKey`, `EncryptingKey` and `DecryptingKey`. Effective key generation and random byte sequence producers.
@ -123,4 +137,4 @@ you need to obtain a license from https://8-rays.dev or [Sergey Chernov]. For op
It will be moved to open source; we also guarantee that it will be moved to open source immediately if the software export restrictions will be lifted. We do not support such practices here at 8-rays.dev and assume open source must be open.
[Sergey Chernov]: https://t.me/real_sergeych
[Sergey Chernov]: https://t.me/real_sergeych

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.3"
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

@ -0,0 +1,78 @@
/*
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
*
* You may use, distribute and modify this code under the
* terms of the private license, which you must obtain from the author
*
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
* real dot sergeych at gmail.
*/
package net.sergeych.crypto2
internal val Hash.supportsHmac: Boolean
get() = when (this) {
Hash.Blake2b, Hash.Sha3_256, Hash.Sha3_384 -> true
Hash.Sha3AndBlake -> false
}
internal val Hash.hkdfMaxOutputSize: Int
get() = hkdfHashSize * 255
private val Hash.hkdfHashSize: Int
get() = digest(ubyteArrayOf()).size
private val Hash.hmacBlockSize: Int
get() = when (this) {
Hash.Blake2b -> 128
Hash.Sha3_256 -> 136
Hash.Sha3_384 -> 104
Hash.Sha3AndBlake -> throw IllegalArgumentException("Hash $this is not supported for HMAC")
}
internal fun hkdf(
hash: Hash,
source: UByteArray,
salt: UByteArray,
info: UByteArray,
size: Int,
): UByteArray {
require(source.isNotEmpty()) { "HKDF source bytes should not be empty" }
require(size > 0) { "HKDF output size should be positive" }
require(hash.supportsHmac) { "Hash $hash is not supported for HKDF" }
require(size <= hash.hkdfMaxOutputSize) {
"HKDF output for $hash is limited to ${hash.hkdfMaxOutputSize} bytes"
}
val hashSize = hash.hkdfHashSize
val extractionSalt = if (salt.isEmpty()) UByteArray(hashSize) else salt
val pseudorandomKey = hmac(hash, extractionSalt, source)
val result = UByteArray(size)
var previous = ubyteArrayOf()
var offset = 0
var counter = 1
while (offset < size) {
previous = hmac(hash, pseudorandomKey, previous + info + counter.toUByte())
val count = minOf(previous.size, size - offset)
for (i in 0..<count) {
result[offset + i] = previous[i]
}
offset += count
counter += 1
}
return result
}
private fun hmac(hash: Hash, key: UByteArray, data: UByteArray): UByteArray {
val blockSize = hash.hmacBlockSize
val normalizedKey = if (key.size > blockSize) hash.digest(key) else key
val keyBlock = UByteArray(blockSize)
for (i in normalizedKey.indices) {
keyBlock[i] = normalizedKey[i]
}
val innerPad = UByteArray(blockSize) { (keyBlock[it].toInt() xor 0x36).toUByte() }
val outerPad = UByteArray(blockSize) { (keyBlock[it].toInt() xor 0x5c).toUByte() }
return hash.digest(outerPad + hash.digest(innerPad + data))
}

View File

@ -129,27 +129,41 @@ enum class Hash(
* strong to brute force, but it is good when you just need to get a deterministic salt of arbitrary size.
*
* _Note that deriving salt of sizes less than hash block will reduce hash strength and should not be allowed
* in situation where strength is of concern while extending its length above hash block size does not improve
* in a situation where strength is of concern while extending its length above hash block size does not improve
* it_. The only reason to use this function is when the desired _salt size_ is not equal to block size, or
* not known beforehand.1
* not known beforehand.
*
* To get a cryptographically safe (more or less) key from password use [KDF] classes, or [KDF.deriveKey]
* and [KDF.deriveMultipleKeys].
*/
fun deriveSalt(base: String, sizeInBytes: Int): UByteArray {
fun deriveSalt(base: String, sizeInBytes: Int): UByteArray =
deriveSaltFromBytes(base.encodeToUByteArray(), sizeInBytes)
/**
* Derive a salt of any size from binary data.
*
* @see deriveSalt
*/
fun deriveSaltFromBytes(base: UByteArray, sizeInBytes: Int): UByteArray {
require(sizeInBytes > 0)
val result = mutableListOf<UByte>()
var round = 0
var src = base.encodeToUByteArray()
var src = base
do {
src = "rnd_${round++}_".encodeToUByteArray() + src
val hash = digest(src)
if (result.size + hash.size <= sizeInBytes) result += hash
else result += hash.slice(0..<(sizeInBytes - result.size))
else result += hash.copyOf(sizeInBytes - result.size)
} while (result.size < sizeInBytes)
return result.toUByteArray()
}
/**
* Typo-compatible alias for [deriveSaltFromBytes].
*/
fun deriveSaltFormBytes(base: UByteArray, sizeInBytes: Int): UByteArray =
deriveSaltFromBytes(base, sizeInBytes)
}

View File

@ -0,0 +1,65 @@
/*
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
*
* You may use, distribute and modify this code under the
* terms of the private license, which you must obtain from the author
*
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
* real dot sergeych at gmail.
*/
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.generichash.GenericHash
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
/**
* Deterministic seed derivation helper.
*
* It compresses arbitrary byte material to an exact seed size using domain-separated BLAKE2b.
* This is intended for already strong random byte material. Do not use it as a password KDF;
* use [KDF] and [PBKD] for passwords and other low-entropy secrets.
*/
object KeySeed {
private const val maxBlake2bOutput = 64
private const val minBlake2bOutput = 16
private val apiKey = "net.sergeych.crypto2.KeySeed.v1".encodeToUByteArray()
/**
* Derive exactly [size] bytes suitable for APIs expecting fixed-size cryptographic seeds.
*
* [purpose] domain-separates seeds for different key types. Use a stable, key-specific value
* when deriving more than one seed from the same [source].
*/
fun fromBytes(source: UByteArray, size: Int, purpose: String = "default"): UByteArray {
require(size > 0) { "seed size should be positive" }
require(source.isNotEmpty()) { "source bytes should not be empty" }
val key = GenericHash.genericHash(apiKey + purpose.encodeToUByteArray(), 32)
if (size <= maxBlake2bOutput) {
val hashSize = size.coerceAtLeast(minBlake2bOutput)
return GenericHash.genericHash(source, hashSize, key).copyOf(size)
}
val result = mutableListOf<UByte>()
var round = 0
while (result.size < size) {
val block = GenericHash.genericHash(
round.toLittleEndianBytes() + size.toLittleEndianBytes() + source,
maxBlake2bOutput,
key
)
val count = minOf(block.size, size - result.size)
result += block.take(count)
round += 1
}
return result.toUByteArray()
}
private fun Int.toLittleEndianBytes(): UByteArray =
ubyteArrayOf(
(this and 0xff).toUByte(),
((this ushr 8) and 0xff).toUByte(),
((this ushr 16) and 0xff).toUByte(),
((this ushr 24) and 0xff).toUByte(),
)
}

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

@ -11,7 +11,14 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
import com.ionspin.kotlin.crypto.keyexchange.KeyExchangeKeyPair
import com.ionspin.kotlin.crypto.keyexchange.crypto_kx_PUBLICKEYBYTES
import com.ionspin.kotlin.crypto.keyexchange.crypto_kx_SECRETKEYBYTES
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import net.sergeych.crypto2.SafeKeyExchange.SessionKey
/**
@ -20,7 +27,7 @@ import net.sergeych.crypto2.SafeKeyExchange.SessionKey
* Usage:
*
* 1. Create [SafeKeyExchange] on both server and client sides
* 2. Exchange [EncryptingPublicKey] instances
* 2. Exchange [PublicKey] instances
* 3. Create [serverSessionKey] and [clientSessionKey] respectively
* 4. Use [SessionKey.sendingKey] and [SessionKey.receivingKey] to send and receive encrypted data.
*
@ -31,9 +38,44 @@ import net.sergeych.crypto2.SafeKeyExchange.SessionKey
* - while it is possible to generate several keys "ahead", the care should be taken when storing them,
* encrypt it with some other key to maintain safety.
* - do not use [EncryptingPublicKey] for anything but creating session keys.
* - the serialized form includes the secret exchange key, so it should be encrypted when stored.
*/
class SafeKeyExchange {
private val pair = KeyExchange.keypair()
@Serializable(with = SafeKeyExchange.SafeKeyExchangeSerializer::class)
class SafeKeyExchange private constructor(
private val pair: KeyExchangeKeyPair,
) {
constructor() : this(KeyExchange.keypair())
@Serializable
private class Packed(
val publicKey: UByteArray,
val secretKey: UByteArray,
)
object SafeKeyExchangeSerializer : KSerializer<SafeKeyExchange> {
private val packedSerializer = Packed.serializer()
override val descriptor: SerialDescriptor = packedSerializer.descriptor
override fun serialize(encoder: Encoder, value: SafeKeyExchange) {
encoder.encodeSerializableValue(
packedSerializer,
Packed(value.pair.publicKey, value.pair.secretKey)
)
}
override fun deserialize(decoder: Decoder): SafeKeyExchange {
val packed = decoder.decodeSerializableValue(packedSerializer)
require(packed.publicKey.size == crypto_kx_PUBLICKEYBYTES) {
"SafeKeyExchange public key must be $crypto_kx_PUBLICKEYBYTES bytes"
}
require(packed.secretKey.size == crypto_kx_SECRETKEYBYTES) {
"SafeKeyExchange secret key must be $crypto_kx_SECRETKEYBYTES bytes"
}
return SafeKeyExchange(KeyExchangeKeyPair(packed.publicKey, packed.secretKey))
}
}
/**
* The session key. It uses a pair of keys to encrypt and decrypt messages to maintain high
@ -90,9 +132,24 @@ class SafeKeyExchange {
* The public key; it should be transmitted to the other party, this is serializable.
* Do not use it except to get [SessionKey] with [clientSessionKey] or [serverSessionKey]. Storing and reusing
* it is a great danger.
*
* Instances can be compared and used as hashtable keys.
*/
@Serializable
class PublicKey(val keyBytes: UByteArray)
class PublicKey(val keyBytes: UByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as PublicKey
return keyBytes.contentEquals(other.keyBytes)
}
override fun hashCode(): Int {
return keyBytes.contentHashCode()
}
}
/**
* The public key to be sent to the other party. When received, get the session keys with [clientSessionKey]
@ -120,4 +177,4 @@ class SafeKeyExchange {
.let { SessionKey(SymmetricKey(it.sendKey), SymmetricKey(it.receiveKey), isClient = false) }
}
}

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

@ -11,6 +11,7 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.box.Box
import com.ionspin.kotlin.crypto.box.crypto_box_SEEDBYTES
import com.ionspin.kotlin.crypto.scalarmult.ScalarMultiplication
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -87,6 +88,11 @@ class DecryptingSecretKey(
companion object {
data class KeyPair(val secretKey: DecryptingSecretKey, val publicKey: EncryptingPublicKey)
/**
* Required seed size for deterministic decrypting key generation.
*/
val seedLength = crypto_box_SEEDBYTES
/**
* Generate a new random pair of public and secret keys.
*/
@ -96,7 +102,30 @@ class DecryptingSecretKey(
return KeyPair(DecryptingSecretKey(p.secretKey, pk), pk)
}
/**
* Generate the key pair deterministically from an exact [seedLength]-byte seed.
*/
fun generateKeys(seed: UByteArray): KeyPair {
require(seed.size == seedLength) { "seed should be $seedLength bytes" }
val p = Box.seedKeypair(seed)
val pk = EncryptingPublicKey(p.publicKey)
return KeyPair(DecryptingSecretKey(p.secretKey, pk), pk)
}
fun new(): DecryptingSecretKey = generateKeys().secretKey
/**
* Create a deterministic secret key from an exact [seedLength]-byte seed.
*/
fun fromSeed(seed: UByteArray): DecryptingSecretKey = generateKeys(seed).secretKey
/**
* Derive an exact [seedLength]-byte seed from arbitrary byte material.
*
* Use this only with high-entropy source bytes. It is not a password KDF.
*/
fun seedFromBytes(source: UByteArray): UByteArray =
KeySeed.fromBytes(source, seedLength, "DecryptingSecretKey")
}
}
}

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,8 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.signature.Signature
import kotlinx.datetime.Instant
import com.ionspin.kotlin.crypto.signature.crypto_sign_SEEDBYTES
import kotlin.time.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@ -76,13 +77,41 @@ class SigningSecretKey(
data class SigningKeyPair(val secretKey: SigningSecretKey, val publicKey: VerifyingPublicKey)
/**
* Required seed size for deterministic signing key generation.
*/
const val seedLength = crypto_sign_SEEDBYTES
fun generatePair(): SigningKeyPair {
val p = Signature.keypair()
val publicKey = VerifyingPublicKey(p.publicKey)
return SigningKeyPair(SigningSecretKey(p.secretKey, publicKey), publicKey)
}
/**
* Generate the key pair deterministically from an exact [seedLength]-byte seed.
*/
fun generatePair(seed: UByteArray): SigningKeyPair {
require(seed.size == seedLength) { "seed should be $seedLength bytes" }
val p = Signature.seedKeypair(seed)
val publicKey = VerifyingPublicKey(p.publicKey)
return SigningKeyPair(SigningSecretKey(p.secretKey, publicKey), publicKey)
}
fun new(): SigningSecretKey = generatePair().secretKey
/**
* Create a deterministic signing key from an exact [seedLength]-byte seed.
*/
fun fromSeed(seed: UByteArray): SigningSecretKey = generatePair(seed).secretKey
/**
* Derive an exact [seedLength]-byte seed from arbitrary byte material.
*
* Use this only with high-entropy source bytes. It is not a password KDF.
*/
fun seedFromBytes(source: UByteArray): UByteArray =
KeySeed.fromBytes(source, seedLength, "SigningSecretKey")
}
}
}

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

@ -48,10 +48,38 @@ class UniversalPrivateKey(
}
companion object {
/**
* Required seed size for deterministic universal private key generation.
*
* It contains independent signing and decrypting seeds.
*/
val seedLength = SigningSecretKey.seedLength + DecryptingSecretKey.seedLength
/**
* Generate 2 new random keys (4 key pairs under the hood) to securely signd and
* decrypt data.
*/
fun new() = UniversalPrivateKey(SigningSecretKey.new(), DecryptingSecretKey.new())
/**
* Create a deterministic private key from an exact [seedLength]-byte seed.
*/
fun fromSeed(seed: UByteArray): UniversalPrivateKey {
require(seed.size == seedLength) { "seed should be $seedLength bytes" }
val signingSeed = seed.sliceArray(0..<SigningSecretKey.seedLength)
val decryptingSeed = seed.sliceArray(SigningSecretKey.seedLength..<seedLength)
return UniversalPrivateKey(
SigningSecretKey.fromSeed(signingSeed),
DecryptingSecretKey.fromSeed(decryptingSeed)
)
}
/**
* Derive an exact [seedLength]-byte seed from arbitrary byte material.
*
* Use this only with high-entropy source bytes. It is not a password KDF.
*/
fun seedFromBytes(source: UByteArray): UByteArray =
KeySeed.fromBytes(source, seedLength, "UniversalPrivateKey")
}
}
}

View File

@ -229,6 +229,8 @@ class UniversalRing(
companion object {
val EMPTY = UniversalRing(keyWithTags = emptyMap())
fun new(): UniversalRing = UniversalRing(keyWithTags = emptyMap())
/**
* Join a collection of keyrings together (same as reducing with `+`). Correctly
* works if there is no keyring (returns [EMPTY]), or only one keyring (returns

View File

@ -42,16 +42,28 @@ sealed class KDF {
/**
* Create [KDF] of the corresponding strength suitable to derive [numberOfKeys] symmetric keys.
*
* Random salt of proper size is used
* Random salt of proper size is used by default. If a custom [salt] size is not [Argon.saltSize],
* it is deterministically normalized with `Hash.Blake2b.deriveSaltFormBytes(...)`.
*/
fun kdfForSize(numberOfKeys: Int,salt: UByteArray = Argon.randomSalt()): KDF =
creteDefault(SymmetricKey.keyLength * numberOfKeys, this, salt)
fun kdfForSize(numberOfKeys: Int, salt: UByteArray = Argon.randomSalt()): KDF =
createDefault(SymmetricKey.keyLength * numberOfKeys, this, salt)
/**
* Create [KDF] for deriving [sizeInBytes] bytes using the provided [salt].
*
* The [salt] is used as is when its size is [Argon.saltSize]. Otherwise, it is deterministically
* normalized with `Hash.Blake2b.deriveSaltFormBytes(...)` to the required size.
*/
fun kdfForSizeInBytes(sizeInBytes: Int, salt: UByteArray): KDF =
createDefault(sizeInBytes, this, salt)
/**
* Derive multiple keys from the password. Derivation params will be included in the key ids, see
* [SymmetricKey.id] as [KeyId.kdp].
* Random salt of proper size is used
* If the [salt] size is not [Argon.saltSize], it is deterministically normalized with
* `Hash.Blake2b.deriveSaltFormBytes(...)`.
*
* ___Important: symmetric keys do not save key ids___. _Container do it, so it is possible to re-derive
* key to open the container, but in many cases you might need to save [KeyId.kdp] separately_.
@ -59,13 +71,22 @@ sealed class KDF {
* to change with time.
*/
@Suppress("unused")
fun deriveMultiple(password: String, count: Int,salt: UByteArray): List<SymmetricKey> =
fun deriveMultiple(password: String, count: Int, salt: UByteArray): List<SymmetricKey> =
kdfForSize(count, salt).deriveMultipleKeys(password, count)
/**
* Derive single key from password, same as [deriveMultiple] with count=1.
*/
fun derive(password: String, salt: UByteArray): SymmetricKey = deriveMultiple(password, 1, salt).first()
/**
* Derive bytes from bytes using the default [Argon] KDF with this complexity.
*
* If the [salt] size is not [Argon.saltSize], it is deterministically normalized with
* `Hash.Blake2b.deriveSaltFormBytes(...)`.
*/
fun derive(source: UByteArray, salt: UByteArray, derivedSize: Int): UByteArray =
createDefault(derivedSize, this, salt).deriveFromBytes(source)
}
/**
@ -73,6 +94,11 @@ sealed class KDF {
*/
abstract fun deriveKey(password: String): UByteArray
/**
* Derive bytes from bytes using this KDF parameters. The derived byte count is this KDF's configured key size.
*/
abstract fun deriveFromBytes(source: UByteArray): UByteArray
/**
* Derive keys from lower part of bytes derived from the password. E.g., if generated size is longer than
* required to create [count] keys, the first bytes will be used. It let use rest bytes for other purposes.
@ -131,6 +157,17 @@ sealed class KDF {
override fun deriveKey(password: String): UByteArray =
PasswordHash.pwhash(keySize, password, salt, instructionsComplexity, memComplexity, algorithm.code)
override fun deriveFromBytes(source: UByteArray): UByteArray {
return PasswordHash.pwhash(
keySize,
source.encodeToBase64Url(),
salt,
instructionsComplexity,
memComplexity,
algorithm.code
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Argon) return false
@ -171,7 +208,7 @@ sealed class KDF {
fun create(complexity: Complexity, salt: UByteArray, keySize: Int): Argon {
require(salt.size == saltSize) { "The salt size should be $saltSize" }
require(keySize > minKeySize) { "The key size should be at least $keySize bytes" }
require(keySize >= minKeySize) { "The key size should be at least $minKeySize bytes" }
return when (complexity) {
FixedLow -> Argon(
V2id_13,
@ -220,14 +257,96 @@ sealed class KDF {
}
}
companion object {
fun creteDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF {
return Argon.create(complexity, salt, keySize)
/**
* Fast HKDF derivation for already strong byte material, implemented as described in
* [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869).
*
* HKDF is suitable when [deriveFromBytes] receives source bytes that already contain enough entropy, for
* example a shared secret produced by a key agreement protocol, random master key material, or another
* high-entropy secret. It is fast by design: it does not slow down brute-force attempts and does not use
* extra memory. Do not use it for passwords, memorable phrases, short PINs, user-entered secrets, or other
* low-entropy inputs; use [Argon] for those instead.
*
* The derivation is deterministic for the tuple `(hash, salt, info, keySize, source)`. Store or recreate the
* same parameters to derive the same output again. [salt] and [info] are not secret, but changing either one
* changes the derived bytes.
*
* @property hash Hash function to use inside HMAC. The default is [Hash.Sha3_256]. [Hash.Blake2b],
* [Hash.Sha3_256], and [Hash.Sha3_384] are supported; [Hash.Sha3AndBlake] is intentionally rejected because
* it is a synthetic concatenated hash and has no well-defined HMAC block size.
* @property salt Optional extraction salt. Prefer a stable random salt when one is available and can be stored
* with the derived data. An empty salt is allowed by HKDF and is treated as a zero-filled salt of the selected
* hash output size.
* @property info Optional application-specific context string or binary label. Use it to domain-separate
* outputs derived from the same source, for example `"container encryption"`, `"header auth"`, or a protocol
* transcript hash. It may be empty when there is only one unambiguous use for the derived bytes.
* @property keySize Number of bytes to derive. It must be positive and no larger than `255 * hashLen`, where
* `hashLen` is the output size of [hash].
*/
@Serializable
@SerialName("hkdf")
data class HKDF(
val hash: Hash = Hash.Sha3_256,
val salt: UByteArray = ubyteArrayOf(),
val info: UByteArray = ubyteArrayOf(),
@Unsigned
val keySize: Int,
) : KDF() {
init {
require(keySize > 0) { "HKDF key size should be positive" }
require(hash.supportsHmac) { "Hash $hash is not supported for HKDF" }
require(keySize <= hash.hkdfMaxOutputSize) {
"HKDF output for $hash is limited to ${hash.hkdfMaxOutputSize} bytes"
}
}
override fun deriveKey(password: String): UByteArray =
throw UnsupportedOperationException("HKDF is not a password KDF; use Argon for passwords")
override fun deriveFromBytes(source: UByteArray): UByteArray =
hkdf(hash, source, salt, info, keySize)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HKDF) return false
if (hash != other.hash) return false
if (keySize != other.keySize) return false
if (!salt.contentEquals(other.salt)) return false
return info.contentEquals(other.info)
}
override fun hashCode(): Int {
var result = hash.hashCode()
result = 31 * result + salt.contentHashCode()
result = 31 * result + info.contentHashCode()
result = 31 * result + keySize
return result
}
}
companion object {
/**
* Create default [Argon] KDF parameters. If [salt] is not exactly [Argon.saltSize], it is
* deterministically normalized with [Hash.Blake2b.deriveSaltFormBytes] before creating the KDF.
*/
fun createDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF {
val normalizedSalt = if (salt.size == Argon.saltSize) {
salt
} else {
Hash.Blake2b.deriveSaltFormBytes(salt, Argon.saltSize)
}
return Argon.create(complexity, normalizedSalt, keySize)
}
@Deprecated("Use createDefault", ReplaceWith("createDefault(keySize, complexity, salt)"))
fun creteDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF =
createDefault(keySize, complexity, salt)
}
data class Instance(val kdf: KDF, val password: String)
}

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() {
@ -63,17 +55,44 @@ class HashTest {
@Test
fun deriveSaltTest() = runTest {
initCrypto()
for( i in 2..257 ) {
val x = Hash.Sha3AndBlake.deriveSalt("base one", i)
val y = Hash.Sha3AndBlake.deriveSalt("base one", i)
val z = Hash.Sha3AndBlake.deriveSalt("base two", i)
assertContentEquals(x, y)
assertFalse { x contentEquals z }
assertEquals(x.size, i)
assertEquals(y.size, i)
assertEquals(z.size, i)
for (hash in Hash.entries) {
for (i in 1..257) {
val x = hash.deriveSalt("base one", i)
val y = hash.deriveSalt("base one", i)
val z = hash.deriveSalt("base two", i)
assertContentEquals(x, y)
if (i > 1) assertFalse { x contentEquals z }
assertEquals(i, x.size)
assertEquals(i, y.size)
assertEquals(i, z.size)
}
}
}
@Test
fun deriveSaltFromBytesTest() = runTest {
initCrypto()
val base = "base one".encodeToByteArray().asUByteArray()
for (hash in Hash.entries) {
for (i in listOf(1, 5, 31, 32, 33, 96, 97)) {
val fromString = hash.deriveSalt("base one", i)
val fromBytes = hash.deriveSaltFromBytes(base, i)
val fromTypoAlias = hash.deriveSaltFormBytes(base, i)
assertEquals(i, fromBytes.size)
assertContentEquals(fromString, fromBytes)
assertContentEquals(fromBytes, fromTypoAlias)
}
}
}
}
@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

@ -8,12 +8,16 @@
* real dot sergeych at gmail.
*/
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto2.Hash
import net.sergeych.crypto2.KDF
import net.sergeych.crypto2.initCrypto
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
class KDFTest {
@Test
@ -55,4 +59,159 @@ class KDFTest {
assertEquals(3, kk.size)
}
}
@Test
fun complexityKdfForSizeInBytesTest() = runTest {
initCrypto()
val size = KDF.Argon.minKeySize + 17
val expectedSalt = UByteArray(KDF.Argon.saltSize) { (it + 3).toUByte() }
val kdf = KDF.Complexity.FixedLow.kdfForSizeInBytes(size, expectedSalt)
assertIs<KDF.Argon>(kdf)
assertEquals(KDF.Argon.create(KDF.Complexity.FixedLow, expectedSalt, size), kdf)
assertEquals(size, kdf.keySize)
assertContentEquals(expectedSalt, kdf.salt)
}
@Test
fun createDefaultNormalizesSaltTest() = runTest {
initCrypto()
val size = KDF.Argon.minKeySize + 19
val shortSalt = ubyteArrayOf(1u, 2u, 3u, 4u, 5u)
val longSalt = UByteArray(KDF.Argon.saltSize + 7) { (it * 5).toUByte() }
val expectedShortSalt = Hash.Blake2b.deriveSaltFormBytes(shortSalt, KDF.Argon.saltSize)
val expectedLongSalt = Hash.Blake2b.deriveSaltFormBytes(longSalt, KDF.Argon.saltSize)
val shortKdf = KDF.createDefault(size, KDF.Complexity.FixedLow, shortSalt)
val longKdf = KDF.Complexity.FixedLow.kdfForSizeInBytes(size, longSalt)
assertIs<KDF.Argon>(shortKdf)
assertIs<KDF.Argon>(longKdf)
assertEquals(KDF.Argon.saltSize, shortKdf.salt.size)
assertEquals(KDF.Argon.saltSize, longKdf.salt.size)
assertContentEquals(expectedShortSalt, shortKdf.salt)
assertContentEquals(expectedLongSalt, longKdf.salt)
}
@Test
fun deriveFromBytesTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { it.toUByte() }
val kdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 80)
val source = UByteArray(47) { (it * 3).toUByte() }
val b1 = kdf.deriveFromBytes(source)
val b2 = kdf.deriveFromBytes(source)
val b3 = kdf.deriveFromBytes(source + 1u)
assertEquals(80, b1.size)
assertContentEquals(b1, b2)
assertFalse { b1 contentEquals b3 }
}
@Test
fun complexityDeriveFromBytesTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { (it + 1).toUByte() }
val source = UByteArray(23) { (it + 7).toUByte() }
val b1 = KDF.Complexity.Interactive.derive(source, salt, 64)
val b2 = KDF.Argon.create(KDF.Complexity.Interactive, salt, 64).deriveFromBytes(source)
assertContentEquals(b1, b2)
assertEquals(64, b1.size)
}
@Test
fun deriveFromBytesUsesKeySizeTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { it.toUByte() }
val source = ubyteArrayOf(1u, 2u, 3u)
val shortKdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 64)
val longKdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 96)
assertEquals(64, shortKdf.deriveFromBytes(source).size)
assertEquals(96, longKdf.deriveFromBytes(source).size)
}
@Test
fun hkdfDeriveFromBytesTest() = runTest {
initCrypto()
val source = "strong source bytes with enough entropy".encodeToUByteArray()
val salt = "stored public salt".encodeToUByteArray()
val info = "crypto2:test".encodeToUByteArray()
val kdf = KDF.HKDF(Hash.Sha3_256, salt, info, 80)
val b1 = kdf.deriveFromBytes(source)
val b2 = kdf.deriveFromBytes(source)
val b3 = kdf.deriveFromBytes(source + 1u)
val b4 = kdf.copy(info = "crypto2:other".encodeToUByteArray()).deriveFromBytes(source)
val expected = hexToUBytes(
"580cfd4eb588312097c9e43852d62bec9aff988591e2e5f721a2facc80c3c494" +
"d3487b81e19f71611bdebb9983b94e058c9f0c38bc226b9b895eb554d17446b" +
"672e12c7e2c36683807cd1e8d67ada0f7"
)
assertEquals(80, b1.size)
assertContentEquals(expected, b1)
assertContentEquals(b1, b2)
assertFalse { b1 contentEquals b3 }
assertFalse { b1 contentEquals b4 }
}
@Test
fun hkdfParametersCompareArraysByContentTest() = runTest {
initCrypto()
val k1 = KDF.HKDF(
Hash.Sha3_384,
"salt".encodeToUByteArray(),
"info".encodeToUByteArray(),
42
)
val k2 = KDF.HKDF(
Hash.Sha3_384,
"salt".encodeToUByteArray(),
"info".encodeToUByteArray(),
42
)
assertEquals(k1, k2)
assertEquals(k1.hashCode(), k2.hashCode())
}
@Test
fun hkdfRejectsInvalidParametersTest() = runTest {
initCrypto()
assertThrows<IllegalArgumentException> {
KDF.HKDF(Hash.Sha3_256, keySize = 0)
}
assertThrows<IllegalArgumentException> {
KDF.HKDF(Hash.Sha3AndBlake, keySize = 32)
}
assertThrows<IllegalArgumentException> {
KDF.HKDF(Hash.Sha3_256, keySize = 32 * 255 + 1)
}
assertThrows<IllegalArgumentException> {
KDF.HKDF(Hash.Sha3_256, keySize = 32).deriveFromBytes(ubyteArrayOf())
}
}
@Test
fun hkdfDoesNotDeriveFromPasswordTest() = runTest {
initCrypto()
val kdf = KDF.HKDF(keySize = 32)
assertThrows<UnsupportedOperationException> {
kdf.deriveKey("password")
}
}
}
private fun hexToUBytes(hex: String): UByteArray {
require(hex.length % 2 == 0) { "hex string should have even length" }
return UByteArray(hex.length / 2) {
hex.substring(it * 2, it * 2 + 2).toInt(16).toUByte()
}
}

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
@ -83,6 +82,48 @@ class KeysTest {
}
@Test
fun seededPrivateKeysTest() = runTest {
initCrypto()
val source = "stable high entropy key material placeholder".encodeToUByteArray() + randomUBytes(128)
val data = "seeded generation works".encodeToUByteArray()
val signingSeed = SigningSecretKey.seedFromBytes(source)
val decryptingSeed = DecryptingSecretKey.seedFromBytes(source)
assertEquals(SigningSecretKey.seedLength, signingSeed.size)
assertEquals(DecryptingSecretKey.seedLength, decryptingSeed.size)
assertFalse(signingSeed contentEquals decryptingSeed)
val signingKey1 = SigningSecretKey.fromSeed(signingSeed)
val signingKey2 = SigningSecretKey.fromSeed(signingSeed)
assertEquals(signingKey1, signingKey2)
val signature = signingKey1.sign(data)
assertTrue(signingKey2.verifyingKey.verify(signature, data))
val decryptingKey1 = DecryptingSecretKey.fromSeed(decryptingSeed)
val decryptingKey2 = DecryptingSecretKey.fromSeed(decryptingSeed)
assertEquals(decryptingKey1.publicKey, decryptingKey2.publicKey)
assertContentEquals(data, decryptingKey2.decrypt(decryptingKey1.publicKey.encrypt(data)))
val universalSeed = UniversalPrivateKey.seedFromBytes(source)
assertEquals(UniversalPrivateKey.seedLength, universalSeed.size)
val universalKey1 = UniversalPrivateKey.fromSeed(universalSeed)
val universalKey2 = UniversalPrivateKey.fromSeed(universalSeed)
assertEquals(universalKey1.publicKey, universalKey2.publicKey)
assertTrue(universalKey2.publicKey.verify(universalKey1.sign(data), data))
assertContentEquals(data, universalKey2.decrypt(universalKey1.publicKey.encrypt(data)))
assertFailsWith<IllegalArgumentException> {
SigningSecretKey.fromSeed(signingSeed.dropLast(1).toUByteArray())
}
assertFailsWith<IllegalArgumentException> {
DecryptingSecretKey.fromSeed(decryptingSeed.dropLast(1).toUByteArray())
}
assertFailsWith<IllegalArgumentException> {
UniversalPrivateKey.fromSeed(universalSeed.dropLast(1).toUByteArray())
}
}
@Test
fun secretEncryptTest() = runTest {
initCrypto()
@ -118,6 +159,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()
@ -142,6 +221,26 @@ class KeysTest {
}
@Test
fun keyExchangeSerializationTest() = runTest {
initCrypto()
val ske = SafeKeyExchange()
val packedExchange = pack(ske)
val cke = SafeKeyExchange()
val clientSessionKey = cke.clientSessionKey(ske.publicKey)
val restoredExchange = unpack<SafeKeyExchange>(packedExchange)
assertEquals(ske.publicKey, restoredExchange.publicKey)
val serverSessionKey = restoredExchange.serverSessionKey(cke.publicKey)
val src = "Hello after restore!"
assertEquals(src, serverSessionKey.decryptString(clientSessionKey.encrypt(src)))
assertEquals(src, clientSessionKey.decryptString(serverSessionKey.encrypt(src)))
assertContentEquals(clientSessionKey.sessionTag, serverSessionKey.sessionTag)
}
@Test
fun asymmetricKeyTest() = 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