Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* '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:
+9
-4
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.utils.Log
|
|||||||
import id.zelory.compressor.Compressor
|
import id.zelory.compressor.Compressor
|
||||||
import id.zelory.compressor.constraint.default
|
import id.zelory.compressor.constraint.default
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MediaCompressorResult(
|
class MediaCompressorResult(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
@@ -89,19 +90,23 @@ class MediaCompressor {
|
|||||||
else -> 60
|
else -> 60
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tempFile: File? = null
|
||||||
return try {
|
return try {
|
||||||
Log.d("MediaCompressor", "Using image compression $mediaQuality")
|
Log.d("MediaCompressor", "Using image compression $mediaQuality")
|
||||||
val tempFile = MediaCompressorFileUtils.from(uri, context)
|
tempFile = MediaCompressorFileUtils.from(uri, context)
|
||||||
val compressedImageFile =
|
val compressedImageFile =
|
||||||
Compressor.compress(context, tempFile) {
|
Compressor.compress(context, tempFile) {
|
||||||
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
|
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())
|
MediaCompressorResult(compressedImageFile.toUri(), MimeTypes.IMAGE_JPEG, compressedImageFile.length())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
|
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
e.printStackTrace()
|
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
|
||||||
|
tempFile?.delete()
|
||||||
MediaCompressorResult(uri, contentType, null)
|
MediaCompressorResult(uri, contentType, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-10
@@ -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.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.File
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
sealed class UploadingState {
|
sealed class UploadingState {
|
||||||
@@ -324,6 +326,26 @@ class UploadOrchestrator {
|
|||||||
return strippingResult.uri
|
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(
|
suspend fun upload(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String?,
|
mimeType: String?,
|
||||||
@@ -341,12 +363,20 @@ class UploadOrchestrator {
|
|||||||
|
|
||||||
val finalUri =
|
val finalUri =
|
||||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
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) {
|
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
|
||||||
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)
|
try {
|
||||||
ServerType.Blossom -> uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, context)
|
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 =
|
val finalUri =
|
||||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
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)
|
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
|
||||||
|
deleteTempUri(finalUri, uri)
|
||||||
|
|
||||||
return when (server.type) {
|
try {
|
||||||
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
|
return when (server.type) {
|
||||||
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, context)
|
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
|
||||||
ServerType.Blossom -> uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -233,6 +233,7 @@ object VideoCompressionHelper {
|
|||||||
|
|
||||||
// Sanity check: compression not smaller than original
|
// Sanity check: compression not smaller than original
|
||||||
if (originalSize > 0 && size >= originalSize) {
|
if (originalSize > 0 && size >= originalSize) {
|
||||||
|
File(path).delete()
|
||||||
applicationContext.notifyUser(
|
applicationContext.notifyUser(
|
||||||
"Compressed file larger than original. Using original.",
|
"Compressed file larger than original. Using original.",
|
||||||
Log.WARN,
|
Log.WARN,
|
||||||
|
|||||||
+3
-2
@@ -697,6 +697,8 @@ open class ShortNotePostViewModel :
|
|||||||
// Abort if upload failed - don't post without voice data
|
// Abort if upload failed - don't post without voice data
|
||||||
if (voiceMetadata == null) {
|
if (voiceMetadata == null) {
|
||||||
Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post")
|
Log.w("ShortNotePostViewModel", "Voice upload failed, aborting post")
|
||||||
|
deleteVoiceLocalFile()
|
||||||
|
voiceAnonymization.deleteDistortedFiles()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update default server if voice message was successfully uploaded
|
// Update default server if voice message was successfully uploaded
|
||||||
@@ -1274,8 +1276,7 @@ open class ShortNotePostViewModel :
|
|||||||
private fun deleteVoiceLocalFile() {
|
private fun deleteVoiceLocalFile() {
|
||||||
voiceLocalFile?.let { file ->
|
voiceLocalFile?.let { file ->
|
||||||
try {
|
try {
|
||||||
if (file.exists()) {
|
if (file.delete()) {
|
||||||
file.delete()
|
|
||||||
Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}")
|
Log.d("ShortNotePostViewModel", "Deleted voice file: ${file.absolutePath}")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
+9
-9
@@ -55,9 +55,16 @@ class ChaCha20Benchmark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encryptLibSodium() {
|
fun encrypt() {
|
||||||
benchmarkRule.measureRepeated {
|
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
|
@Test
|
||||||
fun decryptNative() {
|
fun decryptNative() {
|
||||||
benchmarkRule.measureRepeated {
|
benchmarkRule.measureRepeated {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -25,7 +25,6 @@ fragmentKtx = "1.8.9"
|
|||||||
gms = "4.4.4"
|
gms = "4.4.4"
|
||||||
jacksonModuleKotlin = "2.21.1"
|
jacksonModuleKotlin = "2.21.1"
|
||||||
javaKeyring = "1.0.4"
|
javaKeyring = "1.0.4"
|
||||||
jna = "5.18.1"
|
|
||||||
jtorctl = "0.4.5.7"
|
jtorctl = "0.4.5.7"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kchesslib = "1.0.5"
|
kchesslib = "1.0.5"
|
||||||
@@ -34,8 +33,6 @@ kotlinxCollectionsImmutable = "0.4.0"
|
|||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
kotlinxSerialization = "1.10.0"
|
kotlinxSerialization = "1.10.0"
|
||||||
languageId = "17.0.6"
|
languageId = "17.0.6"
|
||||||
lazysodiumAndroid = "5.2.0"
|
|
||||||
lazysodiumJava = "5.2.0"
|
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
lightcompressor-enhanced = "1.6.0"
|
lightcompressor-enhanced = "1.6.0"
|
||||||
markdown = "f92ef49c9d"
|
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" }
|
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" }
|
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" }
|
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" }
|
jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" }
|
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-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-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", 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-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 = { 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" }
|
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
|
||||||
|
|||||||
@@ -58,70 +58,8 @@ kotlin {
|
|||||||
// project can be found here:
|
// project can be found here:
|
||||||
// https://developer.android.com/kotlin/multiplatform/migrate
|
// https://developer.android.com/kotlin/multiplatform/migrate
|
||||||
val xcfName = "quartz-kmpKit"
|
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 {
|
iosArm64 {
|
||||||
binaries.all {
|
|
||||||
linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium")
|
|
||||||
}
|
|
||||||
binaries.framework {
|
binaries.framework {
|
||||||
baseName = xcfName
|
baseName = xcfName
|
||||||
isStatic = true
|
isStatic = true
|
||||||
@@ -130,9 +68,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iosSimulatorArm64 {
|
iosSimulatorArm64 {
|
||||||
binaries.all {
|
|
||||||
linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator")
|
|
||||||
}
|
|
||||||
binaries.framework {
|
binaries.framework {
|
||||||
baseName = xcfName
|
baseName = xcfName
|
||||||
isStatic = true
|
isStatic = true
|
||||||
@@ -236,9 +171,6 @@ kotlin {
|
|||||||
// Bitcoin secp256k1 bindings
|
// Bitcoin secp256k1 bindings
|
||||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
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
|
// Bitcoin secp256k1 bindings to Android
|
||||||
api(libs.secp256k1.kmp.jni.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
|
// Bitcoin secp256k1 bindings
|
||||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
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
|
// SQLite bundled driver for Host tests
|
||||||
implementation(libs.androidx.sqlite.bundled.jvm)
|
implementation(libs.androidx.sqlite.bundled.jvm)
|
||||||
@@ -297,9 +223,6 @@ kotlin {
|
|||||||
// Bitcoin secp256k1 bindings to Android
|
// Bitcoin secp256k1 bindings to Android
|
||||||
api(libs.secp256k1.kmp.jni.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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,32 +16,10 @@
|
|||||||
# preserve access to native classses
|
# preserve access to native classses
|
||||||
-keep class fr.acinq.secp256k1.** { *; }
|
-keep class fr.acinq.secp256k1.** { *; }
|
||||||
|
|
||||||
# JNA For Libsodium
|
|
||||||
-keep class com.goterl.lazysodium.** { *; }
|
|
||||||
|
|
||||||
# libscrypt
|
# libscrypt
|
||||||
-keep class com.lambdaworks.codec.** { *; }
|
-keep class com.lambdaworks.codec.** { *; }
|
||||||
-keep class com.lambdaworks.crypto.** { *; }
|
-keep class com.lambdaworks.crypto.** { *; }
|
||||||
-keep class com.lambdaworks.jni.** { *; }
|
-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
|
# JSON parsing
|
||||||
-keep class com.vitorpamplona.quartz.** { *; }
|
-keep class com.vitorpamplona.quartz.** { *; }
|
||||||
Vendored
-22
@@ -30,33 +30,11 @@
|
|||||||
# preserve access to native classses
|
# preserve access to native classses
|
||||||
-keep class fr.acinq.secp256k1.** { *; }
|
-keep class fr.acinq.secp256k1.** { *; }
|
||||||
|
|
||||||
# JNA For Libsodium
|
|
||||||
-keep class com.goterl.lazysodium.** { *; }
|
|
||||||
|
|
||||||
# libscrypt
|
# libscrypt
|
||||||
-keep class com.lambdaworks.codec.** { *; }
|
-keep class com.lambdaworks.codec.** { *; }
|
||||||
-keep class com.lambdaworks.crypto.** { *; }
|
-keep class com.lambdaworks.crypto.** { *; }
|
||||||
-keep class com.lambdaworks.jni.** { *; }
|
-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
|
# JSON parsing
|
||||||
-keep class com.vitorpamplona.quartz.** { *; }
|
-keep class com.vitorpamplona.quartz.** { *; }
|
||||||
|
|
||||||
|
|||||||
-131
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-19
@@ -20,33 +20,16 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip44Encryption.crypto
|
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 {
|
class ChaCha20 {
|
||||||
fun encrypt(
|
fun encrypt(
|
||||||
message: ByteArray,
|
message: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
key: ByteArray,
|
key: ByteArray,
|
||||||
) = encryptLibSodium(message, nonce, key)
|
) = ChaCha20Core.chaCha20Xor(message, key, nonce, counter = 0)
|
||||||
|
|
||||||
fun decrypt(
|
fun decrypt(
|
||||||
message: ByteArray,
|
message: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
key: ByteArray,
|
key: ByteArray,
|
||||||
) = decryptLibSodium(message, nonce, key)
|
) = ChaCha20Core.chaCha20Xor(message, key, nonce, counter = 0)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+363
@@ -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()
|
||||||
|
}
|
||||||
+247
@@ -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)
|
||||||
+171
@@ -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
|
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(
|
fun cryptoAeadXChaCha20Poly1305IetfDecrypt(
|
||||||
message: ByteArray,
|
message: ByteArray,
|
||||||
nSec: ByteArray,
|
nSec: ByteArray,
|
||||||
@@ -28,7 +35,14 @@ expect object LibSodiumInstance {
|
|||||||
ad: ByteArray,
|
ad: ByteArray,
|
||||||
nPub: ByteArray,
|
nPub: ByteArray,
|
||||||
k: ByteArray,
|
k: ByteArray,
|
||||||
): Boolean
|
): Boolean =
|
||||||
|
try {
|
||||||
|
val plaintext = XChaCha20Poly1305.decrypt(ciphertext, ad, nPub, k)
|
||||||
|
plaintext.copyInto(message)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fun cryptoAeadXChaCha20Poly1305IetfEncrypt(
|
fun cryptoAeadXChaCha20Poly1305IetfEncrypt(
|
||||||
ciphertext: ByteArray,
|
ciphertext: ByteArray,
|
||||||
@@ -37,18 +51,24 @@ expect object LibSodiumInstance {
|
|||||||
nSec: ByteArray,
|
nSec: ByteArray,
|
||||||
nPub: ByteArray,
|
nPub: ByteArray,
|
||||||
k: ByteArray,
|
k: ByteArray,
|
||||||
): Boolean
|
): Boolean =
|
||||||
|
try {
|
||||||
|
val result = XChaCha20Poly1305.encrypt(message, ad, nPub, k)
|
||||||
|
result.copyInto(ciphertext)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fun cryptoStreamChaCha20IetfXor(
|
fun cryptoStreamChaCha20IetfXor(
|
||||||
message: ByteArray,
|
message: ByteArray,
|
||||||
nonce: ByteArray?,
|
nonce: ByteArray?,
|
||||||
key: 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(
|
fun cryptoStreamXChaCha20Xor(
|
||||||
messageBytes: ByteArray,
|
messageBytes: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
key: ByteArray,
|
key: ByteArray,
|
||||||
): ByteArray
|
): ByteArray = ChaCha20Core.xChaCha20Xor(messageBytes, nonce, key)
|
||||||
}
|
}
|
||||||
|
|||||||
+254
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user