Fix salt derivation and KDF byte derivation

This commit is contained in:
Sergey Chernov 2026-05-30 15:48:48 +03:00
parent 2d55af4e3b
commit ff494ab4ef
5 changed files with 121 additions and 49 deletions

View File

@ -110,6 +110,10 @@ Keys could be associated with tags. Keyrings are used primarily to store keys in
Using very strong Argon_v2id, and adjustable complexity. Allows storing password key derivation parameters (included in the generated symmetric keys) to re-derive keys later, allows multiple keys derivation. All structures meant to be stored are serializable.
KDF convenience constructors accept salt bytes directly. When the provided salt is not exactly the Argon salt size, it is deterministically normalized with `Hash.Blake2b.deriveSaltFormBytes(...)` before the KDF is created; exact-size salts are used as is.
`KDF.deriveFromBytes(source)` derives exactly the configured KDF key size. Create the KDF with the required key size first, for example through `KDF.createDefault(...)` or `Complexity.kdfForSizeInBytes(...)`.
## Unified keys hierarchy
Allows the application code to use proper key abstraction and work with more key types in the future, e.g. `SigningKey`, `VerifyingKey`, `EncryptingKey` and `DecryptingKey`. Effective key generation and random byte sequence producers.
@ -133,4 +137,4 @@ you need to obtain a license from https://8-rays.dev or [Sergey Chernov]. For op
It will be moved to open source; we also guarantee that it will be moved to open source immediately if the software export restrictions will be lifted. We do not support such practices here at 8-rays.dev and assume open source must be open.
[Sergey Chernov]: https://t.me/real_sergeych
[Sergey Chernov]: https://t.me/real_sergeych

View File

@ -129,27 +129,41 @@ enum class Hash(
* strong to brute force, but it is good when you just need to get a deterministic salt of arbitrary size.
*
* _Note that deriving salt of sizes less than hash block will reduce hash strength and should not be allowed
* in situation where strength is of concern while extending its length above hash block size does not improve
* in a situation where strength is of concern while extending its length above hash block size does not improve
* it_. The only reason to use this function is when the desired _salt size_ is not equal to block size, or
* not known beforehand.1
* not known beforehand.
*
* To get a cryptographically safe (more or less) key from password use [KDF] classes, or [KDF.deriveKey]
* and [KDF.deriveMultipleKeys].
*/
fun deriveSalt(base: String, sizeInBytes: Int): UByteArray {
fun deriveSalt(base: String, sizeInBytes: Int): UByteArray =
deriveSaltFromBytes(base.encodeToUByteArray(), sizeInBytes)
/**
* Derive a salt of any size from binary data.
*
* @see deriveSalt
*/
fun deriveSaltFromBytes(base: UByteArray, sizeInBytes: Int): UByteArray {
require(sizeInBytes > 0)
val result = mutableListOf<UByte>()
var round = 0
var src = base.encodeToUByteArray()
var src = base
do {
src = "rnd_${round++}_".encodeToUByteArray() + src
val hash = digest(src)
if (result.size + hash.size <= sizeInBytes) result += hash
else result += hash.slice(0..<(sizeInBytes - result.size))
else result += hash.copyOf(sizeInBytes - result.size)
} while (result.size < sizeInBytes)
return result.toUByteArray()
}
/**
* Typo-compatible alias for [deriveSaltFromBytes].
*/
fun deriveSaltFormBytes(base: UByteArray, sizeInBytes: Int): UByteArray =
deriveSaltFromBytes(base, sizeInBytes)
}

View File

@ -42,22 +42,28 @@ sealed class KDF {
/**
* Create [KDF] of the corresponding strength suitable to derive [numberOfKeys] symmetric keys.
*
* Random salt of proper size is used
* Random salt of proper size is used by default. If a custom [salt] size is not [Argon.saltSize],
* it is deterministically normalized with `Hash.Blake2b.deriveSaltFormBytes(...)`.
*/
fun kdfForSize(numberOfKeys: Int,salt: UByteArray = Argon.randomSalt()): KDF =
creteDefault(SymmetricKey.keyLength * numberOfKeys, this, salt)
fun kdfForSize(numberOfKeys: Int, salt: UByteArray = Argon.randomSalt()): KDF =
createDefault(SymmetricKey.keyLength * numberOfKeys, this, salt)
fun kdfForSizeInBytes(sizeInBytes: Int, domain: String): KDF {
val salt = Hash.Blake2b.deriveSalt(domain, Argon.saltSize)
return creteDefault(sizeInBytes, this, salt)
}
/**
* Create [KDF] for deriving [sizeInBytes] bytes using the provided [salt].
*
* The [salt] is used as is when its size is [Argon.saltSize]. Otherwise, it is deterministically
* normalized with `Hash.Blake2b.deriveSaltFormBytes(...)` to the required size.
*/
fun kdfForSizeInBytes(sizeInBytes: Int, salt: UByteArray): KDF =
createDefault(sizeInBytes, this, salt)
/**
* Derive multiple keys from the password. Derivation params will be included in the key ids, see
* [SymmetricKey.id] as [KeyId.kdp].
* Random salt of proper size is used
* If the [salt] size is not [Argon.saltSize], it is deterministically normalized with
* `Hash.Blake2b.deriveSaltFormBytes(...)`.
*
* ___Important: symmetric keys do not save key ids___. _Container do it, so it is possible to re-derive
* key to open the container, but in many cases you might need to save [KeyId.kdp] separately_.
@ -65,7 +71,7 @@ sealed class KDF {
* to change with time.
*/
@Suppress("unused")
fun deriveMultiple(password: String, count: Int,salt: UByteArray): List<SymmetricKey> =
fun deriveMultiple(password: String, count: Int, salt: UByteArray): List<SymmetricKey> =
kdfForSize(count, salt).deriveMultipleKeys(password, count)
/**
@ -75,9 +81,12 @@ sealed class KDF {
/**
* Derive bytes from bytes using the default [Argon] KDF with this complexity.
*
* If the [salt] size is not [Argon.saltSize], it is deterministically normalized with
* `Hash.Blake2b.deriveSaltFormBytes(...)`.
*/
fun derive(source: UByteArray, salt: UByteArray, derivedSize: Int): UByteArray =
creteDefault(derivedSize, this, salt).deriveFromBytes(source, derivedSize)
createDefault(derivedSize, this, salt).deriveFromBytes(source)
}
/**
@ -86,9 +95,9 @@ sealed class KDF {
abstract fun deriveKey(password: String): UByteArray
/**
* Derive bytes from bytes using this KDF parameters.
* Derive bytes from bytes using this KDF parameters. The derived byte count is this KDF's configured key size.
*/
abstract fun deriveFromBytes(source: UByteArray, derivedSize: Int): UByteArray
abstract fun deriveFromBytes(source: UByteArray): UByteArray
/**
* Derive keys from lower part of bytes derived from the password. E.g., if generated size is longer than
@ -148,10 +157,9 @@ sealed class KDF {
override fun deriveKey(password: String): UByteArray =
PasswordHash.pwhash(keySize, password, salt, instructionsComplexity, memComplexity, algorithm.code)
override fun deriveFromBytes(source: UByteArray, derivedSize: Int): UByteArray {
require(derivedSize >= minKeySize) { "The derived size should be at least $minKeySize bytes" }
override fun deriveFromBytes(source: UByteArray): UByteArray {
return PasswordHash.pwhash(
derivedSize,
keySize,
source.encodeToBase64Url(),
salt,
instructionsComplexity,
@ -251,10 +259,23 @@ sealed class KDF {
companion object {
fun creteDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF {
return Argon.create(complexity, salt, keySize)
/**
* Create default [Argon] KDF parameters. If [salt] is not exactly [Argon.saltSize], it is
* deterministically normalized with [Hash.Blake2b.deriveSaltFormBytes] before creating the KDF.
*/
fun createDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF {
val normalizedSalt = if (salt.size == Argon.saltSize) {
salt
} else {
Hash.Blake2b.deriveSaltFormBytes(salt, Argon.saltSize)
}
return Argon.create(complexity, normalizedSalt, keySize)
}
@Deprecated("Use createDefault", ReplaceWith("createDefault(keySize, complexity, salt)"))
fun creteDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF =
createDefault(keySize, complexity, salt)
}
data class Instance(val kdf: KDF, val password: String)

View File

@ -55,15 +55,33 @@ class HashTest {
@Test
fun deriveSaltTest() = runTest {
initCrypto()
for( i in 2..257 ) {
val x = Hash.Sha3AndBlake.deriveSalt("base one", i)
val y = Hash.Sha3AndBlake.deriveSalt("base one", i)
val z = Hash.Sha3AndBlake.deriveSalt("base two", i)
assertContentEquals(x, y)
assertFalse { x contentEquals z }
assertEquals(x.size, i)
assertEquals(y.size, i)
assertEquals(z.size, i)
for (hash in Hash.entries) {
for (i in 1..257) {
val x = hash.deriveSalt("base one", i)
val y = hash.deriveSalt("base one", i)
val z = hash.deriveSalt("base two", i)
assertContentEquals(x, y)
if (i > 1) assertFalse { x contentEquals z }
assertEquals(i, x.size)
assertEquals(i, y.size)
assertEquals(i, z.size)
}
}
}
@Test
fun deriveSaltFromBytesTest() = runTest {
initCrypto()
val base = "base one".encodeToByteArray().asUByteArray()
for (hash in Hash.entries) {
for (i in listOf(1, 5, 31, 32, 33, 96, 97)) {
val fromString = hash.deriveSalt("base one", i)
val fromBytes = hash.deriveSaltFromBytes(base, i)
val fromTypoAlias = hash.deriveSaltFormBytes(base, i)
assertEquals(i, fromBytes.size)
assertContentEquals(fromString, fromBytes)
assertContentEquals(fromBytes, fromTypoAlias)
}
}
}
@ -78,4 +96,3 @@ suspend fun <T> sw(label: String, f: suspend () -> T): T {
return result
}

View File

@ -16,7 +16,6 @@ import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
class KDFTest {
@ -63,18 +62,34 @@ class KDFTest {
fun complexityKdfForSizeInBytesTest() = runTest {
initCrypto()
val size = KDF.Argon.minKeySize + 17
val domain = "kdf-size-in-bytes-test"
val expectedSalt = Hash.Blake2b.deriveSalt(domain, KDF.Argon.saltSize)
val expectedSalt = UByteArray(KDF.Argon.saltSize) { (it + 3).toUByte() }
val kdf = KDF.Complexity.FixedLow.kdfForSizeInBytes(size, domain)
val kdf = KDF.Complexity.FixedLow.kdfForSizeInBytes(size, expectedSalt)
assertIs<KDF.Argon>(kdf)
assertEquals(KDF.Argon.create(KDF.Complexity.FixedLow, expectedSalt, size), kdf)
assertEquals(size, kdf.keySize)
assertContentEquals(expectedSalt, kdf.salt)
assertFalse {
kdf.salt contentEquals Hash.Blake2b.deriveSalt("$domain-other", KDF.Argon.saltSize)
}
}
@Test
fun createDefaultNormalizesSaltTest() = runTest {
initCrypto()
val size = KDF.Argon.minKeySize + 19
val shortSalt = ubyteArrayOf(1u, 2u, 3u, 4u, 5u)
val longSalt = UByteArray(KDF.Argon.saltSize + 7) { (it * 5).toUByte() }
val expectedShortSalt = Hash.Blake2b.deriveSaltFormBytes(shortSalt, KDF.Argon.saltSize)
val expectedLongSalt = Hash.Blake2b.deriveSaltFormBytes(longSalt, KDF.Argon.saltSize)
val shortKdf = KDF.createDefault(size, KDF.Complexity.FixedLow, shortSalt)
val longKdf = KDF.Complexity.FixedLow.kdfForSizeInBytes(size, longSalt)
assertIs<KDF.Argon>(shortKdf)
assertIs<KDF.Argon>(longKdf)
assertEquals(KDF.Argon.saltSize, shortKdf.salt.size)
assertEquals(KDF.Argon.saltSize, longKdf.salt.size)
assertContentEquals(expectedShortSalt, shortKdf.salt)
assertContentEquals(expectedLongSalt, longKdf.salt)
}
@Test
@ -84,9 +99,9 @@ class KDFTest {
val kdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 80)
val source = UByteArray(47) { (it * 3).toUByte() }
val b1 = kdf.deriveFromBytes(source, 80)
val b2 = kdf.deriveFromBytes(source, 80)
val b3 = kdf.deriveFromBytes(source + 1u, 80)
val b1 = kdf.deriveFromBytes(source)
val b2 = kdf.deriveFromBytes(source)
val b3 = kdf.deriveFromBytes(source + 1u)
assertEquals(80, b1.size)
assertContentEquals(b1, b2)
@ -100,21 +115,22 @@ class KDFTest {
val source = UByteArray(23) { (it + 7).toUByte() }
val b1 = KDF.Complexity.Interactive.derive(source, salt, 64)
val b2 = KDF.Argon.create(KDF.Complexity.Interactive, salt, 64).deriveFromBytes(source, 64)
val b2 = KDF.Argon.create(KDF.Complexity.Interactive, salt, 64).deriveFromBytes(source)
assertContentEquals(b1, b2)
assertEquals(64, b1.size)
}
@Test
fun deriveFromBytesValidationTest() = runTest {
fun deriveFromBytesUsesKeySizeTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { it.toUByte() }
val kdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, KDF.Argon.minKeySize)
val source = ubyteArrayOf(1u, 2u, 3u)
val shortKdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 64)
val longKdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 96)
assertFailsWith<IllegalArgumentException> {
kdf.deriveFromBytes(ubyteArrayOf(1u, 2u, 3u), KDF.Argon.minKeySize - 1)
}
assertEquals(64, shortKdf.deriveFromBytes(source).size)
assertEquals(96, longKdf.deriveFromBytes(source).size)
}
}