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 ee5ef4e72..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 @@ -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.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?, @@ -341,12 +363,20 @@ 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) + } - 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) + if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri) + + 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 { + deleteTempUri(finalUri, uri) } } @@ -368,14 +398,23 @@ 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) - 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 { + 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, 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) { 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 407e8292f..c5b1d9c72 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -55,9 +55,16 @@ class ChaCha20Benchmark { } @Test - fun encryptLibSodium() { + fun encrypt() { benchmarkRule.measureRepeated { - chaCha.encryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + chaCha.encrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) + } + } + + @Test + fun decrypt() { + benchmarkRule.measureRepeated { + chaCha.decrypt(padded, messageKeys.chachaNonce, messageKeys.chachaKey) } } @@ -68,13 +75,6 @@ class ChaCha20Benchmark { } } - @Test - fun decryptLibSodium() { - benchmarkRule.measureRepeated { - chaCha.decryptLibSodium(padded, messageKeys.chachaNonce, messageKeys.chachaKey) - } - } - @Test fun decryptNative() { benchmarkRule.measureRepeated { diff --git a/docs/temp-file-cleanup-analysis.md b/docs/temp-file-cleanup-analysis.md new file mode 100644 index 000000000..d9acd1ea5 --- /dev/null +++ b/docs/temp-file-cleanup-analysis.md @@ -0,0 +1,201 @@ +# 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 | 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) + +| 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. 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; 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 the last remaining intermediate +``` + +## What Was Fixed + +### 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) + +### 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 + +### 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 + +## What Was Skipped + +### 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 + +### Voice anonymization intermediates +- `VoiceAnonymizationController.deleteDistortedFiles()` is already reasonably aggressive +- Called explicitly by `ShortNotePostViewModel` after upload +- **Reason:** Already working well, no leak identified + +### Video sharing delay +- `ZoomableContentView.kt` uses a 1-minute delay before cleanup +- **Reason:** Intentional — receiving app needs time to read the shared file + +### 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. + +## 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db9af4a9e..b82ca191e 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" @@ -145,7 +142,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" } @@ -154,8 +150,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 1fed30907..5b49c924f 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -58,70 +58,8 @@ 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) - } - } - } iosArm64 { - binaries.all { - linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium") - } binaries.framework { baseName = xcfName isStatic = true @@ -130,9 +68,6 @@ kotlin { } iosSimulatorArm64 { - binaries.all { - linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator") - } binaries.framework { baseName = xcfName isStatic = true @@ -236,9 +171,6 @@ kotlin { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) - // LibSodium for ChaCha encryption (NIP-44) - implementation(libs.lazysodium.java) - implementation(libs.jna) } } @@ -261,9 +193,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") } } @@ -275,9 +204,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) @@ -297,9 +223,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 7dd451ed5..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,33 +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 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..66b7e677a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Core.kt @@ -0,0 +1,363 @@ +/* + * 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 and serialize + val output = ByteArray(64) + for (i in 0..15) { + (working[i] + initial[i]).intToLittleEndian(output, i * 4) + } + return output + } + + /** + * 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 + * @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) + 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) { + 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) { + 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() + } + } + } + + 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 + } + + /** + * 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. + * + * @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 (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) + 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 --- + +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) + +internal 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..cf99fca47 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/Poly1305.kt @@ -0,0 +1,247 @@ +/* + * 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..4e8ac5a9e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Poly1305.kt @@ -0,0 +1,171 @@ +/* + * 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 (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 (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) + + // Step 5: Compute tag + val tag = computeTag(polyKey, ad, ciphertext) + + // Step 6: Return ciphertext || tag (single allocation) + val result = ByteArray(ciphertext.size + TAG_SIZE) + ciphertext.copyInto(result) + tag.copyInto(result, ciphertext.size) + return result + } + + /** + * 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 (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 (only 32 bytes, not full 64-byte block) + val polyKey = ChaCha20Core.chaCha20PolyKey(subKey, subNonce) + + // 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..276b18610 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20CoreTest.kt @@ -0,0 +1,254 @@ +/* + * 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 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 +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(" ", "").hexToByteArray() + + private fun ByteArray.toHex(): String = toHexKey() + + // ===== 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..71c14f07e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/XChaCha20Test.kt @@ -0,0 +1,119 @@ +/* + * 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") - } -}