diff --git a/build.gradle.kts b/build.gradle.kts index e07ef31..eedf817 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ plugins { } group = "net.sergeych" -version = "0.9.2" +version = "0.9.3" repositories { mavenCentral() diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/HKDF.kt b/src/commonMain/kotlin/net/sergeych/crypto2/HKDF.kt new file mode 100644 index 0000000..2604483 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto2/HKDF.kt @@ -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.. 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)) +} diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/kdf.kt b/src/commonMain/kotlin/net/sergeych/crypto2/kdf.kt index f2eadad..b5772a1 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/kdf.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/kdf.kt @@ -257,6 +257,75 @@ sealed class KDF { } } + /** + * 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 { /** diff --git a/src/commonTest/kotlin/KDFTest.kt b/src/commonTest/kotlin/KDFTest.kt index 16105dd..2f6ef84 100644 --- a/src/commonTest/kotlin/KDFTest.kt +++ b/src/commonTest/kotlin/KDFTest.kt @@ -8,6 +8,7 @@ * 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 @@ -133,4 +134,84 @@ class KDFTest { 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 { + KDF.HKDF(Hash.Sha3_256, keySize = 0) + } + assertThrows { + KDF.HKDF(Hash.Sha3AndBlake, keySize = 32) + } + assertThrows { + KDF.HKDF(Hash.Sha3_256, keySize = 32 * 255 + 1) + } + assertThrows { + KDF.HKDF(Hash.Sha3_256, keySize = 32).deriveFromBytes(ubyteArrayOf()) + } + } + + @Test + fun hkdfDoesNotDeriveFromPasswordTest() = runTest { + initCrypto() + val kdf = KDF.HKDF(keySize = 32) + + assertThrows { + 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() + } }