From d586a0dc259917de617591c52afae41d62af8c6a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 17:40:29 +0000 Subject: [PATCH 01/10] feat: replace libsodium with pure Kotlin ChaCha20/Poly1305 implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/benchmark/ChaCha20Benchmark.kt | 28 +- gradle/libs.versions.toml | 6 - quartz/build.gradle.kts | 72 ----- quartz/consumer-rules.pro | 22 -- quartz/proguard-rules.pro | 22 -- .../quartz/utils/LibSodiumInstance.android.kt | 131 --------- .../quartz/nip44Encryption/crypto/ChaCha20.kt | 43 +-- .../nip44Encryption/crypto/ChaCha20Core.kt | 266 ++++++++++++++++++ .../quartz/nip44Encryption/crypto/Poly1305.kt | 202 +++++++++++++ .../crypto/XChaCha20Poly1305.kt | 168 +++++++++++ .../quartz/utils/LibSodiumInstance.kt | 32 ++- .../crypto/ChaCha20CoreTest.kt | 253 +++++++++++++++++ .../nip44Encryption/crypto/XChaCha20Test.kt | 110 ++++++++ .../quartz/utils/LibSodiumInstance.ios.kt | 130 --------- .../quartz/utils/LibSodiumInstance.jvm.kt | 104 ------- 15 files changed, 1031 insertions(+), 558 deletions(-) delete mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.android.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt delete mode 100644 quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.ios.kt delete mode 100644 quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.jvm.kt 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") - } -} From 2e1bcf3e52bc46910549ed64cb3c3c4af9198172 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 17:57:31 +0000 Subject: [PATCH 02/10] style: apply spotless formatting to ChaCha20/Poly1305 sources https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i --- .../nip44Encryption/crypto/ChaCha20Core.kt | 12 +- .../quartz/nip44Encryption/crypto/Poly1305.kt | 93 +++++++++---- .../crypto/ChaCha20CoreTest.kt | 6 +- .../nip44Encryption/crypto/XChaCha20Test.kt | 125 ++++++++++-------- 4 files changed, 149 insertions(+), 87 deletions(-) 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 index 2d9abce80..fd5cadbca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt @@ -48,10 +48,14 @@ object ChaCha20Core { 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) + 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) } /** 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 index f5ff591be..cf99fca47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt @@ -84,12 +84,24 @@ object Poly1305 { // 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 + 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) @@ -111,27 +123,57 @@ object Poly1305 { 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 + 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 + 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 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 @@ -160,9 +202,12 @@ object Poly1305 { 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 + 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 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 index af6a1a597..a910aa004 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt @@ -31,7 +31,11 @@ import kotlin.test.assertFailsWith */ class ChaCha20CoreTest { private fun hex(s: String): ByteArray = - s.replace(" ", "").chunked(2).map { it.toInt(16).toByte() }.toByteArray() + s + .replace(" ", "") + .chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } 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 index e6fa13c17..71c14f07e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt @@ -25,40 +25,44 @@ 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() + 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", - ), + 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()) { @@ -68,36 +72,41 @@ class XChaCha20Test { } // 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", - ), + 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()) { From 425fc24bc2487d95d89d1a88ca6c244189cfdc3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 02:35:30 +0000 Subject: [PATCH 03/10] perf: optimize ChaCha20 and XChaCha20-Poly1305 for speed ChaCha20Core.chaCha20Xor: - Parse key/nonce once into initial state, reuse across blocks - XOR at word level (4 bytes at a time) instead of byte-by-byte - Reuse working IntArray across blocks instead of allocating per block XChaCha20Poly1305: - Add hChaCha20FromNonce24 to avoid nonce copyOfRange(0,16) allocation - Add chaCha20PolyKey to generate only 32 bytes instead of full 64-byte block - Use single result array instead of ciphertext + tag concatenation Benchmark (98-byte padded message, JVM): Before: encrypt 2286 ns/op, decrypt 2062 ns/op After: encrypt 1544 ns/op, decrypt 341 ns/op https://claude.ai/code/session_01YHBwqnb3Z7Q7PX31tJ2G3i --- .../nip44Encryption/crypto/ChaCha20Core.kt | 131 +++++++++++++++--- .../crypto/XChaCha20Poly1305.kt | 23 +-- 2 files changed, 125 insertions(+), 29 deletions(-) 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 index fd5cadbca..66b7e677a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt @@ -127,15 +127,10 @@ object ChaCha20Core { 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 + // Add initial state to working state and serialize val output = ByteArray(64) for (i in 0..15) { - working[i].intToLittleEndian(output, i * 4) + (working[i] + initial[i]).intToLittleEndian(output, i * 4) } return output } @@ -144,6 +139,9 @@ object ChaCha20Core { * ChaCha20 IETF stream cipher XOR (RFC 8439 §2.4). * XORs the message with the ChaCha20 keystream. * + * Optimized to parse key/nonce once and reuse state across blocks. + * Full 64-byte blocks use word-level XOR (4 bytes at a time). + * * @param message plaintext or ciphertext * @param key 32-byte key * @param nonce 12-byte nonce @@ -157,22 +155,55 @@ object ChaCha20Core { counter: Int = 0, ): ByteArray { val output = ByteArray(message.size) + if (message.isEmpty()) return output + val fullBlocks = message.size / 64 val remainder = message.size % 64 + // Parse key and nonce once into the initial state template + val initial = initState(key, counter, nonce) + val working = IntArray(16) + 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() + initial[12] = counter + i + initial.copyInto(working) + chaCha20Rounds(working) + + // XOR at word level: read 4 message bytes as Int, XOR with keystream word, write 4 bytes + val off = i * 64 + for (j in 0..15) { + val ks = working[j] + initial[j] + val mw = message.littleEndianToInt(off + j * 4) + (ks xor mw).intToLittleEndian(output, off + j * 4) } } 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() + initial[12] = counter + fullBlocks + initial.copyInto(working) + chaCha20Rounds(working) + + val off = fullBlocks * 64 + // Process full words within the remainder + val fullWords = remainder / 4 + for (j in 0 until fullWords) { + val ks = working[j] + initial[j] + val mw = message.littleEndianToInt(off + j * 4) + (ks xor mw).intToLittleEndian(output, off + j * 4) + } + // Process remaining bytes (0-3 bytes) + val tailStart = fullWords * 4 + if (tailStart < remainder) { + // Serialize just this one keystream word + val ks = working[fullWords] + initial[fullWords] + val ksByte0 = (ks and 0xFF) + val ksByte1 = (ks ushr 8 and 0xFF) + val ksByte2 = (ks ushr 16 and 0xFF) + val ksByte3 = (ks ushr 24 and 0xFF) + val ksBytes = intArrayOf(ksByte0, ksByte1, ksByte2, ksByte3) + for (b in tailStart until remainder) { + output[off + b] = (message[off + b].toInt() xor ksBytes[b - tailStart]).toByte() + } } } @@ -225,6 +256,68 @@ object ChaCha20Core { return output } + /** + * HChaCha20 variant that avoids allocating a 16-byte input copy. + * Reads the first 16 bytes of the nonce directly. + * + * @param key 32-byte key + * @param nonce 24-byte nonce (only first 16 bytes are used) + */ + internal fun hChaCha20FromNonce24( + key: ByteArray, + nonce: 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] = nonce.littleEndianToInt(0) + state[13] = nonce.littleEndianToInt(4) + state[14] = nonce.littleEndianToInt(8) + state[15] = nonce.littleEndianToInt(12) + + chaCha20Rounds(state) + + 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 + } + + /** + * Generates the first 32 bytes of keystream block 0 (used as Poly1305 one-time key). + * Avoids allocating a full 64-byte block. + */ + internal fun chaCha20PolyKey( + key: ByteArray, + nonce: ByteArray, + ): ByteArray { + val initial = initState(key, 0, nonce) + val working = initial.copyOf() + chaCha20Rounds(working) + + val polyKey = ByteArray(32) + for (i in 0..7) { + (working[i] + initial[i]).intToLittleEndian(polyKey, i * 4) + } + return polyKey + } + /** * XChaCha20 stream cipher XOR (draft-irtf-cfrg-xchacha §2.3). * Uses HChaCha20 to derive a subkey, then applies ChaCha20 with the remaining nonce bytes. @@ -239,8 +332,8 @@ object ChaCha20Core { nonce: ByteArray, key: ByteArray, ): ByteArray { - // Step 1: Derive subkey using first 16 bytes of nonce - val subKey = hChaCha20(key, nonce.copyOfRange(0, 16)) + // Step 1: Derive subkey using first 16 bytes of nonce (no copyOfRange) + val subKey = hChaCha20FromNonce24(key, nonce) // Step 2: Build 12-byte subnonce: 4 zero bytes + last 8 bytes of nonce val subNonce = ByteArray(12) @@ -253,13 +346,13 @@ object ChaCha20Core { // --- Little-endian conversion helpers --- -private fun ByteArray.littleEndianToInt(offset: Int): Int = +internal 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( +internal fun Int.intToLittleEndian( output: ByteArray, offset: Int, ) { 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 index c3835ecc7..4e8ac5a9e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt @@ -50,13 +50,13 @@ object XChaCha20Poly1305 { nonce: ByteArray, key: ByteArray, ): ByteArray { - // Step 1-2: Derive subkey and subnonce - val subKey = ChaCha20Core.hChaCha20(key, nonce.copyOfRange(0, 16)) + // Step 1-2: Derive subkey and subnonce (no copyOfRange for nonce) + val subKey = ChaCha20Core.hChaCha20FromNonce24(key, nonce) 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 3: Generate Poly1305 one-time key (only 32 bytes, not full 64-byte block) + val polyKey = ChaCha20Core.chaCha20PolyKey(subKey, subNonce) // Step 4: Encrypt plaintext with counter starting at 1 val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, subKey, subNonce, counter = 1) @@ -64,8 +64,11 @@ object XChaCha20Poly1305 { // Step 5: Compute tag val tag = computeTag(polyKey, ad, ciphertext) - // Step 6: Return ciphertext || tag - return ciphertext + tag + // Step 6: Return ciphertext || tag (single allocation) + val result = ByteArray(ciphertext.size + TAG_SIZE) + ciphertext.copyInto(result) + tag.copyInto(result, ciphertext.size) + return result } /** @@ -89,13 +92,13 @@ object XChaCha20Poly1305 { 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)) + // Step 1-2: Derive subkey and subnonce (no copyOfRange for nonce) + val subKey = ChaCha20Core.hChaCha20FromNonce24(key, nonce) 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 3: Generate Poly1305 one-time key (only 32 bytes, not full 64-byte block) + val polyKey = ChaCha20Core.chaCha20PolyKey(subKey, subNonce) // Step 4: Verify tag val expectedTag = computeTag(polyKey, ad, ciphertext) From cba65344b9128ef85084dcfc94e1fccac4710d78 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 04:37:41 +0000 Subject: [PATCH 04/10] fix: clean up temp files after upload completes MetadataStripper, MediaCompressor, and EncryptFiles create intermediate temp files in cacheDir during the upload pipeline, but these were never deleted after the upload finished. Over time this causes unbounded disk growth. Wrap upload calls in try/finally to ensure all intermediate temp files (compressed, stripped, encrypted) are deleted regardless of success or failure. https://claude.ai/code/session_01YS3dbZ1k84N2EHS3AruYRV --- .../service/uploads/UploadOrchestrator.kt | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index ee5ef4e72..0c54b765e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -31,10 +31,12 @@ import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import okhttp3.OkHttpClient +import java.io.File import kotlin.coroutines.cancellation.CancellationException sealed class UploadingState { @@ -324,6 +326,26 @@ class UploadOrchestrator { return strippingResult.uri } + /** + * Deletes a temporary file created during the upload pipeline if its URI + * differs from the original (meaning it's an intermediate temp file, not the user's content). + */ + private fun deleteTempUri( + tempUri: Uri, + originalUri: Uri, + ) { + if (tempUri == originalUri) return + try { + val path = tempUri.path ?: return + val file = File(path) + if (file.exists() && file.delete()) { + Log.d("UploadOrchestrator", "Deleted temp file: $path") + } + } catch (e: Exception) { + Log.w("UploadOrchestrator", "Failed to delete temp file: ${tempUri.path}", e) + } + } + suspend fun upload( uri: Uri, mimeType: String?, @@ -343,10 +365,17 @@ class UploadOrchestrator { stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) ?: return error(R.string.upload_cancelled) - return when (server.type) { - ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) - ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) - ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + try { + return when (server.type) { + ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context) + ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) + } + } finally { + // Clean up intermediate temp files created by stripping and compression. + // Delete stripped file first (if different from compressed), then compressed (if different from original). + deleteTempUri(finalUri, compressed.uri) + deleteTempUri(compressed.uri, uri) } } @@ -372,10 +401,17 @@ class UploadOrchestrator { val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt) - return when (server.type) { - ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) - ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) - ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + try { + return when (server.type) { + ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context) + ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) + } + } finally { + // Clean up all intermediate temp files: encrypted, stripped, and compressed. + deleteTempUri(encrypted.uri, finalUri) + deleteTempUri(finalUri, compressed.uri) + deleteTempUri(compressed.uri, uri) } } } From 390edf311d548fd56d2ddde1d103bd34c752688f Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 25 Mar 2026 09:29:48 +0100 Subject: [PATCH 05/10] analysis for temporary files --- docs/temp-file-cleanup-analysis.md | 109 +++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/temp-file-cleanup-analysis.md diff --git a/docs/temp-file-cleanup-analysis.md b/docs/temp-file-cleanup-analysis.md new file mode 100644 index 000000000..25299b935 --- /dev/null +++ b/docs/temp-file-cleanup-analysis.md @@ -0,0 +1,109 @@ +# Temporary File Cleanup Analysis + +## Overview + +Analysis of temporary file creation and cleanup patterns across the Amethyst codebase. +The goal is to identify opportunities for more aggressive cleanup — deleting temp files +as soon as they are no longer needed rather than deferring to cache cleanup. + +## Temporary File Creation Sites (Android) + +| Area | File | Creates | Cleanup | Notes | +|------|------|---------|---------|-------| +| Image compression | `MediaCompressor.kt:94` | Temp copy via `MediaCompressorFileUtils.from()` | UploadOrchestrator `finally` | Deferred to cache cleanup | +| Image metadata strip | `MetadataStripper.kt:199` | `stripped_*.jpg` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | +| Video metadata strip | `MetadataStripper.kt:238` | `stripped_video_*.mp4` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | +| Audio metadata strip | `MetadataStripper.kt:286` | `stripped_audio_*.m4a` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | +| MP3 metadata strip | `MetadataStripper.kt:307,363` | `mp3_input_*` + `stripped_mp3_*` | Mixed: some immediate, some orchestrator | MP3 input file cleaned inline | +| File encryption | `EncryptFiles.kt:47` | `EncryptFiles*.encrypted` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | +| URI temp copy | `MediaCompressorFileUtils.kt:41` | Random UUID temp file | Caller-dependent | No self-cleanup | +| Voice anonymization | `VoiceAnonymizationController.kt:85` | Distorted voice files | `deleteDistortedFiles()` explicit | Cleaned by controller | +| Video sharing | `ZoomableContentView.kt:905` | Temp video + sharable copy | Delayed GlobalScope (1 min) | Intentional delay for sharing | +| Camera capture | `TakePicture.kt:244` | Camera temp file | System/caller | Managed by camera provider | + +## Temporary File Creation Sites (Desktop) + +| Area | File | Creates | Cleanup | Notes | +|------|------|---------|---------|-------| +| Clipboard paste | `ClipboardPasteHandler.kt:43` | `clipboard_*.png` | `deleteOnExit()` only | Leaks until JVM exit | +| Image compression | `DesktopMediaCompressor.kt:42` | `stripped_*.jpg` | `deleteOnExit()` only | Leaks until JVM exit | + +## The Upload Pipeline + +The `UploadOrchestrator` is the central cleanup coordinator. Its `finally` blocks call +`deleteTempUri()` to delete intermediate files after upload completes. The pipeline stages: + +``` +1. MediaCompressorFileUtils.from() --> temp copy of original URI +2. MediaCompressor.compress() --> compressed file (may replace #1) +3. MetadataStripper.strip*() --> stripped file (new temp) +4. (optional) EncryptFiles.encrypt() --> encrypted file (new temp) +5. Upload to server +6. finally: delete all intermediates via deleteTempUri() +``` + +Each stage may produce a new temp file. Currently, all intermediates accumulate and are +only cleaned up in the `finally` block after the upload succeeds or fails. + +## Cleanup Patterns in Use + +### 1. Chained Deletion (UploadOrchestrator) +- Most common pattern +- `finally` blocks at `L374-379` (upload) and `L410-415` (uploadEncrypted) +- Deletes all intermediate temp files after pipeline completes +- Checks `tempUri != originalUri` to avoid deleting user's original file + +### 2. Delayed Deletion (Video Sharing) +- `ZoomableContentView.kt:953-960` +- Uses `GlobalScope.launch(Dispatchers.IO)` with 1 minute delay +- Intentional: allows recipient app time to read the shared file +- **Exception: this delay is necessary and should not be shortened** + +### 3. Explicit Cleanup Calls (Voice) +- `VoiceAnonymizationController.deleteDistortedFiles()` at `L108-123` +- Called explicitly by `ShortNotePostViewModel` at `L1336` +- Good pattern: controller owns its temp files + +### 4. deleteOnExit() (Desktop) +- Used by `ClipboardPasteHandler` and `DesktopMediaCompressor` +- Files persist until JVM process exits +- Not ideal for long-running desktop app + +## Opportunities for More Aggressive Cleanup + +### 1. Pipeline Stage Cleanup (UploadOrchestrator) + +**Current:** All intermediates deleted in `finally` after upload. + +**Proposed:** Delete each intermediate as soon as the next stage produces output. + +Example: after `MetadataStripper` produces a stripped file, delete the compressed file +from the previous stage immediately, rather than keeping it alive through upload + finally. + +### 2. Desktop Temp Files + +**Current:** `deleteOnExit()` — files persist for entire app session. + +**Proposed:** Delete immediately after upload completes, matching the Android +`UploadOrchestrator` pattern via `DesktopUploadOrchestrator`. + +### 3. MediaCompressorFileUtils Intermediate + +**Current:** `from()` creates a temp copy at `L41`. If compression produces a *different* +file, the original temp copy is orphaned until `UploadOrchestrator.finally`. + +**Proposed:** `MediaCompressor` should delete the `from()` temp file immediately after +compression succeeds (when the compressed file differs from the input). + +### 4. Voice Anonymization Intermediates + +**Current:** `deleteDistortedFiles()` called after upload. + +**Proposed:** Already reasonably aggressive. Could delete each intermediate distortion +result as soon as the next processing step completes, if there are multiple stages. + +## Exception: Video Sharing + +Sharing a video to other Android apps requires the temporary file to remain accessible +for at least 1 minute. The current `SHARED_VIDEO_CLEANUP_DELAY_MS` delay in +`ZoomableContentView.kt` handles this correctly and should **not** be made more aggressive. \ No newline at end of file From 60b7c6bd0d778aaa8ce75e37b47dfea3f31eb012 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 25 Mar 2026 10:04:44 +0100 Subject: [PATCH 06/10] fix: delete abandoned compressed video when larger than original fix: eagerly delete intermediate temp files in upload pipeline fix: delete temp file from MediaCompressorFileUtils after image compression refactor: simplify temp file cleanup logic - Remove TOCTOU anti-pattern (file.exists() before file.delete()) - Consolidate double deleteTempUri calls into single conditional - Remove restating comments --- .../service/uploads/MediaCompressor.kt | 13 +++++++--- .../service/uploads/UploadOrchestrator.kt | 25 +++++++++++-------- .../service/uploads/VideoCompressionHelper.kt | 1 + 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index f984e7288..875dd72d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log import id.zelory.compressor.Compressor import id.zelory.compressor.constraint.default import kotlinx.coroutines.CancellationException +import java.io.File class MediaCompressorResult( val uri: Uri, @@ -89,19 +90,23 @@ class MediaCompressor { else -> 60 } + var tempFile: File? = null return try { Log.d("MediaCompressor", "Using image compression $mediaQuality") - val tempFile = MediaCompressorFileUtils.from(uri, context) + tempFile = MediaCompressorFileUtils.from(uri, context) val compressedImageFile = Compressor.compress(context, tempFile) { default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality) } - Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]") + if (tempFile != compressedImageFile && !tempFile.delete()) { + Log.w("MediaCompressor", "Failed to delete temp file: ${tempFile.absolutePath}") + } + Log.d("MediaCompressor", "Image compression success. New size [${compressedImageFile.length()}]") MediaCompressorResult(compressedImageFile.toUri(), MimeTypes.IMAGE_JPEG, compressedImageFile.length()) } catch (e: Exception) { - Log.d("MediaCompressor", "Image compression failed: ${e.message}") if (e is CancellationException) throw e - e.printStackTrace() + Log.d("MediaCompressor", "Image compression failed: ${e.message}") + tempFile?.delete() MediaCompressorResult(uri, contentType, null) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 0c54b765e..b56d78932 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -338,7 +338,7 @@ class UploadOrchestrator { try { val path = tempUri.path ?: return val file = File(path) - if (file.exists() && file.delete()) { + if (file.delete()) { Log.d("UploadOrchestrator", "Deleted temp file: $path") } } catch (e: Exception) { @@ -363,7 +363,11 @@ class UploadOrchestrator { val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) - ?: return error(R.string.upload_cancelled) + ?: return error(R.string.upload_cancelled).also { + deleteTempUri(compressed.uri, uri) + } + + if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri) try { return when (server.type) { @@ -372,10 +376,7 @@ class UploadOrchestrator { ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context) } } finally { - // Clean up intermediate temp files created by stripping and compression. - // Delete stripped file first (if different from compressed), then compressed (if different from original). - deleteTempUri(finalUri, compressed.uri) - deleteTempUri(compressed.uri, uri) + deleteTempUri(finalUri, uri) } } @@ -397,9 +398,14 @@ class UploadOrchestrator { val finalUri = stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context) - ?: return error(R.string.upload_cancelled) + ?: return error(R.string.upload_cancelled).also { + deleteTempUri(compressed.uri, uri) + } + + if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri) val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt) + deleteTempUri(finalUri, uri) try { return when (server.type) { @@ -408,10 +414,7 @@ class UploadOrchestrator { ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context) } } finally { - // Clean up all intermediate temp files: encrypted, stripped, and compressed. - deleteTempUri(encrypted.uri, finalUri) - deleteTempUri(finalUri, compressed.uri) - deleteTempUri(compressed.uri, uri) + deleteTempUri(encrypted.uri, uri) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 8f98f6fb9..3d3c64715 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -233,6 +233,7 @@ object VideoCompressionHelper { // Sanity check: compression not smaller than original if (originalSize > 0 && size >= originalSize) { + File(path).delete() applicationContext.notifyUser( "Compressed file larger than original. Using original.", Log.WARN, From 42db03790a157e777b1e9e474a6edb9e62db45d7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 25 Mar 2026 10:23:38 +0100 Subject: [PATCH 07/10] added progress and test plan to doc --- docs/temp-file-cleanup-analysis.md | 234 ++++++++++++++++++++--------- 1 file changed, 163 insertions(+), 71 deletions(-) diff --git a/docs/temp-file-cleanup-analysis.md b/docs/temp-file-cleanup-analysis.md index 25299b935..d9acd1ea5 100644 --- a/docs/temp-file-cleanup-analysis.md +++ b/docs/temp-file-cleanup-analysis.md @@ -8,18 +8,19 @@ as soon as they are no longer needed rather than deferring to cache cleanup. ## Temporary File Creation Sites (Android) -| Area | File | Creates | Cleanup | Notes | -|------|------|---------|---------|-------| -| Image compression | `MediaCompressor.kt:94` | Temp copy via `MediaCompressorFileUtils.from()` | UploadOrchestrator `finally` | Deferred to cache cleanup | -| Image metadata strip | `MetadataStripper.kt:199` | `stripped_*.jpg` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | -| Video metadata strip | `MetadataStripper.kt:238` | `stripped_video_*.mp4` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | -| Audio metadata strip | `MetadataStripper.kt:286` | `stripped_audio_*.m4a` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | -| MP3 metadata strip | `MetadataStripper.kt:307,363` | `mp3_input_*` + `stripped_mp3_*` | Mixed: some immediate, some orchestrator | MP3 input file cleaned inline | -| File encryption | `EncryptFiles.kt:47` | `EncryptFiles*.encrypted` in cacheDir | UploadOrchestrator `finally` | Deferred to cache cleanup | -| URI temp copy | `MediaCompressorFileUtils.kt:41` | Random UUID temp file | Caller-dependent | No self-cleanup | -| Voice anonymization | `VoiceAnonymizationController.kt:85` | Distorted voice files | `deleteDistortedFiles()` explicit | Cleaned by controller | -| Video sharing | `ZoomableContentView.kt:905` | Temp video + sharable copy | Delayed GlobalScope (1 min) | Intentional delay for sharing | -| Camera capture | `TakePicture.kt:244` | Camera temp file | System/caller | Managed by camera provider | +| Area | File | Creates | Cleanup | Status | +|------|------|---------|---------|--------| +| Image compression | `MediaCompressor.kt:94` | Temp copy via `MediaCompressorFileUtils.from()` | Deleted immediately after compression | FIXED | +| Image metadata strip | `MetadataStripper.kt:199` | `stripped_*.jpg` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| Video metadata strip | `MetadataStripper.kt:238` | `stripped_video_*.mp4` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| Audio metadata strip | `MetadataStripper.kt:286` | `stripped_audio_*.m4a` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| MP3 metadata strip | `MetadataStripper.kt:307,363` | `mp3_input_*` + `stripped_mp3_*` | Mixed: some immediate, some orchestrator | Already OK | +| File encryption | `EncryptFiles.kt:47` | `EncryptFiles*.encrypted` in cacheDir | Deleted eagerly by orchestrator | FIXED | +| URI temp copy | `MediaCompressorFileUtils.kt:41` | Random UUID temp file | Deleted by MediaCompressor after use | FIXED | +| Video compression | `VideoCompressionHelper.kt` | Compressed video in app storage | Abandoned file deleted when larger than original | FIXED | +| Voice anonymization | `VoiceAnonymizationController.kt:85` | Distorted voice files | `deleteDistortedFiles()` explicit | Already OK | +| Video sharing | `ZoomableContentView.kt:905` | Temp video + sharable copy | Delayed GlobalScope (1 min) | Skipped (intentional) | +| Camera capture | `TakePicture.kt:244` | Camera temp file | System/caller | Skipped (system-managed) | ## Temporary File Creation Sites (Desktop) @@ -30,80 +31,171 @@ as soon as they are no longer needed rather than deferring to cache cleanup. ## The Upload Pipeline -The `UploadOrchestrator` is the central cleanup coordinator. Its `finally` blocks call -`deleteTempUri()` to delete intermediate files after upload completes. The pipeline stages: +The `UploadOrchestrator` is the central cleanup coordinator. Each intermediate temp file +is now deleted as soon as the next pipeline stage produces its output: ``` -1. MediaCompressorFileUtils.from() --> temp copy of original URI -2. MediaCompressor.compress() --> compressed file (may replace #1) -3. MetadataStripper.strip*() --> stripped file (new temp) -4. (optional) EncryptFiles.encrypt() --> encrypted file (new temp) +1. MediaCompressorFileUtils.from() --> temp copy of original URI +2. MediaCompressor.compress() --> compressed file; temp copy from #1 deleted immediately +3. MetadataStripper.strip*() --> stripped file; compressed file from #2 deleted immediately +4. (optional) EncryptFiles.encrypt() --> encrypted file; stripped file from #3 deleted immediately 5. Upload to server -6. finally: delete all intermediates via deleteTempUri() +6. finally: delete the last remaining intermediate ``` -Each stage may produce a new temp file. Currently, all intermediates accumulate and are -only cleaned up in the `finally` block after the upload succeeds or fails. +## What Was Fixed -## Cleanup Patterns in Use +### 1. MediaCompressor temp file leak (MediaCompressor.kt) +- `MediaCompressorFileUtils.from()` created a temp copy that was never deleted +- Now deleted immediately after `Compressor.compress()` produces a separate output file +- Also cleaned up on compression failure (catch block) -### 1. Chained Deletion (UploadOrchestrator) -- Most common pattern -- `finally` blocks at `L374-379` (upload) and `L410-415` (uploadEncrypted) -- Deletes all intermediate temp files after pipeline completes -- Checks `tempUri != originalUri` to avoid deleting user's original file +### 2. Eager pipeline cleanup (UploadOrchestrator.kt) +- `upload()`: compressed file deleted right after stripping produces `finalUri` +- `uploadEncrypted()`: compressed file deleted after stripping, stripped file deleted + after encryption — only the encrypted file survives until after upload +- Cancel path also cleans up compressed file via `.also {}` block +- `finally` block now only handles the last surviving intermediate -### 2. Delayed Deletion (Video Sharing) -- `ZoomableContentView.kt:953-960` -- Uses `GlobalScope.launch(Dispatchers.IO)` with 1 minute delay -- Intentional: allows recipient app time to read the shared file -- **Exception: this delay is necessary and should not be shortened** +### 3. Abandoned compressed video (VideoCompressionHelper.kt) +- When compressed video is larger than original, the original is used instead +- The abandoned compressed file was leaked — now deleted before returning -### 3. Explicit Cleanup Calls (Voice) -- `VoiceAnonymizationController.deleteDistortedFiles()` at `L108-123` -- Called explicitly by `ShortNotePostViewModel` at `L1336` -- Good pattern: controller owns its temp files +## What Was Skipped -### 4. deleteOnExit() (Desktop) -- Used by `ClipboardPasteHandler` and `DesktopMediaCompressor` -- Files persist until JVM process exits -- Not ideal for long-running desktop app +### Desktop temp files (out of scope for this change) +- `ClipboardPasteHandler.kt` and `DesktopMediaCompressor.kt` use `deleteOnExit()` +- Files persist until JVM process exits — not ideal for a long-running desktop app +- `DesktopUploadOrchestrator.kt` uses bare `processedFile.delete()` with no error + handling or logging, diverging from the Android `deleteTempUri` pattern +- **Reason:** User requested Android-only focus for this iteration -## Opportunities for More Aggressive Cleanup +### Voice anonymization intermediates +- `VoiceAnonymizationController.deleteDistortedFiles()` is already reasonably aggressive +- Called explicitly by `ShortNotePostViewModel` after upload +- **Reason:** Already working well, no leak identified -### 1. Pipeline Stage Cleanup (UploadOrchestrator) +### Video sharing delay +- `ZoomableContentView.kt` uses a 1-minute delay before cleanup +- **Reason:** Intentional — receiving app needs time to read the shared file -**Current:** All intermediates deleted in `finally` after upload. - -**Proposed:** Delete each intermediate as soon as the next stage produces output. - -Example: after `MetadataStripper` produces a stripped file, delete the compressed file -from the previous stage immediately, rather than keeping it alive through upload + finally. - -### 2. Desktop Temp Files - -**Current:** `deleteOnExit()` — files persist for entire app session. - -**Proposed:** Delete immediately after upload completes, matching the Android -`UploadOrchestrator` pattern via `DesktopUploadOrchestrator`. - -### 3. MediaCompressorFileUtils Intermediate - -**Current:** `from()` creates a temp copy at `L41`. If compression produces a *different* -file, the original temp copy is orphaned until `UploadOrchestrator.finally`. - -**Proposed:** `MediaCompressor` should delete the `from()` temp file immediately after -compression succeeds (when the compressed file differs from the input). - -### 4. Voice Anonymization Intermediates - -**Current:** `deleteDistortedFiles()` called after upload. - -**Proposed:** Already reasonably aggressive. Could delete each intermediate distortion -result as soon as the next processing step completes, if there are multiple stages. +### Camera capture temp files +- `TakePicture.kt` creates temp files via camera provider +- **Reason:** Managed by the Android system/camera provider, not our responsibility ## Exception: Video Sharing Sharing a video to other Android apps requires the temporary file to remain accessible for at least 1 minute. The current `SHARED_VIDEO_CLEANUP_DELAY_MS` delay in -`ZoomableContentView.kt` handles this correctly and should **not** be made more aggressive. \ No newline at end of file +`ZoomableContentView.kt` handles this correctly and should **not** be made more aggressive. + +## Manual Test Plan + +### Setup + +Enable `adb logcat` filtering to observe cleanup behavior: + +```bash +adb logcat -s MediaCompressor:* UploadOrchestrator:* VideoCompressionHelper:* MetadataStripper:* +``` + +To verify temp files are actually being deleted, monitor the cache directory before and +after each test: + +```bash +adb shell "ls -la /data/data/com.vitorpamplona.amethyst/cache/ | grep -E 'stripped_|EncryptFiles|mp3_input|stripped_mp3|stripped_video|stripped_audio'" +``` + +### Test 1: Image upload with compression + +1. Open a new note compose screen +2. Attach a JPEG photo from the gallery +3. Set compression quality to Medium +4. Post the note +5. **Verify in logcat:** + - `MediaCompressor: Image compression success` appears + - `MediaCompressor: Failed to delete temp file` does NOT appear + - `UploadOrchestrator: Deleted temp file` appears (for the stripped file after upload) +6. **Verify in cache dir:** No `stripped_*.jpg` files remain after upload completes + +### Test 2: Image upload without compression + +1. Open a new note compose screen +2. Attach a JPEG photo from the gallery +3. Set compression quality to Uncompressed +4. Post the note +5. **Verify in logcat:** + - No `MediaCompressor` compression log appears + - `UploadOrchestrator: Deleted temp file` appears for the stripped file +6. **Verify in cache dir:** No `stripped_*` files remain + +### Test 3: Video upload with compression + +1. Open a new note compose screen +2. Attach a video from the gallery +3. Set compression quality to Medium +4. Post the note +5. **Verify in logcat:** + - `VideoCompressionHelper: Compression success` appears + - `UploadOrchestrator: Deleted temp file` appears +6. **Verify in cache dir:** No `stripped_video_*.mp4` files remain + +### Test 4: Video compression produces larger file + +1. Attach a very small or already-compressed video +2. Set compression to Low quality +3. Post the note +4. **Verify in logcat:** + - `VideoCompressionHelper: Compressed file larger than original. Using original.` appears + - The compressed file is deleted (no orphaned file in cache) + +### Test 5: Audio/voice message upload + +1. Record a voice message in a note or reply +2. Send it +3. **Verify in logcat:** + - `UploadOrchestrator: Deleted temp file` appears for the stripped audio +4. **Verify in cache dir:** No `stripped_audio_*.m4a` files remain + +### Test 6: MP3 upload + +1. Attach an MP3 file (with ID3 tags) from the file picker +2. Post the note +3. **Verify in logcat:** + - `MetadataStripper: Stripped ID3 tags from MP3` appears + - `UploadOrchestrator: Deleted temp file` appears +4. **Verify in cache dir:** No `mp3_input_*` or `stripped_mp3_*` files remain + +### Test 7: Encrypted file upload (NIP-44 DM) + +1. Open a DM conversation +2. Attach an image +3. Send the message (triggers encrypted upload path) +4. **Verify in logcat:** + - Compressed file is deleted after stripping + - Stripped file is deleted after encryption + - Encrypted file is deleted after upload + - Three separate `UploadOrchestrator: Deleted temp file` log lines appear +5. **Verify in cache dir:** No `stripped_*` or `EncryptFiles*` files remain + +### Test 8: Upload cancellation + +1. Open a new note compose screen +2. Attach a large image or video +3. Cancel the upload while compression or upload is in progress +4. **Verify in cache dir:** No temp files remain from the cancelled upload + +### Test 9: Video sharing to other apps + +1. Open a note with a video +2. Long-press or use the share button to share the video to another app +3. **Verify:** The receiving app successfully receives the video +4. **Verify:** After ~1 minute, the temp file in the share directory is cleaned up +5. **This test confirms the 1-minute delay was not broken by our changes** + +### Test 10: Image compression failure fallback + +1. Attach a GIF or SVG file (compression is skipped for these) +2. Post the note +3. **Verify:** Upload succeeds using the original file +4. **Verify in cache dir:** No orphaned temp files \ No newline at end of file From aa5dad9fac85193f65bf85fe89b9b7c53abd370b Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 25 Mar 2026 17:42:05 +0100 Subject: [PATCH 08/10] delete voice files on failuer --- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 9b41af0b5..fc68d621d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -697,6 +697,8 @@ open class ShortNotePostViewModel : // Abort if upload failed - don't post without voice data if (voiceMetadata == null) { Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post") + deleteVoiceLocalFile() + voiceAnonymization.deleteDistortedFiles() return } // Update default server if voice message was successfully uploaded @@ -1274,8 +1276,7 @@ open class ShortNotePostViewModel : private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.exists()) { - file.delete() + if (file.delete()) { Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}") } } catch (e: Exception) { From e2ac534be0d47474a68fa41a6c022b89d561ba62 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 25 Mar 2026 12:55:31 -0400 Subject: [PATCH 09/10] spotless --- .../com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c6a198378..c5b1d9c72 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -67,7 +67,7 @@ class ChaCha20Benchmark { chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } - + @Test fun encryptNative() { benchmarkRule.measureRepeated { From fc560d275258edd09e043f52e236e91ee4779dcf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 25 Mar 2026 13:22:02 -0400 Subject: [PATCH 10/10] reuse hex methods. --- .../quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 index a910aa004..276b18610 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.nip44Encryption.crypto +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -30,14 +32,9 @@ import kotlin.test.assertFailsWith * and libsodium test suite. */ class ChaCha20CoreTest { - private fun hex(s: String): ByteArray = - s - .replace(" ", "") - .chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() + private fun hex(s: String): ByteArray = s.replace(" ", "").hexToByteArray() - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private fun ByteArray.toHex(): String = toHexKey() // ===== RFC 8439 §2.3.2: ChaCha20 Block Function Test Vector ===== @Test