* 'main' of https://github.com/vitorpamplona/amethyst:
  reuse hex methods.
  spotless
  delete voice files on failuer
  added progress and test plan to doc
  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
  analysis for temporary files
  fix: clean up temp files after upload completes
  perf: optimize ChaCha20 and XChaCha20-Poly1305 for speed
  style: apply spotless formatting to ChaCha20/Poly1305 sources
  feat: replace libsodium with pure Kotlin ChaCha20/Poly1305 implementation
This commit is contained in:
Vitor Pamplona
2026-03-25 15:20:28 -04:00
20 changed files with 1454 additions and 542 deletions
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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,
@@ -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) {
@@ -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 {
+201
View File
@@ -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
-6
View File
@@ -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" }
-77
View File
@@ -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")
}
}
-22
View File
@@ -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.** { *; }
-22
View File
@@ -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.** { *; }
@@ -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")
}
}
@@ -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)
}
@@ -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()
}
@@ -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)
@@ -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()
}
}
@@ -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)
}
@@ -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<IllegalStateException> {
XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
}
}
@Test
fun testXChaCha20Poly1305_RoundTrip() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = hex("feedfacedeadbeef")
val plaintext = "Hello, Nostr! This is a round-trip test.".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
@Test
fun testXChaCha20Poly1305_EmptyPlaintext() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = hex("feedface")
val plaintext = ByteArray(0)
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
assertEquals(16, encrypted.size) // Just the tag
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
@Test
fun testXChaCha20Poly1305_EmptyAD() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val ad = ByteArray(0)
val plaintext = "No additional data".encodeToByteArray()
val encrypted = XChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
val decrypted = XChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
assertContentEquals(plaintext, decrypted)
}
// ===== ChaCha20 stream cipher (counter=0) for NIP-44v2 compatibility =====
@Test
fun testChaCha20Xor_SymmetricEncryptDecrypt() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000000090000004a00000000")
val plaintext = "Symmetric cipher test".encodeToByteArray()
val encrypted = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter = 0)
val decrypted = ChaCha20Core.chaCha20Xor(encrypted, key, nonce, counter = 0)
assertContentEquals(plaintext, decrypted)
}
// ===== XChaCha20 stream cipher symmetry =====
@Test
fun testXChaCha20Xor_SymmetricEncryptDecrypt() {
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
val nonce = hex("000102030405060708090a0b0c0d0e0f1011121314151617")
val plaintext = "XChaCha20 symmetric test".encodeToByteArray()
val encrypted = ChaCha20Core.xChaCha20Xor(plaintext, nonce, key)
val decrypted = ChaCha20Core.xChaCha20Xor(encrypted, nonce, key)
assertContentEquals(plaintext, decrypted)
}
}
@@ -0,0 +1,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")
}
}
}
@@ -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<UByteVar>
@@ -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")
}
}