added HKDF, version will be 0.9.3
This commit is contained in:
parent
d402afe1bd
commit
84a3e82216
@ -20,7 +20,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "net.sergeych"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
78
src/commonMain/kotlin/net/sergeych/crypto2/HKDF.kt
Normal file
78
src/commonMain/kotlin/net/sergeych/crypto2/HKDF.kt
Normal 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))
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
/**
|
||||
|
||||
@ -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<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()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user