From aad44c5af56d80a320d0f411d8040f3db35520bd Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 11 Jun 2024 12:33:37 +0700 Subject: [PATCH] redesigned signed box now Sealed box, refactored seals to incorporate expiration, introduced symmetric keys support --- build.gradle.kts | 9 +- .../net/sergeych/crypto2/DecryptingKey.kt | 24 ++++ .../net/sergeych/crypto2/EncryptingKey.kt | 25 +++++ .../kotlin/net/sergeych/crypto2/Seal.kt | 104 ++++++++++++++++-- .../crypto2/{SignedBox.kt => SealedBox.kt} | 31 ++++-- .../kotlin/net/sergeych/crypto2/SigningKey.kt | 4 +- .../net/sergeych/crypto2/SymmetricKey.kt | 79 +++++++++++++ .../kotlin/net/sergeych/crypto2/tools.kt | 53 --------- src/commonTest/kotlin/KeysTest.kt | 46 ++++++-- 9 files changed, 290 insertions(+), 85 deletions(-) create mode 100644 src/commonMain/kotlin/net/sergeych/crypto2/DecryptingKey.kt create mode 100644 src/commonMain/kotlin/net/sergeych/crypto2/EncryptingKey.kt rename src/commonMain/kotlin/net/sergeych/crypto2/{SignedBox.kt => SealedBox.kt} (64%) create mode 100644 src/commonMain/kotlin/net/sergeych/crypto2/SymmetricKey.kt diff --git a/build.gradle.kts b/build.gradle.kts index 48fe0d2..269220e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/DecryptingKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/DecryptingKey.kt new file mode 100644 index 0000000..808caaa --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto2/DecryptingKey.kt @@ -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 DecryptingKey.decryptObject(cipherData: UByteArray): T = + BipackDecoder.decode(decrypt(cipherData).toByteArray()) \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/EncryptingKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/EncryptingKey.kt new file mode 100644 index 0000000..c61e90e --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto2/EncryptingKey.kt @@ -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 EncryptingKey.encryptObject(value: T,randomFill: IntRange? = null): UByteArray = + encrypt(BipackEncoder.encode(value).toUByteArray(),randomFill) diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/Seal.kt b/src/commonMain/kotlin/net/sergeych/crypto2/Seal.kt index 88acc7b..76413d2 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/Seal.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/Seal.kt @@ -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() } } \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/SignedBox.kt b/src/commonMain/kotlin/net/sergeych/crypto2/SealedBox.kt similarity index 64% rename from src/commonMain/kotlin/net/sergeych/crypto2/SignedBox.kt rename to src/commonMain/kotlin/net/sergeych/crypto2/SealedBox.kt index d7fdd86..92bac31 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/SignedBox.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/SealedBox.kt @@ -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, @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) } } } \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/SigningKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/SigningKey.kt index ded9ff8..20fe8f9 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/SigningKey.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/SigningKey.kt @@ -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()}" diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/SymmetricKey.kt b/src/commonMain/kotlin/net/sergeych/crypto2/SymmetricKey.kt new file mode 100644 index 0000000..045f127 --- /dev/null +++ b/src/commonMain/kotlin/net/sergeych/crypto2/SymmetricKey.kt @@ -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( + 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()) + } + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/net/sergeych/crypto2/tools.kt b/src/commonMain/kotlin/net/sergeych/crypto2/tools.kt index cded97d..74950ac 100644 --- a/src/commonMain/kotlin/net/sergeych/crypto2/tools.kt +++ b/src/commonMain/kotlin/net/sergeych/crypto2/tools.kt @@ -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): UInt { var result = 0u @@ -73,39 +55,4 @@ fun >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( - SecretBox.openEasy(wn.cipherData, wn.nonce, secretKey).toDataSource() - ).data - } - catch(_: com.ionspin.kotlin.crypto.secretbox.SecretBoxCorruptedOrTamperedDataExceptionOrInvalidKey) { - throw DecryptionFailedException() - } -} diff --git a/src/commonTest/kotlin/KeysTest.kt b/src/commonTest/kotlin/KeysTest.kt index a129d69..ec6e689 100644 --- a/src/commonTest/kotlin/KeysTest.kt +++ b/src/commonTest/kotlin/KeysTest.kt @@ -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(pack(ms)) + val ms1 = unpack(pack(ms)) assertContentEquals(data, ms1.message) assertTrue(pbk in ms1) assertTrue(p2.publicKey in ms1) assertTrue(p3.publicKey !in ms1) assertThrows { - unpack(pack(ms).also { it[3] = 1u }) + unpack(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 { - decrypt(key, encrypt(key1, "hello".encodeToUByteArray())).decodeFromUByteArray() + key.decrypt(key1.encrypt("hello".encodeToUByteArray())).decodeFromUByteArray() } }