From d0f1fffe6dcfe2defdbf56243c03a05e2cb2b1c7 Mon Sep 17 00:00:00 2001 From: sergeych Date: Fri, 29 May 2026 12:51:14 +0300 Subject: [PATCH] Add seeded private key generation --- build.gradle.kts | 2 +- .../kotlin/net/sergeych/crypto2/KeySeed.kt | 65 +++++++++++++++++++ .../kotlin/net/sergeych/crypto2/SecretKey.kt | 31 ++++++++- .../net/sergeych/crypto2/SigningSecretKey.kt | 31 ++++++++- .../sergeych/crypto2/UniversalPrivateKey.kt | 30 ++++++++- src/commonTest/kotlin/KeysTest.kt | 42 ++++++++++++ 6 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 src/commonMain/kotlin/net/sergeych/crypto2/KeySeed.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3071a5a..093c20b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ plugins { } group = "net.sergeych" -version = "0.9.1-SNAPSHOT" +version = "0.9.2-SNAPSHOT" repositories { mavenCentral() diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/KeySeed.kt b/src/commonMain/kotlin/net/sergeych/crypto2/KeySeed.kt new file mode 100644 index 0000000..1b42d44 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto2/KeySeed.kt @@ -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() + 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(), + ) +} diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/SecretKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/SecretKey.kt index 5b49bac..02e217f 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/SecretKey.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/SecretKey.kt @@ -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") + } -} \ No newline at end of file +} diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/SigningSecretKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/SigningSecretKey.kt index f8966ac..e28c263 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/SigningSecretKey.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/SigningSecretKey.kt @@ -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") + } -} \ No newline at end of file +} diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/UniversalPrivateKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/UniversalPrivateKey.kt index c7b30f9..4539599 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/UniversalPrivateKey.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/UniversalPrivateKey.kt @@ -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.fromSeed(signingSeed.dropLast(1).toUByteArray()) + } + assertFailsWith { + DecryptingSecretKey.fromSeed(decryptingSeed.dropLast(1).toUByteArray()) + } + assertFailsWith { + UniversalPrivateKey.fromSeed(universalSeed.dropLast(1).toUByteArray()) + } + } + @Test fun secretEncryptTest() = runTest { initCrypto()