forked from sergeych/crypto2
Compare commits
9 Commits
4748ea0d65
...
7d3e396cf7
Author | SHA1 | Date | |
---|---|---|---|
7d3e396cf7 | |||
85b13ed8ca | |||
fbbe4d3a34 | |||
875c0f7a50 | |||
fe6190eb8d | |||
776f4e75ff | |||
fa7263b0e7 | |||
bd81f88dd8 | |||
6fcf7841a7 |
@ -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 {
|
||||||
|
@ -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)
|
|
105
src/commonMain/kotlin/net/sergeych/crypto2/EncryptedKVStorage.kt
Normal file
105
src/commonMain/kotlin/net/sergeych/crypto2/EncryptedKVStorage.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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()
|
||||||
|
87
src/commonTest/kotlin/StorageTest.kt
Normal file
87
src/commonTest/kotlin/StorageTest.kt
Normal 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()}")
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user