Compare commits

..
11 Commits
17 changed files with 858 additions and 54 deletions

No files matched your search

+4
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.
+3 -3
View File
@@ -13,14 +13,14 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("multiplatform") version "2.2.21"
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21"
kotlin("multiplatform") version "2.4.0"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0"
id("org.jetbrains.dokka") version "1.9.20"
`maven-publish`
}
group = "net.sergeych"
version = "0.9.0"
version = "0.9.4-SNAPSHOT"
repositories {
mavenCentral()
@@ -155,15 +155,9 @@ sealed class Container {
abstract val decryptedWithKeyId: KeyId?
/**
* If the container _is decrypted by the [EncryptingPublicKey]_, e.g., using secret key encryption,
* contains the [EncryptingPublicKey] that corresponds the [DecryptingSecretKey] used while encrypting, this
* authenticating the sender party cryptographically. This key could be used to encrypt
* the response to be visible to the sender only; the sender, providing it kept his secret key,
* could decrypt it.
* When the container is decrypted, the key that unlocked it, otherwise null.
*/
@Transient
var authorisedByKey: EncryptingPublicKey? = null
protected set
abstract val decryptedWithKey: UniversalKey?
/**
* List of [KeyId] of the keys that unlocked the container, in the same order used for encryption..
@@ -185,7 +179,8 @@ sealed class Container {
) : Container() {
@Transient
private var decryptedWithKey: DecryptingKey? = null
override var decryptedWithKey: UniversalKey? = null
private set
override val decryptedWithKeyId: KeyId?
get() = decryptedWithKey?.id
@@ -202,10 +197,7 @@ sealed class Container {
if (k.id == keyId) {
kotlin.runCatching { k.decrypt(encryptedMessage) }.getOrNull()?.let {
decryptedData = it
decryptedWithKey = k
if(k is DecryptingSecretKey) {
authorisedByKey = Asymmetric.Message.decode(encryptedMessage).senderPublicKey
}
decryptedWithKey = k as UniversalKey
return it
}
}
@@ -284,9 +276,12 @@ sealed class Container {
}
@Transient
override var decryptedWithKeyId: KeyId? = null
override var decryptedWithKey: UniversalKey? = null
private set
override val decryptedWithKeyId: KeyId?
get() = decryptedWithKey?.id
override val keyIds: List<KeyId> = encryptedKeys.map { it.tag }
override fun decryptWith(keyRing: UniversalRing): UByteArray? {
@@ -302,11 +297,8 @@ sealed class Container {
}.getOrNull()?.let { k ->
if (kotlin.runCatching { decryptedData = k.decrypt(encryptedMessage) }.isFailure)
throw InvalidContainerException()
decryptedWithKeyId = key.id
decryptedWithKey = key as UniversalKey
mainKey = k
if(key is DecryptingSecretKey) {
authorisedByKey = Asymmetric.Message.decode(encryptedKey.cipherData).senderPublicKey
}
}
if (decryptedData != null) return decryptedData
}
@@ -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))
}
@@ -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)
}
@@ -0,0 +1,65 @@
/*
* 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 com.ionspin.kotlin.crypto.generichash.GenericHash
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
/**
* Deterministic seed derivation helper.
*
* It compresses arbitrary byte material to an exact seed size using domain-separated BLAKE2b.
* This is intended for already strong random byte material. Do not use it as a password KDF;
* use [KDF] and [PBKD] for passwords and other low-entropy secrets.
*/
object KeySeed {
private const val maxBlake2bOutput = 64
private const val minBlake2bOutput = 16
private val apiKey = "net.sergeych.crypto2.KeySeed.v1".encodeToUByteArray()
/**
* Derive exactly [size] bytes suitable for APIs expecting fixed-size cryptographic seeds.
*
* [purpose] domain-separates seeds for different key types. Use a stable, key-specific value
* when deriving more than one seed from the same [source].
*/
fun fromBytes(source: UByteArray, size: Int, purpose: String = "default"): UByteArray {
require(size > 0) { "seed size should be positive" }
require(source.isNotEmpty()) { "source bytes should not be empty" }
val key = GenericHash.genericHash(apiKey + purpose.encodeToUByteArray(), 32)
if (size <= maxBlake2bOutput) {
val hashSize = size.coerceAtLeast(minBlake2bOutput)
return GenericHash.genericHash(source, hashSize, key).copyOf(size)
}
val result = mutableListOf<UByte>()
var round = 0
while (result.size < size) {
val block = GenericHash.genericHash(
round.toLittleEndianBytes() + size.toLittleEndianBytes() + source,
maxBlake2bOutput,
key
)
val count = minOf(block.size, size - result.size)
result += block.take(count)
round += 1
}
return result.toUByteArray()
}
private fun Int.toLittleEndianBytes(): UByteArray =
ubyteArrayOf(
(this and 0xff).toUByte(),
((this ushr 8) and 0xff).toUByte(),
((this ushr 16) and 0xff).toUByte(),
((this ushr 24) and 0xff).toUByte(),
)
}
@@ -11,7 +11,15 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.keyexchange.KeyExchange
import com.ionspin.kotlin.crypto.keyexchange.KeyExchangeKeyPair
import com.ionspin.kotlin.crypto.keyexchange.crypto_kx_PUBLICKEYBYTES
import com.ionspin.kotlin.crypto.keyexchange.crypto_kx_SECRETKEYBYTES
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import net.sergeych.crypto2.SafeKeyExchange.SessionKey
/**
@@ -20,7 +28,7 @@ import net.sergeych.crypto2.SafeKeyExchange.SessionKey
* Usage:
*
* 1. Create [SafeKeyExchange] on both server and client sides
* 2. Exchange [EncryptingPublicKey] instances
* 2. Exchange [PublicKey] instances
* 3. Create [serverSessionKey] and [clientSessionKey] respectively
* 4. Use [SessionKey.sendingKey] and [SessionKey.receivingKey] to send and receive encrypted data.
*
@@ -31,15 +39,50 @@ import net.sergeych.crypto2.SafeKeyExchange.SessionKey
* - while it is possible to generate several keys "ahead", the care should be taken when storing them,
* encrypt it with some other key to maintain safety.
* - do not use [EncryptingPublicKey] for anything but creating session keys.
* - the serialized form includes the secret exchange key, so it should be encrypted when stored.
*/
class SafeKeyExchange {
private val pair = KeyExchange.keypair()
@Serializable(with = SafeKeyExchange.SafeKeyExchangeSerializer::class)
class SafeKeyExchange private constructor(
private val pair: KeyExchangeKeyPair,
) {
constructor() : this(KeyExchange.keypair())
@Serializable
private class Packed(
val publicKey: UByteArray,
val secretKey: UByteArray,
)
object SafeKeyExchangeSerializer : KSerializer<SafeKeyExchange> {
private val packedSerializer = Packed.serializer()
override val descriptor: SerialDescriptor = packedSerializer.descriptor
override fun serialize(encoder: Encoder, value: SafeKeyExchange) {
encoder.encodeSerializableValue(
packedSerializer,
Packed(value.pair.publicKey, value.pair.secretKey)
)
}
override fun deserialize(decoder: Decoder): SafeKeyExchange {
val packed = decoder.decodeSerializableValue(packedSerializer)
require(packed.publicKey.size == crypto_kx_PUBLICKEYBYTES) {
"SafeKeyExchange public key must be $crypto_kx_PUBLICKEYBYTES bytes"
}
require(packed.secretKey.size == crypto_kx_SECRETKEYBYTES) {
"SafeKeyExchange secret key must be $crypto_kx_SECRETKEYBYTES bytes"
}
return SafeKeyExchange(KeyExchangeKeyPair(packed.publicKey, packed.secretKey))
}
}
/**
* The session key. It uses a pair of keys to encrypt and decrypt messages to maintain high
* security level and allow using counters as nonce with no extra precautions.
*/
@Serializable
@Serializable(with = SafeKeyExchange.SessionKeySerializer::class)
class SessionKey(
val sendingKey: EncryptingKey,
val receivingKey: DecryptingKey,
@@ -86,13 +129,58 @@ class SafeKeyExchange {
}
@Serializable
private class PackedSessionKey(
val sendingKey: SymmetricKey,
val receivingKey: SymmetricKey,
val isClient: Boolean,
)
object SessionKeySerializer : KSerializer<SessionKey> {
private val packedSerializer = PackedSessionKey.serializer()
override val descriptor: SerialDescriptor = packedSerializer.descriptor
override fun serialize(encoder: Encoder, value: SessionKey) {
val sendingKey = value.sendingKey as? SymmetricKey
?: throw SerializationException("SessionKey.sendingKey must be SymmetricKey")
val receivingKey = value.receivingKey as? SymmetricKey
?: throw SerializationException("SessionKey.receivingKey must be SymmetricKey")
encoder.encodeSerializableValue(
packedSerializer,
PackedSessionKey(sendingKey, receivingKey, value.isClient)
)
}
override fun deserialize(decoder: Decoder): SessionKey {
val packed = decoder.decodeSerializableValue(packedSerializer)
return SessionKey(packed.sendingKey, packed.receivingKey, packed.isClient)
}
}
/**
* The public key; it should be transmitted to the other party, this is serializable.
* Do not use it except to get [SessionKey] with [clientSessionKey] or [serverSessionKey]. Storing and reusing
* it is a great danger.
*
* Instances can be compared and used as hashtable keys.
*/
@Serializable
class PublicKey(val keyBytes: UByteArray)
class PublicKey(val keyBytes: UByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as PublicKey
return keyBytes.contentEquals(other.keyBytes)
}
override fun hashCode(): Int {
return keyBytes.contentHashCode()
}
}
/**
* The public key to be sent to the other party. When received, get the session keys with [clientSessionKey]
@@ -11,6 +11,7 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.box.Box
import com.ionspin.kotlin.crypto.box.crypto_box_SEEDBYTES
import com.ionspin.kotlin.crypto.scalarmult.ScalarMultiplication
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -87,6 +88,11 @@ class DecryptingSecretKey(
companion object {
data class KeyPair(val secretKey: DecryptingSecretKey, val publicKey: EncryptingPublicKey)
/**
* Required seed size for deterministic decrypting key generation.
*/
val seedLength = crypto_box_SEEDBYTES
/**
* Generate a new random pair of public and secret keys.
*/
@@ -96,7 +102,30 @@ class DecryptingSecretKey(
return KeyPair(DecryptingSecretKey(p.secretKey, pk), pk)
}
/**
* Generate the key pair deterministically from an exact [seedLength]-byte seed.
*/
fun generateKeys(seed: UByteArray): KeyPair {
require(seed.size == seedLength) { "seed should be $seedLength bytes" }
val p = Box.seedKeypair(seed)
val pk = EncryptingPublicKey(p.publicKey)
return KeyPair(DecryptingSecretKey(p.secretKey, pk), pk)
}
fun new(): DecryptingSecretKey = generateKeys().secretKey
/**
* Create a deterministic secret key from an exact [seedLength]-byte seed.
*/
fun fromSeed(seed: UByteArray): DecryptingSecretKey = generateKeys(seed).secretKey
/**
* Derive an exact [seedLength]-byte seed from arbitrary byte material.
*
* Use this only with high-entropy source bytes. It is not a password KDF.
*/
fun seedFromBytes(source: UByteArray): UByteArray =
KeySeed.fromBytes(source, seedLength, "DecryptingSecretKey")
}
}
@@ -11,6 +11,7 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.signature.Signature
import com.ionspin.kotlin.crypto.signature.crypto_sign_SEEDBYTES
import kotlin.time.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -76,13 +77,41 @@ class SigningSecretKey(
data class SigningKeyPair(val secretKey: SigningSecretKey, val publicKey: VerifyingPublicKey)
/**
* Required seed size for deterministic signing key generation.
*/
const val seedLength = crypto_sign_SEEDBYTES
fun generatePair(): SigningKeyPair {
val p = Signature.keypair()
val publicKey = VerifyingPublicKey(p.publicKey)
return SigningKeyPair(SigningSecretKey(p.secretKey, publicKey), publicKey)
}
/**
* Generate the key pair deterministically from an exact [seedLength]-byte seed.
*/
fun generatePair(seed: UByteArray): SigningKeyPair {
require(seed.size == seedLength) { "seed should be $seedLength bytes" }
val p = Signature.seedKeypair(seed)
val publicKey = VerifyingPublicKey(p.publicKey)
return SigningKeyPair(SigningSecretKey(p.secretKey, publicKey), publicKey)
}
fun new(): SigningSecretKey = generatePair().secretKey
/**
* Create a deterministic signing key from an exact [seedLength]-byte seed.
*/
fun fromSeed(seed: UByteArray): SigningSecretKey = generatePair(seed).secretKey
/**
* Derive an exact [seedLength]-byte seed from arbitrary byte material.
*
* Use this only with high-entropy source bytes. It is not a password KDF.
*/
fun seedFromBytes(source: UByteArray): UByteArray =
KeySeed.fromBytes(source, seedLength, "SigningSecretKey")
}
}
@@ -46,13 +46,54 @@ class SymmetricKey(
data class WithNonce(
val cipherData: UByteArray,
val nonce: UByteArray,
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as WithNonce
if (!cipherData.contentEquals(other.cipherData)) return false
if (!nonce.contentEquals(other.nonce)) return false
return true
}
override fun hashCode(): Int {
var result = cipherData.contentHashCode()
result = 31 * result + nonce.contentHashCode()
return result
}
}
override fun encryptWithNonce(plainData: UByteArray, nonce: UByteArray, randomFill: IntRange?): UByteArray {
require(nonce.size == nonceLength)
return SecretBox.easy(WithFill.encode(plainData, randomFill), nonce, keyBytes)
}
/**
* Compact authenticated encryption with a caller-provided nonce.
*
* This method is meant for storage formats where the nonce is derived or stored elsewhere
* and every byte of encoded output matters, for example short encrypted file names.
* The output is the raw SecretBox ciphertext only: [plainData] size plus 16 authentication
* bytes. The nonce is not stored in the output and must be reproduced exactly for
* [decryptCompactWithNonce].
*
* Unlike [encryptWithNonce], this method does not wrap [plainData] with [WithFill]. For
* short payloads this usually saves only 1 byte over [encryptWithNonce], because both
* methods already require the caller to manage the nonce separately. Use it only when this
* small size gain is important or when the surrounding format explicitly requires raw
* SecretBox output.
*
* Never reuse the same nonce with the same key for different plaintext. Reusing a
* `(key, nonce)` pair with SecretBox breaks the security of the stream cipher.
*/
fun encryptCompactWithNonce(plainData: UByteArray, nonce: UByteArray): UByteArray {
require(nonce.size == nonceLength)
return SecretBox.easy(plainData, nonce, keyBytes)
}
override val nonceBytesLength: Int = nonceLength
override val id by lazy {
@@ -64,6 +105,22 @@ class SymmetricKey(
WithFill.decode(SecretBox.openEasy(cipherData, nonce, keyBytes))
}
/**
* Compact authenticated decryption with a caller-provided nonce.
*
* Decrypts data produced by [encryptCompactWithNonce]. The nonce is not read from
* [cipherData] and must be the exact same nonce that was used for encryption. Use this
* method only for compact formats that intentionally omit both the nonce and the [WithFill]
* wrapper.
*
* @throws DecryptionFailedException if the key, nonce, or cipher data is invalid.
*/
fun decryptCompactWithNonce(cipherData: UByteArray, nonce: UByteArray): UByteArray =
protectDecryption {
require(nonce.size == nonceLength)
SecretBox.openEasy(cipherData, nonce, keyBytes)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SymmetricKey) return false
@@ -48,10 +48,38 @@ class UniversalPrivateKey(
}
companion object {
/**
* Required seed size for deterministic universal private key generation.
*
* It contains independent signing and decrypting seeds.
*/
val seedLength = SigningSecretKey.seedLength + DecryptingSecretKey.seedLength
/**
* Generate 2 new random keys (4 key pairs under the hood) to securely signd and
* decrypt data.
*/
fun new() = UniversalPrivateKey(SigningSecretKey.new(), DecryptingSecretKey.new())
/**
* Create a deterministic private key from an exact [seedLength]-byte seed.
*/
fun fromSeed(seed: UByteArray): UniversalPrivateKey {
require(seed.size == seedLength) { "seed should be $seedLength bytes" }
val signingSeed = seed.sliceArray(0..<SigningSecretKey.seedLength)
val decryptingSeed = seed.sliceArray(SigningSecretKey.seedLength..<seedLength)
return UniversalPrivateKey(
SigningSecretKey.fromSeed(signingSeed),
DecryptingSecretKey.fromSeed(decryptingSeed)
)
}
/**
* Derive an exact [seedLength]-byte seed from arbitrary byte material.
*
* Use this only with high-entropy source bytes. It is not a password KDF.
*/
fun seedFromBytes(source: UByteArray): UByteArray =
KeySeed.fromBytes(source, seedLength, "UniversalPrivateKey")
}
}
@@ -229,6 +229,8 @@ class UniversalRing(
companion object {
val EMPTY = UniversalRing(keyWithTags = emptyMap())
fun new(): UniversalRing = UniversalRing(keyWithTags = emptyMap())
/**
* Join a collection of keyrings together (same as reducing with `+`). Correctly
* works if there is no keyring (returns [EMPTY]), or only one keyring (returns
+129 -10
View File
@@ -42,16 +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)
/**
* 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_.
@@ -59,13 +71,22 @@ 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)
/**
* Derive single key from password, same as [deriveMultiple] with count=1.
*/
fun derive(password: String, salt: UByteArray): SymmetricKey = deriveMultiple(password, 1, salt).first()
/**
* 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 =
createDefault(derivedSize, this, salt).deriveFromBytes(source)
}
/**
@@ -73,6 +94,11 @@ sealed class KDF {
*/
abstract fun deriveKey(password: String): UByteArray
/**
* Derive bytes from bytes using this KDF parameters. The derived byte count is this KDF's configured key size.
*/
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
* required to create [count] keys, the first bytes will be used. It let use rest bytes for other purposes.
@@ -131,6 +157,17 @@ sealed class KDF {
override fun deriveKey(password: String): UByteArray =
PasswordHash.pwhash(keySize, password, salt, instructionsComplexity, memComplexity, algorithm.code)
override fun deriveFromBytes(source: UByteArray): UByteArray {
return PasswordHash.pwhash(
keySize,
source.encodeToBase64Url(),
salt,
instructionsComplexity,
memComplexity,
algorithm.code
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Argon) return false
@@ -171,7 +208,7 @@ sealed class KDF {
fun create(complexity: Complexity, salt: UByteArray, keySize: Int): Argon {
require(salt.size == saltSize) { "The salt size should be $saltSize" }
require(keySize > minKeySize) { "The key size should be at least $keySize bytes" }
require(keySize >= minKeySize) { "The key size should be at least $minKeySize bytes" }
return when (complexity) {
FixedLow -> Argon(
V2id_13,
@@ -220,14 +257,96 @@ sealed class KDF {
}
}
companion object {
fun creteDefault(keySize: Int, complexity: Complexity, salt: UByteArray = Argon.randomSalt()): KDF {
return Argon.create(complexity, salt, keySize)
/**
* 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 {
/**
* 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)
}
+4 -2
View File
@@ -34,6 +34,8 @@ class ContainerTest {
assertNotNull(d)
assertContentEquals(data, d)
assertTrue { c1.isDecrypted }
assertEquals(syk1.id, c1.decryptedWithKeyId)
assertEquals(syk1, c1.decryptedWithKey)
val data2 = "To push unpushinable".encodeToUByteArray()
val c2 = Container.decode(c.updateData(data2).encoded)
@@ -61,7 +63,7 @@ class ContainerTest {
assertContentEquals(data, d)
assertTrue { c1.isDecrypted }
assertEquals(p2.publicKey.id, c1.decryptedWithKeyId)
assertEquals(p1.publicKey, c1.authorisedByKey)
assertEquals(p2.secretKey, c1.decryptedWithKey)
val data2 = "To push unpushinable".encodeToUByteArray()
val c2 = Container.decode(c.updateData(data2).encoded)
@@ -88,7 +90,7 @@ class ContainerTest {
assertContentEquals(data, d)
assertTrue { c1.isDecrypted }
assertEquals(p4.publicKey.id, c1.decryptedWithKeyId)
assertEquals(p1.publicKey, c1.authorisedByKey)
assertEquals(p4.secretKey, c1.decryptedWithKey)
val data2 = "To push unpushinable".encodeToUByteArray()
val c2 = Container.decode(c.updateData(data2).encoded)
+26 -9
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)
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)
assertFalse { x contentEquals z }
assertEquals(x.size, i)
assertEquals(y.size, i)
assertEquals(z.size, i)
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
}
+159
View File
@@ -8,12 +8,16 @@
* 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
import net.sergeych.crypto2.initCrypto
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
class KDFTest {
@Test
@@ -55,4 +59,159 @@ class KDFTest {
assertEquals(3, kk.size)
}
@Test
fun complexityKdfForSizeInBytesTest() = runTest {
initCrypto()
val size = KDF.Argon.minKeySize + 17
val expectedSalt = UByteArray(KDF.Argon.saltSize) { (it + 3).toUByte() }
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)
}
@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
fun deriveFromBytesTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { it.toUByte() }
val kdf = KDF.Argon.create(KDF.Complexity.Interactive, salt, 80)
val source = UByteArray(47) { (it * 3).toUByte() }
val b1 = kdf.deriveFromBytes(source)
val b2 = kdf.deriveFromBytes(source)
val b3 = kdf.deriveFromBytes(source + 1u)
assertEquals(80, b1.size)
assertContentEquals(b1, b2)
assertFalse { b1 contentEquals b3 }
}
@Test
fun complexityDeriveFromBytesTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { (it + 1).toUByte() }
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)
assertContentEquals(b1, b2)
assertEquals(64, b1.size)
}
@Test
fun deriveFromBytesUsesKeySizeTest() = runTest {
initCrypto()
val salt = UByteArray(KDF.Argon.saltSize) { it.toUByte() }
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)
assertEquals(64, shortKdf.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()
}
}
+122 -1
View File
@@ -11,7 +11,6 @@
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
@@ -83,6 +82,48 @@ class KeysTest {
}
@Test
fun seededPrivateKeysTest() = runTest {
initCrypto()
val source = "stable high entropy key material placeholder".encodeToUByteArray() + randomUBytes(128)
val data = "seeded generation works".encodeToUByteArray()
val signingSeed = SigningSecretKey.seedFromBytes(source)
val decryptingSeed = DecryptingSecretKey.seedFromBytes(source)
assertEquals(SigningSecretKey.seedLength, signingSeed.size)
assertEquals(DecryptingSecretKey.seedLength, decryptingSeed.size)
assertFalse(signingSeed contentEquals decryptingSeed)
val signingKey1 = SigningSecretKey.fromSeed(signingSeed)
val signingKey2 = SigningSecretKey.fromSeed(signingSeed)
assertEquals(signingKey1, signingKey2)
val signature = signingKey1.sign(data)
assertTrue(signingKey2.verifyingKey.verify(signature, data))
val decryptingKey1 = DecryptingSecretKey.fromSeed(decryptingSeed)
val decryptingKey2 = DecryptingSecretKey.fromSeed(decryptingSeed)
assertEquals(decryptingKey1.publicKey, decryptingKey2.publicKey)
assertContentEquals(data, decryptingKey2.decrypt(decryptingKey1.publicKey.encrypt(data)))
val universalSeed = UniversalPrivateKey.seedFromBytes(source)
assertEquals(UniversalPrivateKey.seedLength, universalSeed.size)
val universalKey1 = UniversalPrivateKey.fromSeed(universalSeed)
val universalKey2 = UniversalPrivateKey.fromSeed(universalSeed)
assertEquals(universalKey1.publicKey, universalKey2.publicKey)
assertTrue(universalKey2.publicKey.verify(universalKey1.sign(data), data))
assertContentEquals(data, universalKey2.decrypt(universalKey1.publicKey.encrypt(data)))
assertFailsWith<IllegalArgumentException> {
SigningSecretKey.fromSeed(signingSeed.dropLast(1).toUByteArray())
}
assertFailsWith<IllegalArgumentException> {
DecryptingSecretKey.fromSeed(decryptingSeed.dropLast(1).toUByteArray())
}
assertFailsWith<IllegalArgumentException> {
UniversalPrivateKey.fromSeed(universalSeed.dropLast(1).toUByteArray())
}
}
@Test
fun secretEncryptTest() = runTest {
initCrypto()
@@ -118,6 +159,44 @@ class KeysTest {
assertContentEquals(src, k1.decrypt(k1.encrypt(src, 7..117)))
}
@Test
fun symmetricKeyCompactTest() = runTest {
initCrypto()
val key = SymmetricKey.new()
val otherKey = SymmetricKey.new()
val src = "readme.md".encodeToUByteArray()
val nonce = key.randomNonce()
val cipher = key.encryptCompactWithNonce(src, nonce)
assertEquals(src.size + 16, cipher.size)
assertTrue(cipher.size < key.encryptWithNonce(src, nonce).size)
assertTrue(cipher.size < key.encrypt(src).size)
assertContentEquals(src, key.decryptCompactWithNonce(cipher, nonce))
assertThrows<DecryptionFailedException> {
otherKey.decryptCompactWithNonce(cipher, nonce)
}
assertThrows<DecryptionFailedException> {
val n2 = nonce.copyOf()
n2[4] = n2[4].inv()
key.decryptCompactWithNonce(cipher, n2)
}
assertThrows<DecryptionFailedException> {
val c2 = cipher.copyOf()
c2[3] = c2[3].inv()
key.decryptCompactWithNonce(c2, nonce)
}
assertThrows<IllegalArgumentException> {
key.encryptCompactWithNonce(src, nonce.dropLast(1).toUByteArray())
}
assertThrows<DecryptionFailedException> {
key.decryptCompactWithNonce(cipher, nonce.dropLast(1).toUByteArray())
}
}
@Test
fun keyExchangeTest() = runTest {
initCrypto()
@@ -142,6 +221,48 @@ class KeysTest {
}
@Test
fun keyExchangeSerializationTest() = runTest {
initCrypto()
val ske = SafeKeyExchange()
val packedExchange = pack(ske)
val cke = SafeKeyExchange()
val clientSessionKey = cke.clientSessionKey(ske.publicKey)
val restoredExchange = unpack<SafeKeyExchange>(packedExchange)
assertEquals(ske.publicKey, restoredExchange.publicKey)
val serverSessionKey = restoredExchange.serverSessionKey(cke.publicKey)
val src = "Hello after restore!"
assertEquals(src, serverSessionKey.decryptString(clientSessionKey.encrypt(src)))
assertEquals(src, clientSessionKey.decryptString(serverSessionKey.encrypt(src)))
assertContentEquals(clientSessionKey.sessionTag, serverSessionKey.sessionTag)
}
@Test
fun sessionKeySerializationTest() = runTest {
initCrypto()
val ske = SafeKeyExchange()
val cke = SafeKeyExchange()
val clientSessionKey = cke.clientSessionKey(ske.publicKey)
val serverSessionKey = ske.serverSessionKey(cke.publicKey)
val restoredClientSessionKey = unpack<SafeKeyExchange.SessionKey>(pack(clientSessionKey))
val restoredServerSessionKey = unpack<SafeKeyExchange.SessionKey>(pack(serverSessionKey))
assertEquals(clientSessionKey, restoredClientSessionKey)
assertEquals(serverSessionKey, restoredServerSessionKey)
assertContentEquals(clientSessionKey.sessionTag, restoredClientSessionKey.sessionTag)
assertContentEquals(serverSessionKey.sessionTag, restoredServerSessionKey.sessionTag)
val src = "Hello from restored session keys!"
assertEquals(src, restoredServerSessionKey.decryptString(restoredClientSessionKey.encrypt(src)))
assertEquals(src, restoredClientSessionKey.decryptString(restoredServerSessionKey.encrypt(src)))
}
@Test
fun asymmetricKeyTest() = runTest {
initCrypto()