diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt index 296a8927f..2f39dcc55 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -52,36 +52,16 @@ class ChaCha20Benchmark { } @Test - fun encryptLibSodium() { + fun encrypt() { benchmarkRule.measureRepeated { - chaCha.encryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + chaCha.encrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } - /* - Removed in the conversion to KMP @Test - fun encryptNative() { + fun decrypt() { benchmarkRule.measureRepeated { - chaCha.encryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } - */ - - @Test - fun decryptLibSodium() { - benchmarkRule.measureRepeated { - chaCha.decryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) - } - } - - /* - Removed in the conversion to KMP - @Test - fun decryptNative() { - benchmarkRule.measureRepeated { - chaCha.decryptNative(padded, messageKeys.chachaNonce, messageKeys.chachaKey) - } - } - */ } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0323197d2..eeba3e5e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,6 @@ fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.21.1" javaKeyring = "1.0.4" -jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" kchesslib = "1.0.5" @@ -34,8 +33,6 @@ kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.10.0" languageId = "17.0.6" -lazysodiumAndroid = "5.2.0" -lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" @@ -144,7 +141,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } java-keyring = { group = "com.github.javakeyring", name = "java-keyring", version.ref = "javaKeyring" } -jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } @@ -153,8 +149,6 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } -lazysodium-java = { group = "com.goterl", name = "lazysodium-java", version.ref = "lazysodiumJava" } -lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" } markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" } markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" } markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index da7c363a9..1ed25d82e 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -63,65 +63,11 @@ kotlin { // project can be found here: // https://developer.android.com/kotlin/multiplatform/migrate val xcfName = "quartz-kmpKit" - val libsodiumPath = project.file("src/nativeInterop/libsodium") - val libsodiumHeaderFilesPath = project.file("$libsodiumPath/include/sodium") - - // Generate target-specific Libsodium definition files for creating native bindings. - // Device (iosArm64) uses libsodium.a, simulator targets use libsodium-simulator.a. - val libsodiumDeviceDefFile = - project.layout.buildDirectory - .file("cinterop/Clibsodium-device.def") - .get() - .asFile - val libsodiumSimulatorDefFile = - project.layout.buildDirectory - .file("cinterop/Clibsodium-simulator.def") - .get() - .asFile - - // This generates the Libsodium definition file, necessary for creating native bindings(a Kotlin API) for libsodium(for iOS). - val libsodiumDefFileGeneration = - tasks.register("GenerateSodiumCinteropFile") { - outputs.files(libsodiumDeviceDefFile, libsodiumSimulatorDefFile) - doLast { - libsodiumDeviceDefFile.parentFile.mkdirs() - libsodiumDeviceDefFile.writeText( - "package = Clibsodium\n" + - "staticLibraries = libsodium.a\n" + - "libraryPaths = ${libsodiumPath.absolutePath}/ios/lib\n", - ) - libsodiumSimulatorDefFile.writeText( - "package = Clibsodium\n" + - "staticLibraries = libsodium-simulator.a\n" + - "libraryPaths = ${libsodiumPath.absolutePath}/ios-simulators/lib\n", - ) - } - } listOf( iosArm64(), iosSimulatorArm64(), ).forEach { target -> - val isSimulator = target.name != "iosArm64" - val defFile = if (isSimulator) libsodiumSimulatorDefFile else libsodiumDeviceDefFile - - target.compilations.getByName("main") { - val clibsodium by cinterops.creating { - definitionFile = defFile - packageName = "Clibsodium" - - headers( - "$libsodiumHeaderFilesPath/crypto_aead_xchacha20poly1305.h", - "$libsodiumHeaderFilesPath/crypto_core_hchacha20.h", - "$libsodiumHeaderFilesPath/crypto_stream_chacha20.h", - ) - } - - tasks.named(cinterops.getByName("clibsodium").interopProcessingTaskName).configure { - dependsOn(libsodiumDefFileGeneration) - } - } - target.swiftPackageConfig(cinteropName = "swiftbridge") { minIos = "17" minMacos = "14" @@ -139,9 +85,6 @@ kotlin { } iosArm64 { - binaries.all { - linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium") - } binaries.framework { baseName = xcfName binaryOption("bundleId", "com.vitorpamplona.quartz") @@ -149,9 +92,6 @@ kotlin { } iosSimulatorArm64 { - binaries.all { - linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator") - } binaries.framework { baseName = xcfName binaryOption("bundleId", "com.vitorpamplona.quartz") @@ -254,9 +194,6 @@ kotlin { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - // LibSodium for ChaCha encryption (NIP-44) - implementation(libs.lazysodium.java) - implementation(libs.jna) } } @@ -279,9 +216,6 @@ kotlin { // Bitcoin secp256k1 bindings to Android api(libs.secp256k1.kmp.jni.android) - // LibSodium for ChaCha encryption (NIP-44) - implementation("com.goterl:lazysodium-android:5.2.0@aar") - implementation("net.java.dev.jna:jna:5.18.1@aar") } } @@ -293,9 +227,6 @@ kotlin { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - // LibSodium for ChaCha encryption (NIP-44) - Needed for host tests - implementation(libs.lazysodium.java) - implementation(libs.jna) // SQLite bundled driver for Host tests implementation(libs.androidx.sqlite.bundled.jvm) @@ -315,9 +246,6 @@ kotlin { // Bitcoin secp256k1 bindings to Android api(libs.secp256k1.kmp.jni.android) - // LibSodium for ChaCha encryption (NIP-44) - implementation("com.goterl:lazysodium-android:5.2.0@aar") - implementation("net.java.dev.jna:jna:5.18.1@aar") } } diff --git a/quartz/consumer-rules.pro b/quartz/consumer-rules.pro index 4985bd413..04373ed53 100644 --- a/quartz/consumer-rules.pro +++ b/quartz/consumer-rules.pro @@ -16,32 +16,10 @@ # preserve access to native classses -keep class fr.acinq.secp256k1.** { *; } -# JNA For Libsodium --keep class com.goterl.lazysodium.** { *; } - # libscrypt -keep class com.lambdaworks.codec.** { *; } -keep class com.lambdaworks.crypto.** { *; } -keep class com.lambdaworks.jni.** { *; } -# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out --keep class com.sun.jna.ToNativeConverter { *; } --keep class com.sun.jna.NativeMapped { *; } --keep class com.sun.jna.CallbackReference { *; } --keep class com.sun.jna.ptr.IntByReference { *; } --keep class com.sun.jna.NativeLong { *; } --keep class com.sun.jna.Structure { *; } --keep class com.sun.jna.Structure$* { *; } --keep class com.sun.jna.Native$ffi_callback { *; } --keep class * implements com.sun.jna.Structure$* { *; } --keep class * implements com.sun.jna.Native$* { *; } --keep class com.sun.jna.Native { - private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object); - private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object); - private static java.lang.Class nativeType(java.lang.Class); - private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object); - private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method); -} - # JSON parsing -keep class com.vitorpamplona.quartz.** { *; } \ No newline at end of file diff --git a/quartz/proguard-rules.pro b/quartz/proguard-rules.pro index 4d9f471e2..e1f545f4d 100644 --- a/quartz/proguard-rules.pro +++ b/quartz/proguard-rules.pro @@ -30,33 +30,11 @@ # preserve access to native classses -keep class fr.acinq.secp256k1.** { *; } -# JNA For Libsodium --keep class com.goterl.lazysodium.** { *; } - # libscrypt -keep class com.lambdaworks.codec.** { *; } -keep class com.lambdaworks.crypto.** { *; } -keep class com.lambdaworks.jni.** { *; } -# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out --keep class com.sun.jna.ToNativeConverter { *; } --keep class com.sun.jna.NativeMapped { *; } --keep class com.sun.jna.CallbackReference { *; } --keep class com.sun.jna.ptr.IntByReference { *; } --keep class com.sun.jna.NativeLong { *; } --keep class com.sun.jna.Structure { *; } --keep class com.sun.jna.Structure$* { *; } --keep class com.sun.jna.Native$ffi_callback { *; } --keep class * implements com.sun.jna.Structure$* { *; } --keep class * implements com.sun.jna.Native$* { *; } --keep class com.sun.jna.Native { - private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object); - private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object); - private static java.lang.Class nativeType(java.lang.Class); - private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object); - private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method); -} - # JSON parsing -keep class com.vitorpamplona.quartz.** { *; } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt deleted file mode 100644 index 7bfa83a2f..000000000 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * 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.utils - -import com.goterl.lazysodium.LazySodium -import com.goterl.lazysodium.LazySodiumAndroid -import com.goterl.lazysodium.Sodium -import com.goterl.lazysodium.SodiumAndroid - -actual object LibSodiumInstance { - private val libSodium: Sodium = - try { - // If we are running in a host test, SodiumJava might be available. - // SodiumJava uses a ResourceLoader to find the dylib/so/dll in the jar. - Class - .forName("com.goterl.lazysodium.SodiumJava") - .getConstructor() - .newInstance() as Sodium - } catch (_: Exception) { - SodiumAndroid() - } - - private val lazySodium: LazySodium = - if (libSodium is SodiumAndroid) { - LazySodiumAndroid(libSodium) - } else { - // this should only happen on test cases - val sodiumJava = - Class - .forName("com.goterl.lazysodium.SodiumJava") - - Class - .forName("com.goterl.lazysodium.LazySodiumJava") - .getConstructor(sodiumJava) - .newInstance(libSodium) as LazySodium - } - - actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt( - message: ByteArray, - nSec: ByteArray, - ciphertext: ByteArray, - ad: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean = - lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( - message, - longArrayOf(message.size.toLong()), - nSec, - ciphertext, - ciphertext.size.toLong(), - ad, - ad.size.toLong(), - nPub, - k, - ) - - actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext: ByteArray, - message: ByteArray, - ad: ByteArray, - nSec: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean = - lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext, - longArrayOf(ciphertext.size.toLong()), - message, - message.size.toLong(), - ad, - ad.size.toLong(), - nSec, - nPub, - k, - ) - - actual fun cryptoStreamChaCha20IetfXor( - message: ByteArray, - nonce: ByteArray?, - key: ByteArray?, - ): ByteArray { - val ciphertext = ByteArray(message.size) - lazySodium.cryptoStreamChaCha20IetfXor(ciphertext, message, message.size.toLong(), nonce, key) - return ciphertext - } - - // This function wasn't available in the bindings library. I had to move them here from C - actual fun cryptoStreamXChaCha20Xor( - messageBytes: ByteArray, - nonce: ByteArray, - key: ByteArray, - ): ByteArray { - val cipher = ByteArray(messageBytes.size) - val k2 = ByteArray(32) - - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) - - libSodium.crypto_core_hchacha20(k2, nonce, key, null) - val resultCode = - libSodium.crypto_stream_chacha20_xor_ic( - cipher, - messageBytes, - messageBytes.size.toLong(), - nonceChaCha, - 0, - k2, - ) - - return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message") - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt index 3fe570a86..1cbf72a8a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20.kt @@ -20,55 +20,16 @@ */ package com.vitorpamplona.quartz.nip44Encryption.crypto -import com.vitorpamplona.quartz.utils.LibSodiumInstance - -/** - * Encapsulates the ChaCha20 options. LibSodium is faster on real hardware: 851ns vs 2,535ns encrypt/decrypt times - */ class ChaCha20 { fun encrypt( message: ByteArray, nonce: ByteArray, key: ByteArray, - ) = encryptLibSodium(message, nonce, key) + ) = ChaCha20Core.chaCha20Xor(message, key, nonce, counter = 0) fun decrypt( message: ByteArray, nonce: ByteArray, key: ByteArray, - ) = decryptLibSodium(message, nonce, key) - - /* - fun encryptNative( - message: ByteArray, - nonce: ByteArray, - key: ByteArray, - ): ByteArray { - val cipher = Cipher.getInstance("ChaCha20") - cipher.init(Cipher.ENCRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) - return cipher.doFinal(message) - } - - fun decryptNative( - message: ByteArray, - nonce: ByteArray, - key: ByteArray, - ): ByteArray { - val cipher = Cipher.getInstance("ChaCha20") - cipher.init(Cipher.DECRYPT_MODE, FixedKey(key, "ChaCha20"), IvParameterSpec(nonce)) - return cipher.doFinal(message) - } - */ - - fun encryptLibSodium( - message: ByteArray, - nonce: ByteArray, - key: ByteArray, - ) = LibSodiumInstance.cryptoStreamChaCha20IetfXor(message, nonce, key) - - fun decryptLibSodium( - message: ByteArray, - nonce: ByteArray, - key: ByteArray, - ) = LibSodiumInstance.cryptoStreamChaCha20IetfXor(message, nonce, key) + ) = ChaCha20Core.chaCha20Xor(message, key, nonce, counter = 0) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt new file mode 100644 index 000000000..2d9abce80 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt @@ -0,0 +1,266 @@ +/* + * 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 + +/** + * Pure Kotlin implementation of ChaCha20, HChaCha20, and XChaCha20. + * + * All functions are stateless and thread-safe — they use only local variables + * and produce no side effects beyond their return values. + * + * References: + * - RFC 8439: ChaCha20 and Poly1305 + * - draft-irtf-cfrg-xchacha: XChaCha20 (HChaCha20 + ChaCha20) + */ +object ChaCha20Core { + // "expand 32-byte k" as little-endian integers + private const val SIGMA0 = 0x61707865.toInt() + private const val SIGMA1 = 0x3320646e.toInt() + private const val SIGMA2 = 0x79622d32.toInt() + private const val SIGMA3 = 0x6b206574.toInt() + + /** + * ChaCha20 quarter round (RFC 8439 §2.1). + * Operates in-place on the 16-word state. + */ + private fun quarterRound( + state: IntArray, + a: Int, + b: Int, + c: Int, + d: Int, + ) { + state[a] += state[b]; state[d] = (state[d] xor state[a]).rotateLeft(16) + state[c] += state[d]; state[b] = (state[b] xor state[c]).rotateLeft(12) + state[a] += state[b]; state[d] = (state[d] xor state[a]).rotateLeft(8) + state[c] += state[d]; state[b] = (state[b] xor state[c]).rotateLeft(7) + } + + /** + * Perform 20 rounds (10 double-rounds) of ChaCha on the state. + */ + private fun chaCha20Rounds(state: IntArray) { + repeat(10) { + // Column rounds + quarterRound(state, 0, 4, 8, 12) + quarterRound(state, 1, 5, 9, 13) + quarterRound(state, 2, 6, 10, 14) + quarterRound(state, 3, 7, 11, 15) + // Diagonal rounds + quarterRound(state, 0, 5, 10, 15) + quarterRound(state, 1, 6, 11, 12) + quarterRound(state, 2, 7, 8, 13) + quarterRound(state, 3, 4, 9, 14) + } + } + + /** + * Initialize the ChaCha20 state matrix from key, counter, and nonce. + * Layout (RFC 8439 §2.3): + * cccccccc cccccccc cccccccc cccccccc + * kkkkkkkk kkkkkkkk kkkkkkkk kkkkkkkk + * kkkkkkkk kkkkkkkk kkkkkkkk kkkkkkkk + * bbbbbbbb nnnnnnnn nnnnnnnn nnnnnnnn + */ + private fun initState( + key: ByteArray, + counter: Int, + nonce: ByteArray, + ): IntArray { + val state = IntArray(16) + state[0] = SIGMA0 + state[1] = SIGMA1 + state[2] = SIGMA2 + state[3] = SIGMA3 + state[4] = key.littleEndianToInt(0) + state[5] = key.littleEndianToInt(4) + state[6] = key.littleEndianToInt(8) + state[7] = key.littleEndianToInt(12) + state[8] = key.littleEndianToInt(16) + state[9] = key.littleEndianToInt(20) + state[10] = key.littleEndianToInt(24) + state[11] = key.littleEndianToInt(28) + state[12] = counter + state[13] = nonce.littleEndianToInt(0) + state[14] = nonce.littleEndianToInt(4) + state[15] = nonce.littleEndianToInt(8) + return state + } + + /** + * ChaCha20 block function (RFC 8439 §2.3). + * Produces a 64-byte keystream block. + * + * @param key 32-byte key + * @param counter block counter + * @param nonce 12-byte nonce (IETF variant) + * @return 64-byte keystream block + */ + fun chaCha20Block( + key: ByteArray, + counter: Int, + nonce: ByteArray, + ): ByteArray { + val initial = initState(key, counter, nonce) + val working = initial.copyOf() + chaCha20Rounds(working) + + // Add initial state to working state + for (i in 0..15) { + working[i] += initial[i] + } + + // Serialize to little-endian bytes + val output = ByteArray(64) + for (i in 0..15) { + working[i].intToLittleEndian(output, i * 4) + } + return output + } + + /** + * ChaCha20 IETF stream cipher XOR (RFC 8439 §2.4). + * XORs the message with the ChaCha20 keystream. + * + * @param message plaintext or ciphertext + * @param key 32-byte key + * @param nonce 12-byte nonce + * @param counter initial block counter (0 for stream cipher, 1 for AEAD) + * @return XOR'd output (same length as message) + */ + fun chaCha20Xor( + message: ByteArray, + key: ByteArray, + nonce: ByteArray, + counter: Int = 0, + ): ByteArray { + val output = ByteArray(message.size) + val fullBlocks = message.size / 64 + val remainder = message.size % 64 + + for (i in 0 until fullBlocks) { + val block = chaCha20Block(key, counter + i, nonce) + val offset = i * 64 + for (j in 0..63) { + output[offset + j] = (message[offset + j].toInt() xor block[j].toInt()).toByte() + } + } + + if (remainder > 0) { + val block = chaCha20Block(key, counter + fullBlocks, nonce) + val offset = fullBlocks * 64 + for (j in 0 until remainder) { + output[offset + j] = (message[offset + j].toInt() xor block[j].toInt()).toByte() + } + } + + return output + } + + /** + * HChaCha20 (draft-irtf-cfrg-xchacha §2.2). + * Derives a 32-byte subkey from a 32-byte key and 16-byte input. + * Used as the first step of XChaCha20. + * + * @param key 32-byte key + * @param input 16-byte input (first 16 bytes of the 24-byte XChaCha20 nonce) + * @return 32-byte subkey + */ + fun hChaCha20( + key: ByteArray, + input: ByteArray, + ): ByteArray { + val state = IntArray(16) + state[0] = SIGMA0 + state[1] = SIGMA1 + state[2] = SIGMA2 + state[3] = SIGMA3 + state[4] = key.littleEndianToInt(0) + state[5] = key.littleEndianToInt(4) + state[6] = key.littleEndianToInt(8) + state[7] = key.littleEndianToInt(12) + state[8] = key.littleEndianToInt(16) + state[9] = key.littleEndianToInt(20) + state[10] = key.littleEndianToInt(24) + state[11] = key.littleEndianToInt(28) + state[12] = input.littleEndianToInt(0) + state[13] = input.littleEndianToInt(4) + state[14] = input.littleEndianToInt(8) + state[15] = input.littleEndianToInt(12) + + chaCha20Rounds(state) + + // Output words 0-3 and 12-15 (skip the key material in 4-11) + val output = ByteArray(32) + state[0].intToLittleEndian(output, 0) + state[1].intToLittleEndian(output, 4) + state[2].intToLittleEndian(output, 8) + state[3].intToLittleEndian(output, 12) + state[12].intToLittleEndian(output, 16) + state[13].intToLittleEndian(output, 20) + state[14].intToLittleEndian(output, 24) + state[15].intToLittleEndian(output, 28) + return output + } + + /** + * XChaCha20 stream cipher XOR (draft-irtf-cfrg-xchacha §2.3). + * Uses HChaCha20 to derive a subkey, then applies ChaCha20 with the remaining nonce bytes. + * + * @param message plaintext or ciphertext + * @param nonce 24-byte nonce + * @param key 32-byte key + * @return XOR'd output (same length as message) + */ + fun xChaCha20Xor( + message: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + // Step 1: Derive subkey using first 16 bytes of nonce + val subKey = hChaCha20(key, nonce.copyOfRange(0, 16)) + + // Step 2: Build 12-byte subnonce: 4 zero bytes + last 8 bytes of nonce + val subNonce = ByteArray(12) + nonce.copyInto(subNonce, destinationOffset = 4, startIndex = 16, endIndex = 24) + + // Step 3: Encrypt with ChaCha20 using subkey, counter=0 + return chaCha20Xor(message, subKey, subNonce, counter = 0) + } +} + +// --- Little-endian conversion helpers --- + +private fun ByteArray.littleEndianToInt(offset: Int): Int = + (this[offset].toInt() and 0xFF) or + ((this[offset + 1].toInt() and 0xFF) shl 8) or + ((this[offset + 2].toInt() and 0xFF) shl 16) or + ((this[offset + 3].toInt() and 0xFF) shl 24) + +private fun Int.intToLittleEndian( + output: ByteArray, + offset: Int, +) { + output[offset] = (this and 0xFF).toByte() + output[offset + 1] = (this ushr 8 and 0xFF).toByte() + output[offset + 2] = (this ushr 16 and 0xFF).toByte() + output[offset + 3] = (this ushr 24 and 0xFF).toByte() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt new file mode 100644 index 000000000..f5ff591be --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt @@ -0,0 +1,202 @@ +/* + * 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 + +/** + * Pure Kotlin implementation of Poly1305 one-time authenticator (RFC 8439 §2.5). + * + * Uses 130-bit arithmetic with 5 limbs of 26 bits each, following the approach + * from D.J. Bernstein's reference implementation. All functions are stateless + * and thread-safe. + * + * The 32-byte key is split into: + * - r (first 16 bytes): clamped per spec, used as the multiplier + * - s (last 16 bytes): added at the end + */ +object Poly1305 { + /** + * Compute a 16-byte Poly1305 MAC. + * + * @param message the data to authenticate + * @param key 32-byte one-time key (r || s) + * @return 16-byte authentication tag + */ + fun mac( + message: ByteArray, + key: ByteArray, + ): ByteArray { + // Parse and clamp r from first 16 bytes of key + val r0 = (key.leToLong(0)) and 0x3ffffff + val r1 = (key.leToLong(3) ushr 2) and 0x3ffff03 + val r2 = (key.leToLong(6) ushr 4) and 0x3ffc0ff + val r3 = (key.leToLong(9) ushr 6) and 0x3f03fff + val r4 = (key.leToLong(12) ushr 8) and 0x00fffff + + // Precompute 5*r for the reduction step + val s1 = r1 * 5 + val s2 = r2 * 5 + val s3 = r3 * 5 + val s4 = r4 * 5 + + // Accumulator: 5 limbs of 26 bits + var h0 = 0L + var h1 = 0L + var h2 = 0L + var h3 = 0L + var h4 = 0L + + val fullBlocks = message.size / 16 + val remainder = message.size % 16 + + // Process full 16-byte blocks + for (i in 0 until fullBlocks) { + val offset = i * 16 + h0 += (message.leToLong(offset)) and 0x3ffffff + h1 += (message.leToLong(offset + 3) ushr 2) and 0x3ffffff + h2 += (message.leToLong(offset + 6) ushr 4) and 0x3ffffff + h3 += (message.leToLong(offset + 9) ushr 6) and 0x3ffffff + h4 += (message.leToLong(offset + 12) ushr 8) or (1L shl 24) // hibit = 1 + + // Multiply and reduce + val d0 = h0 * r0 + h1 * s4 + h2 * s3 + h3 * s2 + h4 * s1 + val d1 = h0 * r1 + h1 * r0 + h2 * s4 + h3 * s3 + h4 * s2 + val d2 = h0 * r2 + h1 * r1 + h2 * r0 + h3 * s4 + h4 * s3 + val d3 = h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * s4 + val d4 = h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0 + + // Partial reduction mod 2^130 - 5 + var c: Long + c = d0 ushr 26; h0 = d0 and 0x3ffffff; h1 = d1 + c + c = h1 ushr 26; h1 = h1 and 0x3ffffff; h2 = d2 + c + c = h2 ushr 26; h2 = h2 and 0x3ffffff; h3 = d3 + c + c = h3 ushr 26; h3 = h3 and 0x3ffffff; h4 = d4 + c + c = h4 ushr 26; h4 = h4 and 0x3ffffff; h0 += c * 5 + c = h0 ushr 26; h0 = h0 and 0x3ffffff; h1 += c + } + + // Process remaining bytes (if any) + if (remainder > 0) { + val block = ByteArray(17) + message.copyInto(block, 0, fullBlocks * 16, message.size) + block[remainder] = 1 // pad with 0x01 + + h0 += (block.leToLong(0)) and 0x3ffffff + h1 += (block.leToLong(3) ushr 2) and 0x3ffffff + h2 += (block.leToLong(6) ushr 4) and 0x3ffffff + h3 += (block.leToLong(9) ushr 6) and 0x3ffffff + h4 += (block.leToLong(12) ushr 8) + + val d0 = h0 * r0 + h1 * s4 + h2 * s3 + h3 * s2 + h4 * s1 + val d1 = h0 * r1 + h1 * r0 + h2 * s4 + h3 * s3 + h4 * s2 + val d2 = h0 * r2 + h1 * r1 + h2 * r0 + h3 * s4 + h4 * s3 + val d3 = h0 * r3 + h1 * r2 + h2 * r1 + h3 * r0 + h4 * s4 + val d4 = h0 * r4 + h1 * r3 + h2 * r2 + h3 * r1 + h4 * r0 + + var c: Long + c = d0 ushr 26; h0 = d0 and 0x3ffffff; h1 = d1 + c + c = h1 ushr 26; h1 = h1 and 0x3ffffff; h2 = d2 + c + c = h2 ushr 26; h2 = h2 and 0x3ffffff; h3 = d3 + c + c = h3 ushr 26; h3 = h3 and 0x3ffffff; h4 = d4 + c + c = h4 ushr 26; h4 = h4 and 0x3ffffff; h0 += c * 5 + c = h0 ushr 26; h0 = h0 and 0x3ffffff; h1 += c + } + + // Final reduction: fully carry and reduce mod 2^130 - 5 + var c: Long + c = h1 ushr 26; h1 = h1 and 0x3ffffff; h2 += c + c = h2 ushr 26; h2 = h2 and 0x3ffffff; h3 += c + c = h3 ushr 26; h3 = h3 and 0x3ffffff; h4 += c + c = h4 ushr 26; h4 = h4 and 0x3ffffff; h0 += c * 5 + c = h0 ushr 26; h0 = h0 and 0x3ffffff; h1 += c + + // Compute h + -(2^130 - 5) = h - 2^130 + 5 + var g0 = h0 + 5; c = g0 ushr 26; g0 = g0 and 0x3ffffff + var g1 = h1 + c; c = g1 ushr 26; g1 = g1 and 0x3ffffff + var g2 = h2 + c; c = g2 ushr 26; g2 = g2 and 0x3ffffff + var g3 = h3 + c; c = g3 ushr 26; g3 = g3 and 0x3ffffff + var g4 = h4 + c - (1L shl 26) + + // Select h if g4 is negative (bit 63 set), else select g + val mask = (g4 ushr 63) - 1 // 0 if h, all-ones if g + g0 = g0 and mask + g1 = g1 and mask + g2 = g2 and mask + g3 = g3 and mask + g4 = g4 and mask + val nmask = mask.inv() + h0 = (h0 and nmask) or g0 + h1 = (h1 and nmask) or g1 + h2 = (h2 and nmask) or g2 + h3 = (h3 and nmask) or g3 + h4 = (h4 and nmask) or g4 + + // Assemble h into 4 32-bit words + var f0 = ((h0) or (h1 shl 26)) and 0xffffffffL + var f1 = ((h1 ushr 6) or (h2 shl 20)) and 0xffffffffL + var f2 = ((h2 ushr 12) or (h3 shl 14)) and 0xffffffffL + var f3 = ((h3 ushr 18) or (h4 shl 8)) and 0xffffffffL + + // Add s (second half of the key) + val s0 = key.leToUInt(16) + val s1Long = key.leToUInt(20) + val s2Long = key.leToUInt(24) + val s3Long = key.leToUInt(28) + + f0 += s0; c = f0 ushr 32 + f1 += s1Long + c; c = f1 ushr 32 + f2 += s2Long + c; c = f2 ushr 32 + f3 += s3Long + c + + // Output as little-endian bytes + val tag = ByteArray(16) + tag[0] = (f0).toByte() + tag[1] = (f0 ushr 8).toByte() + tag[2] = (f0 ushr 16).toByte() + tag[3] = (f0 ushr 24).toByte() + tag[4] = (f1).toByte() + tag[5] = (f1 ushr 8).toByte() + tag[6] = (f1 ushr 16).toByte() + tag[7] = (f1 ushr 24).toByte() + tag[8] = (f2).toByte() + tag[9] = (f2 ushr 8).toByte() + tag[10] = (f2 ushr 16).toByte() + tag[11] = (f2 ushr 24).toByte() + tag[12] = (f3).toByte() + tag[13] = (f3 ushr 8).toByte() + tag[14] = (f3 ushr 16).toByte() + tag[15] = (f3 ushr 24).toByte() + return tag + } +} + +/** Read 4 bytes as unsigned little-endian Long starting at [offset]. */ +private fun ByteArray.leToUInt(offset: Int): Long = + (this[offset].toLong() and 0xFF) or + ((this[offset + 1].toLong() and 0xFF) shl 8) or + ((this[offset + 2].toLong() and 0xFF) shl 16) or + ((this[offset + 3].toLong() and 0xFF) shl 24) + +/** Read 4 bytes as unsigned little-endian Long starting at [offset]. Used for Poly1305 limb loading. */ +private fun ByteArray.leToLong(offset: Int): Long = + (this[offset].toLong() and 0xFF) or + ((this[offset + 1].toLong() and 0xFF) shl 8) or + ((this[offset + 2].toLong() and 0xFF) shl 16) or + ((this[offset + 3].toLong() and 0xFF) shl 24) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt new file mode 100644 index 000000000..c3835ecc7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt @@ -0,0 +1,168 @@ +/* + * 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 + +/** + * Pure Kotlin implementation of XChaCha20-Poly1305 AEAD (RFC 8439 §2.8 + XChaCha20 draft). + * + * All functions are stateless and thread-safe. + * + * Construction: + * 1. Derive subkey via HChaCha20(key, nonce[0..15]) + * 2. Build 12-byte subnonce: 0x00000000 || nonce[16..23] + * 3. Generate Poly1305 OTK from ChaCha20 block 0 + * 4. Encrypt with ChaCha20 starting at counter 1 + * 5. Compute Poly1305 tag over: pad16(ad) || pad16(ciphertext) || len(ad) || len(ct) + */ +object XChaCha20Poly1305 { + private const val TAG_SIZE = 16 + + /** + * AEAD encrypt. + * + * @param plaintext message to encrypt + * @param ad associated data (authenticated but not encrypted) + * @param nonce 24-byte nonce + * @param key 32-byte key + * @return ciphertext || 16-byte tag + */ + fun encrypt( + plaintext: ByteArray, + ad: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + // Step 1-2: Derive subkey and subnonce + val subKey = ChaCha20Core.hChaCha20(key, nonce.copyOfRange(0, 16)) + val subNonce = ByteArray(12) + nonce.copyInto(subNonce, destinationOffset = 4, startIndex = 16, endIndex = 24) + + // Step 3: Generate Poly1305 one-time key from block 0 + val polyKey = ChaCha20Core.chaCha20Block(subKey, 0, subNonce).copyOfRange(0, 32) + + // Step 4: Encrypt plaintext with counter starting at 1 + val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, subKey, subNonce, counter = 1) + + // Step 5: Compute tag + val tag = computeTag(polyKey, ad, ciphertext) + + // Step 6: Return ciphertext || tag + return ciphertext + tag + } + + /** + * AEAD decrypt. + * + * @param ciphertextWithTag ciphertext || 16-byte tag + * @param ad associated data + * @param nonce 24-byte nonce + * @param key 32-byte key + * @return plaintext, or throws on authentication failure + */ + fun decrypt( + ciphertextWithTag: ByteArray, + ad: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + require(ciphertextWithTag.size >= TAG_SIZE) { "Ciphertext too short" } + + val ctLen = ciphertextWithTag.size - TAG_SIZE + val ciphertext = ciphertextWithTag.copyOfRange(0, ctLen) + val receivedTag = ciphertextWithTag.copyOfRange(ctLen, ciphertextWithTag.size) + + // Step 1-2: Derive subkey and subnonce + val subKey = ChaCha20Core.hChaCha20(key, nonce.copyOfRange(0, 16)) + val subNonce = ByteArray(12) + nonce.copyInto(subNonce, destinationOffset = 4, startIndex = 16, endIndex = 24) + + // Step 3: Generate Poly1305 one-time key + val polyKey = ChaCha20Core.chaCha20Block(subKey, 0, subNonce).copyOfRange(0, 32) + + // Step 4: Verify tag + val expectedTag = computeTag(polyKey, ad, ciphertext) + check(constantTimeEquals(receivedTag, expectedTag)) { "Authentication failed" } + + // Step 5: Decrypt + return ChaCha20Core.chaCha20Xor(ciphertext, subKey, subNonce, counter = 1) + } + + /** + * Compute Poly1305 tag over the AEAD construction: + * pad16(ad) || pad16(ciphertext) || len(ad) as 8-byte LE || len(ct) as 8-byte LE + */ + private fun computeTag( + polyKey: ByteArray, + ad: ByteArray, + ciphertext: ByteArray, + ): ByteArray { + val adPadLen = if (ad.size % 16 == 0) 0 else 16 - (ad.size % 16) + val ctPadLen = if (ciphertext.size % 16 == 0) 0 else 16 - (ciphertext.size % 16) + + val macData = ByteArray(ad.size + adPadLen + ciphertext.size + ctPadLen + 16) + var offset = 0 + + // pad16(ad) + ad.copyInto(macData, offset) + offset += ad.size + adPadLen + + // pad16(ciphertext) + ciphertext.copyInto(macData, offset) + offset += ciphertext.size + ctPadLen + + // len(ad) as 8-byte little-endian + longToLittleEndian(ad.size.toLong(), macData, offset) + offset += 8 + + // len(ciphertext) as 8-byte little-endian + longToLittleEndian(ciphertext.size.toLong(), macData, offset) + + return Poly1305.mac(macData, polyKey) + } + + /** Constant-time comparison to prevent timing attacks. */ + private fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { + if (a.size != b.size) return false + var result = 0 + for (i in a.indices) { + result = result or (a[i].toInt() xor b[i].toInt()) + } + return result == 0 + } + + private fun longToLittleEndian( + value: Long, + output: ByteArray, + offset: Int, + ) { + output[offset] = (value and 0xFF).toByte() + output[offset + 1] = (value ushr 8 and 0xFF).toByte() + output[offset + 2] = (value ushr 16 and 0xFF).toByte() + output[offset + 3] = (value ushr 24 and 0xFF).toByte() + output[offset + 4] = (value ushr 32 and 0xFF).toByte() + output[offset + 5] = (value ushr 40 and 0xFF).toByte() + output[offset + 6] = (value ushr 48 and 0xFF).toByte() + output[offset + 7] = (value ushr 56 and 0xFF).toByte() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt index 1ebf25086..2f5b0225c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt @@ -20,7 +20,14 @@ */ package com.vitorpamplona.quartz.utils -expect object LibSodiumInstance { +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Core +import com.vitorpamplona.quartz.nip44Encryption.crypto.XChaCha20Poly1305 + +/** + * Pure Kotlin replacement for LazySodium/libsodium. + * All functions are stateless and thread-safe. + */ +object LibSodiumInstance { fun cryptoAeadXChaCha20Poly1305IetfDecrypt( message: ByteArray, nSec: ByteArray, @@ -28,7 +35,14 @@ expect object LibSodiumInstance { ad: ByteArray, nPub: ByteArray, k: ByteArray, - ): Boolean + ): Boolean = + try { + val plaintext = XChaCha20Poly1305.decrypt(ciphertext, ad, nPub, k) + plaintext.copyInto(message) + true + } catch (_: Exception) { + false + } fun cryptoAeadXChaCha20Poly1305IetfEncrypt( ciphertext: ByteArray, @@ -37,18 +51,24 @@ expect object LibSodiumInstance { nSec: ByteArray, nPub: ByteArray, k: ByteArray, - ): Boolean + ): Boolean = + try { + val result = XChaCha20Poly1305.encrypt(message, ad, nPub, k) + result.copyInto(ciphertext) + true + } catch (_: Exception) { + false + } fun cryptoStreamChaCha20IetfXor( message: ByteArray, nonce: ByteArray?, key: ByteArray?, - ): ByteArray + ): ByteArray = ChaCha20Core.chaCha20Xor(message, key!!, nonce!!, counter = 0) - // This function wasn't available in the bindings library. I had to move them here from C fun cryptoStreamXChaCha20Xor( messageBytes: ByteArray, nonce: ByteArray, key: ByteArray, - ): ByteArray + ): ByteArray = ChaCha20Core.xChaCha20Xor(messageBytes, nonce, key) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt new file mode 100644 index 000000000..af6a1a597 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt @@ -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 { + 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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt new file mode 100644 index 000000000..e6fa13c17 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt @@ -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") + } + } +} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.ios.kt deleted file mode 100644 index 3b56e181b..000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.ios.kt +++ /dev/null @@ -1,130 +0,0 @@ -/* - * 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.utils - -import kotlinx.cinterop.CValuesRef -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.UByteVar -import kotlinx.cinterop.refTo -import kotlin.experimental.ExperimentalNativeApi - -actual object LibSodiumInstance { - @OptIn(ExperimentalForeignApi::class) - actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt( - message: ByteArray, - nSec: ByteArray, - ciphertext: ByteArray, - ad: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean { - val returnCode = - Clibsodium.crypto_aead_xchacha20poly1305_ietf_decrypt( - m = message.uRefTo(0), - mlen_p = ulongArrayOf(message.size.toULong()).refTo(0), - nsec = nSec.uRefTo(0), - c = ciphertext.uRefTo(0), - clen = ciphertext.size.toULong(), - ad = ad.uRefTo(0), - adlen = ad.size.toULong(), - npub = nPub.uRefTo(0), - k = k.uRefTo(0), - ) - return returnCode == 0 - } - - @OptIn(ExperimentalForeignApi::class) - actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext: ByteArray, - message: ByteArray, - ad: ByteArray, - nSec: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean { - val retCode = - Clibsodium.crypto_aead_xchacha20poly1305_ietf_encrypt( - c = ciphertext.uRefTo(0), - clen_p = ulongArrayOf(ciphertext.size.toULong()).refTo(0), - m = message.uRefTo(0), - mlen = message.size.toULong(), - ad = ad.uRefTo(0), - adlen = ad.size.toULong(), - nsec = nSec.uRefTo(0), - npub = nPub.uRefTo(0), - k = k.uRefTo(0), - ) - - return retCode == 0 - } - - @OptIn(ExperimentalForeignApi::class) - actual fun cryptoStreamChaCha20IetfXor( - message: ByteArray, - nonce: ByteArray?, - key: ByteArray?, - ): ByteArray { - val ciphertext = ByteArray(message.size) - Clibsodium.crypto_stream_chacha20_ietf_xor( - c = ciphertext.uRefTo(0), - m = message.uRefTo(0), - mlen = message.size.toULong(), - n = nonce?.uRefTo(0), - k = key?.uRefTo(0), - ) - return ciphertext - } - - @OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) - actual fun cryptoStreamXChaCha20Xor( - messageBytes: ByteArray, - nonce: ByteArray, - key: ByteArray, - ): ByteArray { - val cipher = ByteArray(messageBytes.size) - val k2 = ByteArray(32) - - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) - - Clibsodium.crypto_core_hchacha20( - out = k2.uRefTo(0), - nonce.uRefTo(0), - k = key.uRefTo(0), - c = null, - ) - - val resultCode = - Clibsodium.crypto_stream_chacha20_xor_ic( - c = cipher.uRefTo(0), - m = messageBytes.uRefTo(0), - mlen = messageBytes.size.toULong(), - n = nonceChaCha.uRefTo(0), - ic = 0L.toULong(), - k = k2.uRefTo(0), - ) - return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message") - } -} - -@OptIn(ExperimentalForeignApi::class) -@Suppress("UNCHECKED_CAST") -private fun ByteArray.uRefTo(index: Int) = refTo(index) as CValuesRef diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.jvm.kt deleted file mode 100644 index 0645caee8..000000000 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.jvm.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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.utils - -import com.goterl.lazysodium.LazySodiumJava -import com.goterl.lazysodium.SodiumJava - -actual object LibSodiumInstance { - private val libSodium = SodiumJava() - private val lazySodium = LazySodiumJava(libSodium) - - actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt( - message: ByteArray, - nSec: ByteArray, - ciphertext: ByteArray, - ad: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean = - lazySodium.cryptoAeadXChaCha20Poly1305IetfDecrypt( - message, - longArrayOf(message.size.toLong()), - nSec, - ciphertext, - ciphertext.size.toLong(), - ad, - ad.size.toLong(), - nPub, - k, - ) - - actual fun cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext: ByteArray, - message: ByteArray, - ad: ByteArray, - nSec: ByteArray, - nPub: ByteArray, - k: ByteArray, - ): Boolean = - lazySodium.cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext, - longArrayOf(ciphertext.size.toLong()), - message, - message.size.toLong(), - ad, - ad.size.toLong(), - nSec, - nPub, - k, - ) - - actual fun cryptoStreamChaCha20IetfXor( - message: ByteArray, - nonce: ByteArray?, - key: ByteArray?, - ): ByteArray { - val ciphertext = ByteArray(message.size) - lazySodium.cryptoStreamChaCha20IetfXor(ciphertext, message, message.size.toLong(), nonce, key) - return ciphertext - } - - actual fun cryptoStreamXChaCha20Xor( - messageBytes: ByteArray, - nonce: ByteArray, - key: ByteArray, - ): ByteArray { - val cipher = ByteArray(messageBytes.size) - val k2 = ByteArray(32) - - val nonceChaCha = nonce.drop(16).toByteArray() - assert(nonceChaCha.size == 8) - - libSodium.crypto_core_hchacha20(k2, nonce, key, null) - val resultCode = - libSodium.crypto_stream_chacha20_xor_ic( - cipher, - messageBytes, - messageBytes.size.toLong(), - nonceChaCha, - 0, - k2, - ) - - return if (resultCode == 0) cipher else throw IllegalStateException("Could not decrypt message") - } -}