added HKDF, version will be 0.9.3

This commit is contained in:
Sergey Chernov 2026-06-07 13:12:03 +03:00
parent d402afe1bd
commit 84a3e82216
4 changed files with 229 additions and 1 deletions

View File

@ -20,7 +20,7 @@ plugins {
} }
group = "net.sergeych" group = "net.sergeych"
version = "0.9.2" version = "0.9.3"
repositories { repositories {
mavenCentral() mavenCentral()

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

@ -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 { companion object {
/** /**

View File

@ -8,6 +8,7 @@
* real dot sergeych at gmail. * real dot sergeych at gmail.
*/ */
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import net.sergeych.crypto2.Hash import net.sergeych.crypto2.Hash
import net.sergeych.crypto2.KDF import net.sergeych.crypto2.KDF
@ -133,4 +134,84 @@ class KDFTest {
assertEquals(96, longKdf.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()
}
} }