redesigned signed box now Sealed box, refactored seals to incorporate expiration, introduced symmetric keys support

This commit is contained in:
Sergey Chernov 2024-06-11 12:33:37 +07:00
parent b11ea4d35a
commit aad44c5af5
9 changed files with 290 additions and 85 deletions

View File

@ -16,6 +16,9 @@ repositories {
kotlin {
jvm()
js {
browser()
}
linuxX64()
linuxArm64()
@ -25,7 +28,8 @@ kotlin {
// iosArm64()
// iosSimulatorArm64()
mingwX64()
// wasmJs() no libsodimu bindings yet (strangely)
// @OptIn(ExperimentalWasmDsl::class)
// wasmJs() //no libsodium bindings yet (strangely)
// val ktor_version = "2.3.6"
sourceSets {
@ -41,9 +45,8 @@ kotlin {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.0")
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.2")
api("com.ionspin.kotlin:bignum:0.3.9")
api("net.sergeych:mp_bintools:0.1.5-SNAPSHOT")
api("net.sergeych:mp_stools:1.4.1")
}

View File

@ -0,0 +1,24 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
import net.sergeych.bipack.BipackDecoder
/**
* Some key able to perform decrypting. It is not serializable by purpose, as not all such
* keys are wise to transfer/save. Concrete implementations are, like [SymmetricKey].
*/
interface DecryptingKey {
/**
* Authenticated decryption that checks the message is not tampered and therefor
* the key is valid. It is not possible in general to distinguish whether the key is invalid
* or the encrypted data is tampered so the only one exception is used.
*
* @throws DecryptionFailedException if the key is not valid or [cipherData] tampered.
*/
fun decrypt(cipherData: UByteArray): UByteArray
fun decryptString(cipherData: UByteArray): String = decrypt(cipherData).decodeFromUByteArray()
}
inline fun <reified T>DecryptingKey.decryptObject(cipherData: UByteArray): T =
BipackDecoder.decode<T>(decrypt(cipherData).toByteArray())

View File

@ -0,0 +1,25 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import net.sergeych.bipack.BipackEncoder
/**
* Some key able to encrypt data with optional random fill that conceals message size
* when needed.
*
* It is not serializable by design.
* Custom implementations are, see [SymmetricKey] for example.
*/
interface EncryptingKey {
/**
* Authenticated encrypting with optional random fill to protect from message size analysis.
* Note that [randomFill] if present should be positive.
*/
fun encrypt(plainData: UByteArray,randomFill: IntRange?=null): UByteArray
fun encrypt(plainText: String,randomFill: IntRange? = null): UByteArray =
encrypt(plainText.encodeToUByteArray(),randomFill)
}
inline fun <reified T>EncryptingKey.encryptObject(value: T,randomFill: IntRange? = null): UByteArray =
encrypt(BipackEncoder.encode(value).toUByteArray(),randomFill)

View File

@ -3,20 +3,44 @@ package net.sergeych.crypto2
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import net.sergeych.bipack.BipackEncoder
import net.sergeych.bipack.decodeFromBipack
import net.sergeych.crypto2.Seal.Companion.create
import net.sergeych.utools.now
import kotlin.random.Random
import kotlin.random.nextUBytes
/**
* Extended public-key signature.
*
* See [Seal.create] for details and usage.
*
* This constructor normally should not be used directly, please use [Seal.create] instead
*
* @param publicKey the public key that could be used to verify the message. It could safely be published.
* @param signature the signature generated by [create]. Do not generate it yourself.
* @param nonce if [create] was called with `isDeterministic = true` there will be a random nonce.
* that makes it impossible to judge whether known seals are related to the same unknown message.
* @param createdAt creation time as specified when creating.
* @param expiresAt if set by creator, determines the time instant from when the seal is invalid.
*/
@Serializable
class Seal(
val publicKey: SigningKey.Public,
val signature: UByteArray,
val nonce: UByteArray?,
val createdAt: Instant,
val expiresAt: Instant? = null,
val expiresAt: Instant?,
) {
/**
* @suppress
* This is the structure that is actually signed/verified with a key.
*/
@Suppress("unused")
@Serializable
class SealedData(
val message: UByteArray,
val nonce: UByteArray?,
val createdAt: Instant?,
val validUntil: Instant?,
)
@ -35,10 +59,13 @@ class Seal(
* Check that message is correct for this seal and throws exception if it is not.
* Note that tampering [createdAt] and [expiresAt] invalidate the seal too.
*
* It checks first the signature. _If it is ok, it also checks_ [expiresAt].
*
* See [check] and [isValid] for non-throwing checks.
*
* @throws ExpiredSignatureException
* @throws IllegalSignatureException
* @throws IllegalSignatureException if the signature is not valid for this [message]
* @throws ExpiredSignatureException if the signature is valid, but [expiresAt] is not null and the seal
* is expired.
*/
fun verify(message: UByteArray) {
val n = now()
@ -46,19 +73,80 @@ class Seal(
expiresAt?.let {
if (n >= it) throw ExpiredSignatureException("signature expired at $it")
}
val data = BipackEncoder.encode(SealedData(message, createdAt, expiresAt))
val data = BipackEncoder.encode(SealedData(message, nonce, createdAt, expiresAt))
if (!publicKey.verify(signature, data.toUByteArray()))
throw IllegalSignatureException()
}
/**
* Check that the seal is not expired.
* __It is important__ that you can't determine whether the seal
* is genuine and [expiresAt] not tampered if you do not have a message. If you do, be sure to call
* [verify] or check [isValid] before calling this method.
*/
fun isExpired(): Boolean = expiresAt?.let { it > now() } != false
/**
* In the rare cases you want to use it alone, the packed binary representation, use [unpack] to restore.
* Most often, all you need is to put your Seal inside some kotlinx-serializable class.
*/
val packed: UByteArray by lazy { BipackEncoder.encode(this).toUByteArray() }
companion object {
operator fun invoke(
key: SigningKey.Secret, message: UByteArray,
/**
* Seal [message] with a [key].
*
* Seals are kotlinx-serializable and can be used
* to check the authenticity of the arbitrary [message] using a public key, [SigningKey.Public]
* instance, using public-key signing algorithms.
*
* Unlike a regular binary signature, Seal contains the signer's [publicKey], and also
* [createdAt] and [expiresAt] fields which are also signed and are guaranteed to be non-tampered
* if the [isValid] returns true (or [verify] does not throw). See [isExpired].
*
* It is important to understand that the seal itself could not be checked _having no message
* it was created for_.
* This is made intentionally: if you have no message, what means you are not the intended recipient, you can't
* analyze the seal.
*
* To check that the message is genuine using a seal use:
*
* - [verify] which throws [IllegalSignatureException] or [ExpiredSignatureException] if it is not
* - [check] that returns success or the exception without throwing it
* - [isValid] that returns true or false, so you can't judge what was the reason (invalid signature or
* expiration, do not check for expiration _after_ false is returned as you can't trust
* into your seal yet).
*
* When you need to have a message and one or more seals all together, use [SealedBox].
*
* Please note for in the very rare key you want to trust the Seal having no message you need to create
* another Seal to seal the seal. It sounds crazy as it is, and you should avoid such designs.
*
*
* @param key secret key to sign with
* @param message message to seal
* @param createdAt seal creation time, usually current time
* @param expiresAt optional seal expiration time
* @param nonDeterministic if true, it is not possible to check whether two seals correspond to the same
* unknown message (if the message is known, it is trivial by verifying the seal). It is a
* rare case so default os false.
*/
fun create(
key: SigningKey.Secret,
message: UByteArray,
createdAt: Instant = now(),
expiresAt: Instant? = null,
nonDeterministic: Boolean = false
): Seal {
val data = BipackEncoder.encode(SealedData(message, createdAt, expiresAt)).toUByteArray()
return Seal(key.publicKey, key.sign(data), createdAt, expiresAt)
val nonce = if( nonDeterministic ) Random.nextUBytes(32) else null
val data = BipackEncoder.encode(SealedData(message, nonce, createdAt, expiresAt)).toUByteArray()
return Seal(key.publicKey, key.sign(data), nonce, createdAt, expiresAt)
}
/**
* Int the rare case you need a packed seal alone, unpack it. Normally just add seal to some [Serializable]
* class, it is serializable.
*/
fun unpack(packed: UByteArray): Seal = packed.toByteArray().decodeFromBipack()
}
}

View File

@ -1,17 +1,19 @@
package net.sergeych.crypto2
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
/**
* Multi-signed data box. Use [SignedBox.invoke] to easily create
* instances and [SignedBox.plus] to add more signatures (signing keys), and
* [SignedBox.contains] to check for a specific key signature presence.
* Multi-signed data box. Do not use the constructori directly, use [SealedBox.create]
* instead to create the box and [SealedBox.plus] or [addSeal] to add more signatures
* with different signing keys.
* [SealedBox.contains] checks for a specific key signature presence.
*
* Signatures, [Seal], incorporate creation time and optional expiration which are
* also signed and checked upon deserialization.
*
* It is serializable and checks integrity on deserialization. If any of seals does not
* It is serializable and checks integrity __on deserialization__k. If any of seals does not
* match the signed [message], it throws [IllegalSignatureException] _on deserialization_.
* E.g., if you have it deserialized, it is ok, check it contains all needed keys among
* signers.
@ -20,7 +22,7 @@ import kotlinx.serialization.Transient
* know what you are doing as it may be dangerous.Use one of the above to create or change it.
*/
@Serializable
class SignedBox(
class SealedBox(
val message: UByteArray,
private val seals: List<Seal>,
@Transient
@ -32,9 +34,18 @@ class SignedBox(
* key, or return unchanged (same) object if it is already signed by this key; you
* _can't assume it always returns a copied object!_
*/
operator fun plus(key: SigningKey.Secret): SignedBox =
operator fun plus(key: SigningKey.Secret): SealedBox =
if (key.publicKey in this) this
else SignedBox(message, seals + key.seal(message),false)
else SealedBox(message, seals + key.seal(message),false)
/**
* Add expiring seal, otherwise use [plus]. Overrides exising seal for [key]
* if present:
*/
fun addSeal(key: SigningKey.Secret,expresAt: Instant): SealedBox {
val filtered = seals.filter { it.publicKey != key.publicKey }
return SealedBox(message, filtered + key.seal(message, expresAt), false)
}
/**
* Check that it is signed with a specified key.
@ -55,15 +66,15 @@ class SignedBox(
/**
* Create a new instance with a specific data sealed by one or more
* keys. At least one key is required to disallow providing not-signed
* instances, e.g. [SignedBox] is guaranteed to be properly sealed when
* instances, e.g. [SealedBox] is guaranteed to be properly sealed when
* successfully instantiated.
*
* @param data a message to sign
* @param keys a list of keys to sign with, should be at least one key.
* @throws IllegalArgumentException if keys are not specified.
*/
operator fun invoke(data: UByteArray, vararg keys: SigningKey.Secret): SignedBox {
return SignedBox(data, keys.map { it.seal(data) }, false)
fun create(data: UByteArray, vararg keys: SigningKey.Secret): SealedBox {
return SealedBox(data, keys.map { it.seal(data) }, false)
}
}
}

View File

@ -62,8 +62,8 @@ sealed class SigningKey {
fun sign(message: UByteArray): UByteArray = Signature.detached(message, packed)
fun seal(message: UByteArray, validUntil: Instant? = null): Seal =
Seal(this, message, now(), validUntil)
fun seal(message: UByteArray, expiresAt: Instant? = null): Seal =
Seal.create(this, message, now(), expiresAt)
override fun toString(): String = "Sct:${super.toString()}"

View File

@ -0,0 +1,79 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.secretbox.SecretBox
import kotlinx.serialization.Serializable
import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
import net.sergeych.crypto2.SymmetricKey.Companion.random
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.random.nextUBytes
/**
* Symmetric key implements authenticated encrypting with random nonce and optional fill.
* Random fill is normally used when cryptanalysis of the message size is a threat.
*
* Do not call this constructor directly, use [random] or deserialize it.
*
* __Algorithms:__
*
* - Encryption: XSalsa20 stream cipher.
* - Authentication: Poly1305 MAC
*/
@Serializable
class SymmetricKey(
val keyBytes: UByteArray
): EncryptingKey, DecryptingKey {
/**
* @suppress
* nonce + ciphered data serialization aid
*/
@Serializable
data class WithNonce(
val cipherData: UByteArray,
val nonce: UByteArray,
)
/**
* @suppress
* add some random data to the end of the plain message
*/
@Serializable
data class WithFill(
val data: UByteArray,
val safetyFill: UByteArray? = null
)
override fun encrypt(plainData: UByteArray,randomFill: IntRange?): UByteArray {
val fill = randomFill?.let {
require(it.start >= 0)
Random.nextUBytes(Random.nextInt(it))
}
val filled = BipackEncoder.encode(WithFill(plainData, fill))
val nonce = randomNonce()
val encrypted = SecretBox.easy(filled.toUByteArray(), nonce, keyBytes)
return BipackEncoder.encode(WithNonce(encrypted, nonce)).toUByteArray()
}
override fun decrypt(cipherData: UByteArray): UByteArray {
val wn: WithNonce = BipackDecoder.Companion.decode(cipherData.toDataSource())
try {
return BipackDecoder.Companion.decode<WithFill>(
SecretBox.openEasy(wn.cipherData, wn.nonce, keyBytes).toDataSource()
).data
}
catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
throw DecryptionFailedException()
}
}
companion object {
/**
* Create a secure random symmetric key.
*/
fun random() = SymmetricKey(SecretBox.keygen())
}
}

View File

@ -2,30 +2,12 @@
package net.sergeych.crypto2
import com.ionspin.kotlin.crypto.secretbox.SecretBox
import com.ionspin.kotlin.crypto.secretbox.crypto_secretbox_NONCEBYTES
import com.ionspin.kotlin.crypto.util.LibsodiumRandom
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.serialization.Serializable
import net.sergeych.bintools.toDataSource
import net.sergeych.bipack.BipackDecoder
import net.sergeych.bipack.BipackEncoder
class DecryptionFailedException : RuntimeException("can't encrypt: wrong key or tampered message")
@Serializable
data class WithNonce(
val cipherData: UByteArray,
val nonce: UByteArray,
)
@Serializable
data class WithFill(
val data: UByteArray,
val safetyFill: UByteArray? = null
) {
constructor(data: UByteArray, fillSize: Int) : this(data, randomBytes(fillSize))
}
suspend fun readVarUnsigned(input: ReceiveChannel<UByte>): UInt {
var result = 0u
@ -73,39 +55,4 @@ fun <T: Comparable<T>>T.limitMin(min: T) = if( this > min ) this else min
fun randomNonce(): UByteArray = randomBytes(crypto_secretbox_NONCEBYTES)
/**
* Secret-key encrypt with authentication.
* Generates random nonce and add some random fill to protect
* against some analysis attacks. Nonce is included in the result. To be
* used with [decrypt].
* @param secretKey a _secret_ key, see [SecretBox.keygen()] or like.
* @param plain data to encrypt
* @param fillSize number of random fill data to add. Use random value or default.
*/
fun encrypt(
secretKey: UByteArray,
plain: UByteArray,
fillSize: Int = randomUInt((plain.size * 3 / 10).limitMin(3)).toInt()
): UByteArray {
val filled = BipackEncoder.encode(WithFill(plain, fillSize))
val nonce = randomNonce()
val encrypted = SecretBox.easy(filled.toUByteArray(), nonce, secretKey)
return BipackEncoder.encode(WithNonce(encrypted, nonce)).toUByteArray()
}
/**
* Decrypt a secret-key-based message, normally encrypted with [encrypt].
* @throws DecryptionFailedException if the key is wrong or a message is tampered with (MAC
* check failed).
*/
fun decrypt(secretKey: UByteArray, cipher: UByteArray): UByteArray {
val wn: WithNonce = BipackDecoder.decode(cipher.toDataSource())
try {
return BipackDecoder.decode<WithFill>(
SecretBox.openEasy(wn.cipherData, wn.nonce, secretKey).toDataSource()
).data
}
catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) {
throw DecryptionFailedException()
}
}

View File

@ -1,15 +1,15 @@
import com.ionspin.kotlin.crypto.secretbox.SecretBox
import com.ionspin.kotlin.crypto.util.decodeFromUByteArray
import com.ionspin.kotlin.crypto.util.encodeToUByteArray
import kotlinx.coroutines.test.runTest
import net.sergeych.crypto2.*
import net.sergeych.utools.now
import net.sergeych.utools.pack
import net.sergeych.utools.unpack
import kotlin.test.*
class KeysTest {
@Test
fun testCreationAndMap() = runTest {
fun testSigningCreationAndMap() = runTest {
initCrypto()
val (stk,pbk) = SigningKey.pair()
@ -30,28 +30,56 @@ class KeysTest {
val p2 = SigningKey.pair()
val p3 = SigningKey.pair()
val ms = SignedBox(data, s1) + p2.secretKey
val ms = SealedBox.create(data, s1) + p2.secretKey
// non tampered:
val ms1 = unpack<SignedBox>(pack(ms))
val ms1 = unpack<SealedBox>(pack(ms))
assertContentEquals(data, ms1.message)
assertTrue(pbk in ms1)
assertTrue(p2.publicKey in ms1)
assertTrue(p3.publicKey !in ms1)
assertThrows<IllegalSignatureException> {
unpack<SignedBox>(pack(ms).also { it[3] = 1u })
unpack<SealedBox>(pack(ms).also { it[3] = 1u })
}
}
@Test
fun testNonDeterministicSeals() = runTest {
initCrypto()
val data = "Welcome to the Miami, bitch!".encodeToUByteArray()
val (sk,_) = SigningKey.pair()
val t = now()
val s1 = Seal.create(sk, data, createdAt = t)
val s2 = Seal.create(sk, data, createdAt = t)
val s2bad = Seal.create(sk, data + "!".encodeToUByteArray())
val s3 = Seal.create(sk, data, createdAt = t, nonDeterministic = true)
val s4 = Seal.create(sk, data, createdAt = t, nonDeterministic = true)
for( seal in listOf(s1,s2,s3,s4)) {
assertTrue { seal.isValid(data) }
assertTrue { Seal.unpack(seal.packed).isValid(data) }
}
assertFalse { s2bad.isValid(data)}
assertContentEquals(s1.packed, s2.packed)
assertFalse { s1.packed contentEquals s3.packed }
assertFalse { s4.packed contentEquals s3.packed }
}
@Test
fun secretEncryptTest() = runTest {
initCrypto()
val key = SecretBox.keygen()
val key1 = SecretBox.keygen()
assertEquals("hello", decrypt(key, encrypt(key, "hello".encodeToUByteArray())).decodeFromUByteArray())
val key = SymmetricKey.random()
val key1 = SymmetricKey.random()
assertEquals("hello", key.decrypt(key.encrypt("hello".encodeToUByteArray())).decodeFromUByteArray())
assertEquals("hello", key.decryptString(key.encrypt("hello")))
assertEquals("hello", key.decryptObject(key.encryptObject("hello")))
assertEquals("hello", key.decrypt(key.encrypt("hello".encodeToUByteArray(), 18..334)).decodeFromUByteArray())
assertEquals("hello", key.decryptString(key.encrypt("hello", 18..334)))
assertEquals("hello", key.decryptObject(key.encryptObject("hello", 18..334)))
assertThrows<DecryptionFailedException> {
decrypt(key, encrypt(key1, "hello".encodeToUByteArray())).decodeFromUByteArray()
key.decrypt(key1.encrypt("hello".encodeToUByteArray())).decodeFromUByteArray()
}
}