feat: replace libsodium with pure Kotlin ChaCha20/Poly1305 implementation

Remove LazySodium/JNA/libsodium native dependencies and replace with
pure Kotlin implementations of ChaCha20, HChaCha20, XChaCha20,
Poly1305 MAC, and XChaCha20-Poly1305 AEAD. This eliminates platform-
specific native binaries while maintaining full NIP-44 compatibility.

- Add ChaCha20Core with RFC 8439 block function, IETF stream XOR,
  HChaCha20, and XChaCha20
- Add Poly1305 MAC (RFC 8439 §2.5)
- Add XChaCha20Poly1305 AEAD encrypt/decrypt
- Convert LibSodiumInstance from expect/actual to commonMain object
- Delete platform-specific LibSodiumInstance actuals (android/jvm/ios)
- Remove iOS native interop (cinterop, linker opts, .a libraries)
- Remove lazysodium-java, lazysodium-android, JNA from build deps
- Clean up ProGuard rules (remove JNA/LazySodium keep rules)
- Add RFC 8439 + libsodium test vectors for ChaCha20 and XChaCha20
- Update benchmark to use simplified ChaCha20 API

https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i
This commit is contained in:
Claude
2026-03-23 17:40:29 +00:00
parent bbb75afed9
commit d586a0dc25
15 changed files with 1031 additions and 558 deletions
@@ -0,0 +1,253 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Test vectors from RFC 8439, XChaCha20 draft (draft-irtf-cfrg-xchacha-03),
* and libsodium test suite.
*/
class ChaCha20CoreTest {
private fun hex(s: String): ByteArray =
s.replace(" ", "").chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
// ===== RFC 8439 §2.3.2: ChaCha20 Block Function Test Vector =====
@Test
fun testChaCha20Block_RFC8439() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000090000004a00000000")
val counter = 1
val block = ChaCha20Core.chaCha20Block(key, counter, nonce)
val expected =
hex(
"10f1e7e4d13b5915500fdd1fa32071c4" +
"c7d1f4c733c068030422aa9ac3d46c4e" +
"d2826446079faa0914c2d705d98b02a2" +
"b5129cd1de164eb9cbd083e8a2503c4e",
)
assertContentEquals(expected, block)
}
// ===== RFC 8439 §2.4.2: ChaCha20 Encryption Test Vector =====
@Test
fun testChaCha20Encrypt_RFC8439() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000000000004a00000000")
val counter = 1
val plaintext =
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
.encodeToByteArray()
val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter)
val expected =
hex(
"6e2e359a2568f98041ba0728dd0d6981" +
"e97e7aec1d4360c20a27afccfd9fae0b" +
"f91b65c5524733ab8f593dabcd62b357" +
"1639d624e65152ab8f530c359f0861d8" +
"07ca0dbf500d6a6156a38e088a22b65e" +
"52bc514d16ccf806818ce91ab7793736" +
"5af90bbf74a35be6b40b8eedf2785e42" +
"874d",
)
assertContentEquals(expected, ciphertext)
}
// ===== RFC 8439 §2.5.2: Poly1305 Test Vector =====
@Test
fun testPoly1305_RFC8439() {
val key = hex("85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b")
val message =
"Cryptographic Forum Research Group".encodeToByteArray()
val tag = Poly1305.mac(message, key)
val expected = hex("a8061dc1305136c6c22b8baf0c0127a9")
assertContentEquals(expected, tag)
}
// ===== RFC 8439 §2.8.2: AEAD ChaCha20-Poly1305 Test Vector =====
// Adapted for XChaCha20 via the XChaCha20 draft test vector
@Test
fun testPoly1305KeyGeneration_RFC8439() {
// RFC 8439 §2.6.2 Poly1305 Key Generation
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("000000000001020304050607")
val block = ChaCha20Core.chaCha20Block(key, 0, nonce)
val polyKey = block.copyOfRange(0, 32)
val expected = hex("8ad5a08b905f81cc815040274ab29471a833b637e3fd0da508dbb8e2fdd1a646")
assertContentEquals(expected, polyKey)
}
// ===== XChaCha20 draft §2.2.1: HChaCha20 Test Vector =====
@Test
fun testHChaCha20_Draft() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val input = hex("000000090000004a0000000031415927")
val subKey = ChaCha20Core.hChaCha20(key, input)
val expected = hex("82413b4227b27bfed30e42508a877d73a0f9e4d58a74a853c12ec41326d3ecdc")
assertContentEquals(expected, subKey)
}
// ===== XChaCha20 draft §A.3.1: XChaCha20-Poly1305 AEAD Test Vector =====
@Test
fun testXChaCha20Poly1305Encrypt_Draft() {
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("404142434445464748494a4b4c4d4e4f5051525354555657")
val ad = hex("50515253c0c1c2c3c4c5c6c7")
val plaintext =
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
.encodeToByteArray()
val result = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
// Ciphertext portion
val expectedCiphertext =
hex(
"bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb" +
"731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b452" +
"2f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff9" +
"21f9664c97637da9768812f615c68b13b52e",
)
// Tag portion
val expectedTag = hex("c0875924c1c7987947deafd8780acf49")
val ciphertext = result.copyOfRange(0, result.size - 16)
val tag = result.copyOfRange(result.size - 16, result.size)
assertContentEquals(expectedCiphertext, ciphertext)
assertContentEquals(expectedTag, tag)
}
@Test
fun testXChaCha20Poly1305Decrypt_Draft() {
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("404142434445464748494a4b4c4d4e4f5051525354555657")
val ad = hex("50515253c0c1c2c3c4c5c6c7")
val ciphertextWithTag =
hex(
"bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb" +
"731c7f1b0b4aa6440bf3a82f4eda7e39ae64c6708c54c216cb96b72e1213b452" +
"2f8c9ba40db5d945b11b69b982c1bb9e3f3fac2bc369488f76b2383565d3fff9" +
"21f9664c97637da9768812f615c68b13b52e" +
"c0875924c1c7987947deafd8780acf49",
)
val plaintext = XChaCha20Poly1305.decrypt(ciphertextWithTag, ad, nonce, key)
val expected =
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
assertEquals(expected, plaintext.decodeToString())
}
@Test
fun testXChaCha20Poly1305_TamperedCiphertext() {
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
val nonce = hex("404142434445464748494a4b4c4d4e4f5051525354555657")
val ad = hex("50515253c0c1c2c3c4c5c6c7")
val plaintext = "Test message".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
// Flip a bit in the ciphertext
encrypted[0] = (encrypted[0].toInt() xor 1).toByte()
assertFailsWith<IllegalStateException> {
XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
}
}
@Test
fun testXChaCha20Poly1305_RoundTrip() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = hex("feedfacedeadbeef")
val plaintext = "Hello, Nostr! This is a round-trip test.".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
@Test
fun testXChaCha20Poly1305_EmptyPlaintext() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = hex("feedface")
val plaintext = ByteArray(0)
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
assertEquals(16, encrypted.size) // Just the tag
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
@Test
fun testXChaCha20Poly1305_EmptyAD() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = ByteArray(0)
val plaintext = "No additional data".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
// ===== ChaCha20 stream cipher (counter=0) for NIP-44v2 compatibility =====
@Test
fun testChaCha20Xor_SymmetricEncryptDecrypt() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000090000004a00000000")
val plaintext = "Symmetric cipher test".encodeToByteArray()
val encrypted = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter = 0)
val decrypted = ChaCha20Core.chaCha20Xor(encrypted, key, nonce, counter = 0)
assertContentEquals(plaintext, decrypted)
}
// ===== XChaCha20 stream cipher symmetry =====
@Test
fun testXChaCha20Xor_SymmetricEncryptDecrypt() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val plaintext = "XChaCha20 symmetric test".encodeToByteArray()
val encrypted = ChaCha20Core.xChaCha20Xor(plaintext, nonce, key)
val decrypted = ChaCha20Core.xChaCha20Xor(encrypted, nonce, key)
assertContentEquals(plaintext, decrypted)
}
}
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip44Encryption.crypto
import kotlin.test.Test
import kotlin.test.assertContentEquals
/** Test vectors from libsodium xchacha20.c */
class XChaCha20Test {
private fun hex(s: String): ByteArray =
s.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
// HChaCha20 test vectors from libsodium
data class HChaCha20TV(val key: String, val input: String, val expected: String)
private val hChaCha20Vectors = listOf(
HChaCha20TV(
"24f11cce8a1b3d61e441561a696c1c1b7e173d084fd4812425435a8896a013dc",
"d9660c5900ae19ddad28d6e06e45fe5e",
"5966b3eec3bff1189f831f06afe4d4e3be97fa9235ec8c20d08acfbbb4e851e3",
),
HChaCha20TV(
"80a5f6272031e18bb9bcd84f3385da65e7731b7039f13f5e3d475364cd4d42f7",
"c0eccc384b44c88e92c57eb2d5ca4dfa",
"6ed11741f724009a640a44fce7320954c46e18e0d7ae063bdbc8d7cf372709df",
),
HChaCha20TV(
"cb1fc686c0eec11a89438b6f4013bf110e7171dace3297f3a657a309b3199629",
"fcd49b93e5f8f299227e64d40dc864a3",
"84b7e96937a1a0a406bb7162eeaad34308d49de60fd2f7ec9dc6a79cbab2ca34",
),
HChaCha20TV(
"6640f4d80af5496ca1bc2cfff1fefbe99638dbceaabd7d0ade118999d45f053d",
"31f59ceeeafdbfe8cae7914caeba90d6",
"9af4697d2f5574a44834a2c2ae1a0505af9f5d869dbe381a994a18eb374c36a0",
),
HChaCha20TV(
"0693ff36d971225a44ac92c092c60b399e672e4cc5aafd5e31426f123787ac27",
"3a6293da061da405db45be1731d5fc4d",
"f87b38609142c01095bfc425573bb3c698f9ae866b7e4216840b9c4caf3b0865",
),
)
@Test
fun testHChaCha20_LibsodiumVectors() {
for ((i, tv) in hChaCha20Vectors.withIndex()) {
val result = ChaCha20Core.hChaCha20(hex(tv.key), hex(tv.input))
assertContentEquals(hex(tv.expected), result, "HChaCha20 vector $i failed")
}
}
// XChaCha20 stream test vectors from libsodium
data class XChaCha20TV(val key: String, val nonce: String, val expected: String)
private val xChaCha20Vectors = listOf(
XChaCha20TV(
"79c99798ac67300bbb2704c95c341e3245f3dcb21761b98e52ff45b24f304fc4",
"b33ffd3096479bcfbc9aee49417688a0a2554f8d95389419",
"c6e9758160083ac604ef90e712ce6e75d7797590744e0cf060f013739c",
),
XChaCha20TV(
"ddf7784fee099612c40700862189d0397fcc4cc4b3cc02b5456b3a97d1186173",
"a9a04491e7bf00c3ca91ac7c2d38a777d88993a7047dfcc4",
"2f289d371f6f0abc3cb60d11d9b7b29adf6bc5ad843e8493e928448d",
),
XChaCha20TV(
"3d12800e7b014e88d68a73f0a95b04b435719936feba60473f02a9e61ae60682",
"56bed2599eac99fb27ebf4ffcb770a64772dec4d5849ea2d",
"a2c3c1406f33c054a92760a8e0666b84f84fa3a618f0",
),
XChaCha20TV(
"eadc0e27f77113b5241f8ca9d6f9a5e7f09eee68d8a5cf30700563bf01060b4e",
"a171a4ef3fde7c4794c5b86170dc5a099b478f1b852f7b64",
"23839f61795c3cdbcee2c749a92543baeeea3cbb721402aa42e6cae140447575f2916c5d71108e3b13357eaf86f060cb",
),
XChaCha20TV(
"91319c9545c7c804ba6b712e22294c386fe31c4ff3d278827637b959d3dbaab2",
"410e854b2a911f174aaf1a56540fc3855851f41c65967a4e",
"cbe7d24177119b7fdfa8b06ee04dade4256ba7d35ffda6b89f014e479faef6",
),
)
@Test
fun testXChaCha20Xor_LibsodiumVectors() {
for ((i, tv) in xChaCha20Vectors.withIndex()) {
// XOR zeros with keystream to get the expected keystream output
val zeros = ByteArray(hex(tv.expected).size)
val result = ChaCha20Core.xChaCha20Xor(zeros, hex(tv.nonce), hex(tv.key))
assertContentEquals(hex(tv.expected), result, "XChaCha20 vector $i failed")
}
}
}