Compare commits

...

9 Commits

8 changed files with 214 additions and 121 deletions

View File

@ -13,14 +13,14 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
kotlin("multiplatform") version "2.0.20" kotlin("multiplatform") version "2.0.21"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
id("org.jetbrains.dokka") version "1.9.20" id("org.jetbrains.dokka") version "1.9.20"
`maven-publish` `maven-publish`
} }
group = "net.sergeych" group = "net.sergeych"
version = "0.7.4-SNAPSHOT" version = "0.8.3-SNAPSHOT"
repositories { repositories {
mavenCentral() mavenCentral()
@ -44,17 +44,16 @@ kotlin {
linuxX64() linuxX64()
linuxArm64() linuxArm64()
macosX64() // macosX64()
macosArm64() // macosArm64()
iosX64() // iosX64()
iosArm64() // iosArm64()
iosSimulatorArm64() // iosSimulatorArm64()
mingwX64() // mingwX64()
@OptIn(ExperimentalWasmDsl::class) @OptIn(ExperimentalWasmDsl::class)
wasmJs { wasmJs {
browser() browser()
} }
val ktor_version = "2.3.6"
sourceSets { sourceSets {
all { all {
@ -73,8 +72,7 @@ kotlin {
implementation(project.dependencies.platform("org.kotlincrypto.hash:bom:0.5.1")) implementation(project.dependencies.platform("org.kotlincrypto.hash:bom:0.5.1"))
implementation("org.kotlincrypto.hash:sha3") implementation("org.kotlincrypto.hash:sha3")
api("com.ionspin.kotlin:bignum:0.3.9") api("com.ionspin.kotlin:bignum:0.3.9")
api("net.sergeych:mp_bintools:0.1.7") api("net.sergeych:mp_bintools:0.1.12-SNAPSHOT")
api("net.sergeych:mp_stools:1.5.1")
} }
} }
val commonTest by getting { val commonTest by getting {

View File

@ -1,100 +0,0 @@
/*
* Copyright (c) 2025. Sergey S. Chernov - All Rights Reserved
*
* You may use, distribute and modify this code under the
* terms of the private license, which you must obtain from the author
*
* To obtain the license, contact the author: https://t.me/real_sergeych or email to
* real dot sergeych at gmail.
*/
package net.sergeych.crypto2
import kotlinx.serialization.Serializable
import net.sergeych.bintools.decodeHex
import net.sergeych.bintools.encodeToHex
import kotlin.math.min
/**
* Bytes sequence with comparison, concatenation, and string representation,
* could be used as hash keys for pure binary values, etc.
*/
@Suppress("unused")
@Serializable
class ByteChunk(val data: UByteArray): Comparable<ByteChunk> {
val size: Int get() = data.size
/**
* Per-byte comparison also of different length. From two chunks
* of different size but equal beginning, the shorter is considered
* the smaller.
*/
override fun compareTo(other: ByteChunk): Int {
val limit = min(size, other.size)
for( i in 0 ..< limit) {
val own = data[i]
val their = other.data[i]
if( own < their) return -1
else if( own > their) return 1
}
if( size < other.size ) return -1
if( size > other.size ) return 1
return 0
}
/**
* Equal chunks means content equality.
*/
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ByteChunk) return false
return data contentEquals other.data
}
/**
* Content-based hash code
*/
override fun hashCode(): Int {
return data.contentHashCode()
}
/**
* hex representation of data
*/
override fun toString(): String = base64Url
/**
* Hex encoded data
*/
val hex by lazy { data.encodeToHex() }
val base64Url by lazy { data.encodeToBase64Url() }
/**
* human-readable dump
*/
val dump by lazy { data.toDump() }
/**
* Concatenate two chunks and return new one
*/
operator fun plus(other: ByteChunk): ByteChunk = ByteChunk(data + other.data)
fun toByteArray(): ByteArray = data.asByteArray()
fun toUByteArray(): UByteArray = data
companion object {
fun fromHex(hex: String): ByteChunk = ByteChunk(hex.decodeHex().asUByteArray())
fun random(sizeInBytes: Int=16) = randomUBytes(sizeInBytes).toChunk()
}
}
private fun UByteArray.toChunk(): ByteChunk = ByteChunk(this)
@Suppress("unused")
private fun ByteArray.toChunk(): ByteChunk = ByteChunk(this.asUByteArray())
@Suppress("unused")
fun ByteArray.asChunk() = ByteChunk(toUByteArray())
fun UByteArray.asChunk(): ByteChunk = ByteChunk(this)

View File

@ -0,0 +1,105 @@
package net.sergeych.crypto2
import net.sergeych.bintools.KVStorage
import net.sergeych.bintools.MemoryKVStorage
import net.sergeych.bintools.optStored
import net.sergeych.synctools.ProtectedOp
import net.sergeych.synctools.invoke
import kotlin.random.Random
import kotlin.random.nextUBytes
/**
* Encrypted variant of [KVStorage]; the storage is encrypted with the given key
* in a given [plainStore] [KVStorage]. It is threadsafe where
* applicable. Also, it supports in-place key change [reEncrypt].
*
* Keys are stored encrypted and used hashed so it is not possible to
* retrieve them without knowing the encryption key.
*
* @param plainStore where to store encrypted data
* @param encryptionKey key to decrypt existing/encrypt new data. Can cause [DecryptionFailedException]
* if the key is wrong and the storage is already initialized with a new key and same [prefix]
* @param prefix prefix for keys to distinguish from other data in [plainStore]
* @param removeExisting if true, removes all existing data in [plainStore] if the [encryptionKey] can't
* decrypt existing encrypted data
*/
class EncryptedKVStorage(
private val plainStore: KVStorage,
private var encryptionKey: SymmetricKey,
private val prefix: String = "EKVS_",
removeExisting: Boolean
) : KVStorage {
private val op = ProtectedOp()
private val prefix2 = prefix + ":"
val seed: UByteArray
init {
var encryptedSeed by plainStore.optStored<UByteArray>("$prefix#seed")
seed = try {
encryptedSeed?.let { encryptionKey.decrypt(it) }
?: Random.nextUBytes(32).also {
encryptedSeed = encryptionKey.encrypt(it)
}
} catch (x: DecryptionFailedException) {
if (removeExisting) {
plainStore.keys.filter { it.startsWith(prefix) }.forEach {
plainStore.delete(it)
}
Random.nextUBytes(32).also {
encryptedSeed = encryptionKey.encrypt(it)
}
} else throw x
}
}
private fun mkkey(key: String): String =
blake2b(key.encodeToByteArray().asUByteArray() + seed).encodeToBase64Url()
override val keys: Set<String>
get() = op.invoke {
plainStore.keys.mapNotNull {
if (it.startsWith(prefix2))
plainStore[it]?.let { encrypted ->
encryptionKey.decrypt(encrypted.asUByteArray()).asByteArray().decodeToString()
}
else null
}.toSet()
}
override fun get(key: String): ByteArray? = op {
val k0 = mkkey(key)
val k = prefix + k0
plainStore[k]?.let { encryptionKey.decrypt(it.asUByteArray()).asByteArray() }
?.also {
val k2 = prefix2 + k0
if (k2 !in plainStore)
plainStore[k2] = encryptionKey.encrypt(key).asByteArray()
}
}
override fun set(key: String, value: ByteArray?) {
op {
val k1 = mkkey(key)
plainStore[prefix + k1] = value?.let {
encryptionKey.encrypt(it.asUByteArray()).asByteArray()
}
plainStore[prefix2 + k1] = encryptionKey.encrypt(key).asByteArray()
}
}
/**
* Re-encrypts the entire storage in-place with the given key; it is threadsafe where
* applicable.
*
* This method re-encrypts every data item so it is cryptographically secure.
*/
fun reEncrypt(newKey: SymmetricKey) {
op {
val copy = MemoryKVStorage().also { it.addAll(this) }
encryptionKey = newKey
addAll(copy)
}
}
}

View File

@ -14,6 +14,8 @@ import com.ionspin.kotlin.crypto.generichash.GenericHash
import com.ionspin.kotlin.crypto.util.encodeToUByteArray import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import net.sergeych.bintools.ByteChunk
import net.sergeych.bintools.asChunk
import org.kotlincrypto.hash.sha3.SHA3_256 import org.kotlincrypto.hash.sha3.SHA3_256
import org.kotlincrypto.hash.sha3.SHA3_384 import org.kotlincrypto.hash.sha3.SHA3_384

View File

@ -44,7 +44,8 @@ sealed class KDF {
* *
* Random salt of proper size is used * Random salt of proper size is used
*/ */
fun kdfForSize(numberOfKeys: Int): KDF = creteDefault(SymmetricKey.keyLength * numberOfKeys, this) fun kdfForSize(numberOfKeys: Int,salt: UByteArray = Argon.randomSalt()): KDF =
creteDefault(SymmetricKey.keyLength * numberOfKeys, this, salt)
/** /**
* Derive multiple keys from the password. Derivation params will be included in the key ids, see * Derive multiple keys from the password. Derivation params will be included in the key ids, see
@ -58,13 +59,13 @@ sealed class KDF {
* to change with time. * to change with time.
*/ */
@Suppress("unused") @Suppress("unused")
fun deriveMultiple(password: String, count: Int): List<SymmetricKey> = fun deriveMultiple(password: String, count: Int,salt: UByteArray): List<SymmetricKey> =
kdfForSize(count).deriveMultipleKeys(password, count) kdfForSize(count, salt).deriveMultipleKeys(password, count)
/** /**
* Derive single key from password, same as [deriveMultiple] with count=1. * Derive single key from password, same as [deriveMultiple] with count=1.
*/ */
fun derive(password: String): SymmetricKey = deriveMultiple(password, 1).first() fun derive(password: String, salt: UByteArray): SymmetricKey = deriveMultiple(password, 1, salt).first()
} }
/** /**

View File

@ -9,10 +9,10 @@
*/ */
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import net.sergeych.bintools.ByteChunk
import net.sergeych.bintools.toDump import net.sergeych.bintools.toDump
import net.sergeych.bipack.BipackEncoder import net.sergeych.bipack.BipackEncoder
import net.sergeych.crypto2.BinaryId import net.sergeych.crypto2.BinaryId
import net.sergeych.crypto2.ByteChunk
import net.sergeych.crypto2.initCrypto import net.sergeych.crypto2.initCrypto
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertContentEquals import kotlin.test.assertContentEquals
@ -35,10 +35,10 @@ class BinaryIdTest {
initCrypto() initCrypto()
val x = ByteChunk.random(3) val x = ByteChunk.random(3)
assertEquals(3, x.data.size) assertEquals(3, x.data.size)
assertEquals(3, x.toByteArray().size) assertEquals(3, x.asByteArray.size)
assertEquals(3, x.toUByteArray().size) assertEquals(3, x.data.size)
println(BipackEncoder.encode(x).toDump()) println(BipackEncoder.encode(x).toDump())
assertEquals(4, BipackEncoder.encode(x).size) assertEquals(4, BipackEncoder.encode(x).size)
assertContentEquals(BipackEncoder.encode(x.toByteArray()), BipackEncoder.encode(x)) assertContentEquals(BipackEncoder.encode(x.asByteArray), BipackEncoder.encode(x))
} }
} }

View File

@ -442,7 +442,7 @@ class KeysTest {
assertContentEquals(k2.keyBytes, k2.id.id.body) assertContentEquals(k2.keyBytes, k2.id.id.body)
val k7 = SymmetricKey.new() val k7 = SymmetricKey.new()
val k8 = KDF.Complexity.Interactive.derive("super") val k8 = KDF.Complexity.Interactive.derive("super", KDF.Argon.randomSalt())
fun testToString(k: UniversalKey) { fun testToString(k: UniversalKey) {
val s = k.toString() val s = k.toString()

View File

@ -0,0 +1,87 @@
import kotlinx.coroutines.test.runTest
import net.sergeych.bintools.*
import net.sergeych.bipack.decodeFromBipack
import net.sergeych.crypto2.DecryptionFailedException
import net.sergeych.crypto2.EncryptedKVStorage
import net.sergeych.crypto2.SymmetricKey
import net.sergeych.crypto2.initCrypto
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class StorageTest {
@Test
fun testGetAndSet() = runTest {
initCrypto()
val plain = MemoryKVStorage()
val key = SymmetricKey.new()
val storage = EncryptedKVStorage(plain, key, removeExisting = false)
var hello by storage.optStored<String>()
assertNull(hello)
hello = "world"
assertEquals("world", storage["hello"]?.decodeFromBipack<String>())
println("plain: ${plain.keys}")
assertEquals(setOf("hello"), storage.keys)
var foo by storage.stored("bar")
assertEquals("bar", foo)
foo = "bar2"
// plain.dump()
// storage.dump()
assertEquals(setOf("hello", "foo"), storage.keys)
}
@Test
fun testReEncrypt() = runTest {
initCrypto()
fun test(x: KVStorage) {
val foo by x.stored("1")
val bar by x.stored("2")
val bazz by x.stored("3")
assertEquals("foo", foo)
assertEquals("bar", bar)
assertEquals("bazz", bazz)
}
fun setup(s: KVStorage, k: SymmetricKey): EncryptedKVStorage {
val x = EncryptedKVStorage(s, k, removeExisting = false)
var foo by x.stored("1")
var bar by x.stored("2")
var bazz by x.stored("3")
foo = "foo"
bar = "bar"
bazz = "bazz"
return x
}
val k1 = SymmetricKey.new()
val k2 = SymmetricKey.new()
val plain = MemoryKVStorage()
val s1 = setup(plain, k1)
test(s1)
s1.reEncrypt(k2)
test(s1)
// val s2 = EncryptedKVStorage(plain, k2)
// test(s2)
}
@Test
fun testDeleteExisting() = runTest {
initCrypto()
val plain = MemoryKVStorage()
val c1 = EncryptedKVStorage(plain, SymmetricKey.new(), removeExisting = false) // 1
c1.write("hello", "world")
assertFailsWith<DecryptionFailedException> {
EncryptedKVStorage(plain, SymmetricKey.new(), removeExisting = false) // 2
}
EncryptedKVStorage(plain, SymmetricKey.new(), removeExisting = true) // 2
}
}
@Suppress("unused")
fun KVStorage.dump() {
for (k in keys)
println("$k: ${this[k]?.toDump()}")
}