Add seeded private key generation

This commit is contained in:
Sergey Chernov 2026-05-29 12:51:14 +03:00
parent 8015a4310b
commit d0f1fffe6d
6 changed files with 197 additions and 4 deletions

View File

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

View File

@ -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(),
)
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -82,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()