From 795aef8a77932e7319588e991e447953dd612189 Mon Sep 17 00:00:00 2001 From: SessionHero01 Date: Mon, 11 Aug 2025 14:57:41 +1000 Subject: [PATCH 1/3] Encrypt streaming support --- library/src/main/cpp/encryption.cpp | 131 ++++++++++++++++++ .../encrypt/DecryptionStream.kt | 107 ++++++++++++++ .../encrypt/EncryptionStream.kt | 98 +++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt create mode 100644 library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt diff --git a/library/src/main/cpp/encryption.cpp b/library/src/main/cpp/encryption.cpp index 1530c7b..05bfba9 100644 --- a/library/src/main/cpp/encryption.cpp +++ b/library/src/main/cpp/encryption.cpp @@ -156,4 +156,135 @@ Java_network_loki_messenger_libsession_1util_SessionEncrypt_calculateECHDAgreeme return util::bytes_from_span(env, shared_secret); }); +} + +extern "C" +JNIEXPORT jlong JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_EncryptionStream_00024Companion_createEncryptionStreamState( + JNIEnv *env, jobject thiz, jbyteArray javaKey, jbyteArray javaHeaderOut) { + JavaByteArrayRef key(env, javaKey); + JavaByteArrayRef headerOut(env, javaHeaderOut); + + if (headerOut.get().size() < crypto_secretstream_xchacha20poly1305_HEADERBYTES) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), + "Invalid headerOut: not enough space"); + return 0; + } + + if (key.get().size() != crypto_secretstream_xchacha20poly1305_KEYBYTES) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), + "Invalid key: unexpected size"); + return 0; + } + + auto state = std::make_unique(); + crypto_secretstream_xchacha20poly1305_init_push(state.get(), + reinterpret_cast(env->GetDirectBufferAddress(javaHeaderOut)), + key.get().data()); + + return reinterpret_cast(state.release()); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_EncryptionStream_00024Companion_encryptionStreamHeaderSize( + JNIEnv *env, jobject thiz) { + return static_cast(crypto_secretstream_xchacha20poly1305_HEADERBYTES); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_EncryptionStream_00024Companion_encryptionStreamChunkOverhead( + JNIEnv *env, jobject thiz) { + return static_cast(crypto_secretstream_xchacha20poly1305_ABYTES); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_EncryptionStream_00024Companion_encryptStreamPush( + JNIEnv *env, jobject thiz, jlong state_ptr, jbyteArray java_in_buf, jint in_buf_size, jbyteArray java_out_buf) { + auto state = reinterpret_cast(state_ptr); + + JavaByteArrayRef in_buf(env, java_in_buf); + JavaByteArrayRef out_buf(env, java_out_buf); + + unsigned long long cipher_len = out_buf.get().size(); + + if (crypto_secretstream_xchacha20poly1305_push( + state, + out_buf.get().data(), &cipher_len, // Cipher data out + in_buf.get().data(), in_buf_size, // Plaintext data in + nullptr, 0, // Additional data (not used here) + 0 // Tag (not used here, can be 0 for message) + )) { + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), + "Failed to push data into encryption stream"); + return 0; + } + + // Return the size of the ciphertext written to the output buffer + return cipher_len; +} + +extern "C" +JNIEXPORT void JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_EncryptionStream_00024Companion_destroyEncryptionStreamState( + JNIEnv *env, jobject thiz, jlong state_ptr) { + delete reinterpret_cast(state_ptr); +} + + +extern "C" +JNIEXPORT jlong JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_DecryptionStream_00024Companion_createDecryptionStreamState( + JNIEnv *env, jobject thiz, jbyteArray javaKey, jbyteArray javaHeader) { + JavaByteArrayRef key(env, javaKey); + JavaByteArrayRef header(env, javaHeader); + + if (header.get().size() < crypto_secretstream_xchacha20poly1305_HEADERBYTES) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), + "Invalid header: unexpected size"); + return 0; + } + + if (key.get().size() != crypto_secretstream_xchacha20poly1305_KEYBYTES) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), + "Invalid key: unexpected size"); + return 0; + } + + auto state = std::make_unique(); + + if (crypto_secretstream_xchacha20poly1305_init_pull(state.get(), header.get().data(), key.get().data()) != 0) { + env->ThrowNew(env->FindClass("java/lang/IllegalArgumentException"), + "Failed to initialize decryption stream state"); + return 0; + } + + return reinterpret_cast(state.release()); +} + +extern "C" +JNIEXPORT jint JNICALL +Java_network_loki_messenger_libsession_1util_encrypt_DecryptionStream_00024Companion_decryptionStreamPull( + JNIEnv *env, jobject thiz, jlong native_state_ptr, jbyteArray java_in_buf, jint in_buf_len, jbyteArray java_out_buf) { + JavaByteArrayRef out_buf(env, java_out_buf); + JavaByteArrayRef in_buf(env, java_in_buf); + + unsigned long long mlen = out_buf.get().size(); + unsigned char tag; + + if (crypto_secretstream_xchacha20poly1305_pull( + reinterpret_cast(native_state_ptr), + out_buf.get().data(), &mlen, // Plaintext data out + &tag, + in_buf.get().data(), in_buf_len, // Ciphertext data in + nullptr, 0 // Additional data (not used here) + )) { + env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), + "Failed to pull data from decryption stream"); + return 0; + } + + return mlen; } \ No newline at end of file diff --git a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt new file mode 100644 index 0000000..cf65fe3 --- /dev/null +++ b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt @@ -0,0 +1,107 @@ +package network.loki.messenger.libsession_util.encrypt + +import java.io.InputStream +import java.nio.ByteBuffer + +class DecryptionStream( + private val inStream: InputStream, + key: ByteArray, + private val autoClose: Boolean = true +) : InputStream() { + private val nativeStatePtr: Long + private val chunkSize: Int + private val cipherBuffer: ByteArray + private val plaintextBuffer: ByteBuffer + + init { + try { + // Read chunk size from the input stream + val chunkSizeBuffer = ByteBuffer.allocate(4) + check(inStream.read(chunkSizeBuffer.array(), 0, 4) == 4) { + "Failed to read the chunk size from the input stream." + } + + chunkSize = chunkSizeBuffer.int + + cipherBuffer = ByteArray((chunkSize + EncryptionStream.encryptionStreamChunkOverhead()).coerceAtLeast( + EncryptionStream.encryptionStreamHeaderSize() + )) + + plaintextBuffer = ByteBuffer.allocate(chunkSize) + + // Read the initial header from the input stream + check(inStream.read(cipherBuffer, 0, EncryptionStream.encryptionStreamHeaderSize()) + == EncryptionStream.encryptionStreamHeaderSize()) { + "Failed to read the initial header from the input stream." + } + + // Initialize the native decryption stream state + nativeStatePtr = createDecryptionStreamState(key, cipherBuffer) + } catch (e: Exception) { + if (autoClose) { + inStream.close() + } + + throw e + } + } + + protected fun finalize() { + EncryptionStream.destroyEncryptionStreamState(nativeStatePtr) + } + + private fun readChunkIfNeeded() { + if (!plaintextBuffer.hasRemaining()) { + // Read the next chunk of encrypted data from the input stream + val bytesRead = inStream.read(cipherBuffer) + if (bytesRead == -1) { + // End of stream reached + return + } + + // Decrypt the chunk and fill the plaintext buffer + plaintextBuffer.clear() + val decryptedBytes = decryptionStreamPull(nativeStatePtr, cipherBuffer, bytesRead, plaintextBuffer.array()) + if (decryptedBytes < 0) { + throw IllegalStateException("Decryption failed with error code: $decryptedBytes") + } + + plaintextBuffer.limit(decryptedBytes) + } + } + + override fun read(): Int { + readChunkIfNeeded() + + if (plaintextBuffer.hasRemaining()) { + return plaintextBuffer.get().toInt() + } + + return -1 // End of stream + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + readChunkIfNeeded() + + if (!plaintextBuffer.hasRemaining()) { + return -1 // End of stream + } + + val bytesToRead = minOf(len, plaintextBuffer.remaining()) + plaintextBuffer.get(b, off, bytesToRead) + return bytesToRead + } + + override fun close() { + if (autoClose) { + inStream.close() + } + + super.close() + } + + companion object { + private external fun createDecryptionStreamState(key: ByteArray, header: ByteArray): Long + private external fun decryptionStreamPull(nativeStatePtr: Long, inBuf: ByteArray, inLen: Int, outBuf: ByteArray): Int + } +} \ No newline at end of file diff --git a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt new file mode 100644 index 0000000..67cf959 --- /dev/null +++ b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt @@ -0,0 +1,98 @@ +package network.loki.messenger.libsession_util.encrypt + +import java.io.OutputStream +import java.nio.ByteBuffer + +class EncryptionStream( + private val out: OutputStream, + key: ByteArray, + val chunkSize: Int = 4096, + private val autoClose: Boolean = true +) : OutputStream() { + private val nativeStatePtr: Long + private val plaintextBuffer by lazy(LazyThreadSafetyMode.NONE) { + ByteBuffer.allocate(chunkSize) + } + + private val cipherBuffer = ByteBuffer.allocate(chunkSize + encryptionStreamChunkOverhead()) + + init { + try { + // Write the chunk size + out.write(ByteBuffer.allocate(4).putInt(chunkSize).array()) + + // Write initial header to the output stream + nativeStatePtr = createEncryptionStreamState(key, cipherBuffer.array()) + out.write(cipherBuffer.array(), 0, encryptionStreamHeaderSize()) + } catch (e: Exception) { + if (autoClose) { + out.close() + } + + throw e + } + } + + protected fun finalize() { + destroyEncryptionStreamState(nativeStatePtr) + } + + private fun flushWhenFull() { + if (!plaintextBuffer.hasRemaining()) { + flush() + } + } + + override fun write(b: Int) { + flushWhenFull() + plaintextBuffer.put(b.toByte()) + flushWhenFull() + } + + override fun write(b: ByteArray, off: Int, len: Int) { + var from = off + val to = off + len + while (from < to) { + flushWhenFull() + val toWrite = minOf(to - from, chunkSize - plaintextBuffer.position()).coerceAtLeast(0) + plaintextBuffer.put(b, from, toWrite) + from += toWrite + flushWhenFull() + } + } + + override fun flush() { + super.flush() + + // Flip the buffer to prepare for reading + plaintextBuffer.flip() + + if (plaintextBuffer.hasRemaining()) { + cipherBuffer.clear() + val cipherLength = encryptStreamPush(nativeStatePtr, plaintextBuffer.array(), plaintextBuffer.remaining(), cipherBuffer.array()) + out.write(cipherBuffer.array(), 0, cipherLength) + plaintextBuffer.clear() + } + + out.flush() + } + + override fun close() { + flush() + if (autoClose) { + out.close() + } + super.close() + } + + + companion object { + external fun encryptionStreamHeaderSize(): Int + external fun encryptionStreamChunkOverhead(): Int + + private external fun createEncryptionStreamState(key: ByteArray, headerOut: ByteArray): Long + private external fun encryptStreamPush(statePtr: Long, inBuf: ByteArray, inBufLen: Int, outBuf: ByteArray): Int + + external fun destroyEncryptionStreamState(statePtr: Long) + } +} \ No newline at end of file From b57a6bd3ae84798702896aa29f444ca076f8347f Mon Sep 17 00:00:00 2001 From: SessionHero01 Date: Mon, 11 Aug 2025 16:29:08 +1000 Subject: [PATCH 2/3] Add encryption stream --- gradle/libs.versions.toml | 4 ++ library/build.gradle.kts | 4 ++ .../libsession_util/EncryptionStreamTest.kt | 53 +++++++++++++++++++ library/src/main/cpp/encryption.cpp | 5 +- .../encrypt/DecryptionStream.kt | 1 + .../encrypt/EncryptionStream.kt | 4 ++ 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 library/src/androidTest/kotlin/network/loki/messenger/libsession_util/EncryptionStreamTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa222bd..241dc78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,10 @@ agp = "8.12.0" kotlin = "2.2.0" [libraries] +junit = { module = "junit:junit", version = "4.13.2" } +androidx-test-runner = { module = "androidx.test:runner", version = "1.7.0" } +androidx-test-rules = { module = "androidx.test:rules", version = "1.7.0" } +androidx-test-ext = { module = "androidx.test.ext:junit", version = "1.3.0" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 0d16ef5..23ed558 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -94,4 +94,8 @@ publishing { } dependencies { + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.ext) } diff --git a/library/src/androidTest/kotlin/network/loki/messenger/libsession_util/EncryptionStreamTest.kt b/library/src/androidTest/kotlin/network/loki/messenger/libsession_util/EncryptionStreamTest.kt new file mode 100644 index 0000000..566156c --- /dev/null +++ b/library/src/androidTest/kotlin/network/loki/messenger/libsession_util/EncryptionStreamTest.kt @@ -0,0 +1,53 @@ +package network.loki.messenger.libsession_util + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import network.loki.messenger.libsession_util.encrypt.DecryptionStream +import network.loki.messenger.libsession_util.encrypt.EncryptionStream +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.security.SecureRandom + +@RunWith(AndroidJUnit4::class) +class EncryptionStreamTest { + + private fun testEncryptionCase( + chunkSize: Int, + dataSize: Int, + ) { + try { + val key = ByteArray(32) + SecureRandom().nextBytes(key) + + val expectData = ByteArray(dataSize) + SecureRandom().nextBytes(expectData) + + val encrypted = ByteArrayOutputStream().let { outputStream -> + EncryptionStream(outputStream, key, chunkSize).use { + it.write(expectData) + } + + outputStream.toByteArray() + } + + val actualData = DecryptionStream( + ByteArrayInputStream(encrypted), + key + ).use { it.readAllBytes() } + + assertArrayEquals(expectData, actualData) + } catch (e: Exception) { + throw RuntimeException("Encryption/Decryption failed for chunkSize: $chunkSize, dataSize: $dataSize", e) + } + } + + @Test + fun shouldEncryptDecrypt() { + testEncryptionCase(chunkSize = 24, dataSize = 25) + testEncryptionCase(chunkSize = 24, dataSize = 24) + testEncryptionCase(chunkSize = 24, dataSize = 12) + testEncryptionCase(chunkSize = 24, dataSize = 48) + } +} \ No newline at end of file diff --git a/library/src/main/cpp/encryption.cpp b/library/src/main/cpp/encryption.cpp index 05bfba9..9ce79e0 100644 --- a/library/src/main/cpp/encryption.cpp +++ b/library/src/main/cpp/encryption.cpp @@ -179,7 +179,7 @@ Java_network_loki_messenger_libsession_1util_encrypt_EncryptionStream_00024Compa auto state = std::make_unique(); crypto_secretstream_xchacha20poly1305_init_push(state.get(), - reinterpret_cast(env->GetDirectBufferAddress(javaHeaderOut)), + headerOut.get().data(), key.get().data()); return reinterpret_cast(state.release()); @@ -272,12 +272,11 @@ Java_network_loki_messenger_libsession_1util_encrypt_DecryptionStream_00024Compa JavaByteArrayRef in_buf(env, java_in_buf); unsigned long long mlen = out_buf.get().size(); - unsigned char tag; if (crypto_secretstream_xchacha20poly1305_pull( reinterpret_cast(native_state_ptr), out_buf.get().data(), &mlen, // Plaintext data out - &tag, + nullptr, // Tag (not used here) in_buf.get().data(), in_buf_len, // Ciphertext data in nullptr, 0 // Additional data (not used here) )) { diff --git a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt index cf65fe3..72dd5f7 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt @@ -28,6 +28,7 @@ class DecryptionStream( )) plaintextBuffer = ByteBuffer.allocate(chunkSize) + plaintextBuffer.limit(0) // Should be empty initially // Read the initial header from the input stream check(inStream.read(cipherBuffer, 0, EncryptionStream.encryptionStreamHeaderSize()) diff --git a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt index 67cf959..5312ef7 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt @@ -87,6 +87,10 @@ class EncryptionStream( companion object { + init { + System.loadLibrary("session_util") + } + external fun encryptionStreamHeaderSize(): Int external fun encryptionStreamChunkOverhead(): Int From 48fd0ca7949f93f4dab906b2801cbb6be3d5ce83 Mon Sep 17 00:00:00 2001 From: SessionHero01 Date: Mon, 11 Aug 2025 16:33:32 +1000 Subject: [PATCH 3/3] Added comments --- .../libsession_util/encrypt/DecryptionStream.kt | 9 +++++++++ .../libsession_util/encrypt/EncryptionStream.kt | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt index 72dd5f7..262bd44 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/DecryptionStream.kt @@ -3,6 +3,15 @@ package network.loki.messenger.libsession_util.encrypt import java.io.InputStream import java.nio.ByteBuffer +/** + * A stream for decrypting data that was encrypted using [EncryptionStream]. + * + * Note that you don't need to specify the chunk size as it would have been encoded into the stream. + * + * @param inStream the underlying input stream to read encrypted data from. + * @param key the decryption key to use for decrypting data, must be 32 bytes long. + * @param autoClose whether to automatically close the underlying input stream when this stream is + */ class DecryptionStream( private val inStream: InputStream, key: ByteArray, diff --git a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt index 5312ef7..7ce564c 100644 --- a/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt +++ b/library/src/main/java/network/loki/messenger/libsession_util/encrypt/EncryptionStream.kt @@ -3,6 +3,18 @@ package network.loki.messenger.libsession_util.encrypt import java.io.OutputStream import java.nio.ByteBuffer +/** + * An [OutputStream] that encrypts data on the fly, using libsodium's encryption stream API. + * + * Note that you must ensure this stream is flushed at the end, or you can call [close] (which you + * should always do) to make sure all data is written out. + * + * @param out the underlying output stream to write encrypted data to. + * @param key the encryption key to use for encrypting data, must be 32 bytes long. + * @param chunkSize the size of chunks to write to the output stream. The bigger the chunk size, + * the more memory is used, but the less overhead there is for each write operation. + * @param autoClose whether to automatically close the underlying output stream when this stream is closed. + */ class EncryptionStream( private val out: OutputStream, key: ByteArray,