Added support for compact encryption/decryption with caller-provided nonce in SymmetricKey and accompanying tests. Bumped version to 0.9.1-SNAPSHOT.

This commit is contained in:
Sergey Chernov 2026-05-28 18:40:17 +03:00
parent 13dff8d760
commit 8015a4310b
3 changed files with 98 additions and 4 deletions

View File

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

View File

@ -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

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
@ -118,6 +117,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()