diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt new file mode 100644 index 000000000..ed80e2f77 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/DMDecryptionTest.kt @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2024 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.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM +import junit.framework.TestCase.assertEquals +import okhttp3.Request +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DMDecryptionTest { + val okHttp = HttpClientManager.getHttpClient(false) + + val url = "https://cdn.satellite.earth/812fd4cf9d4d4b59c141ecd6a6c08c7571b5872237ad6477916cb2d119b5cacd" + val cipher = + AESGCM( + "7b184b3849e161027bab1aac9f2d96eb22bfe02c8dc34cad5d2cc436a64f191d".hexToByteArray(), + "3936923383652e3f0d72bfd40568b435".hexToByteArray(), + ) + val decryptedSize = 1277122 + val expectedMimeType = "video/mp4" + + @Test + fun runDownloadAndDecryptVideo() { + HttpClientManager.addCipherToCache(url, cipher, "video/mp4") + + val request = + Request + .Builder() + .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") + .url(url) + .get() + .build() + + okHttp.newCall(request).execute().use { + assertEquals(decryptedSize, it.body.bytes().size) + assertEquals(expectedMimeType, it.body.contentType().toString()) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt index 85447bfe1..789e16c1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptedBlobInterceptor.kt @@ -20,51 +20,85 @@ */ package com.vitorpamplona.amethyst.service.okhttp -import android.util.Log import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM -import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody class EncryptedBlobInterceptor( val cache: EncryptionKeyCache, ) : Interceptor { - fun Response.decrypt(cipher: NostrCipher): Response { + private fun Response.decryptOrNullWithErrorCorrection(info: DecryptInformation): Response? { val body = peekBody(Long.MAX_VALUE) - val decryptedBytes = cipher.decrypt(body.bytes()) - val newBody = decryptedBytes.toResponseBody(body.contentType()) - return newBuilder().body(newBody).build() - } - fun Response.decryptOrNull(cipher: NostrCipher): Response? = - try { - decrypt(cipher) - } catch (e: Exception) { - Log.w("EncryptedBlobInterceptor", "Failed to decrypt", e) - null + // Only tries to decrypt if the content-type is a byte array + if (body.contentType().toString() != "application/octet-stream") { + return null } - private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? { - return decryptOrNull(cipher) ?: return if (cipher is AESGCM) { - decryptOrNull(cipher.copyUsingUTF8Nonce()) - } else { - null + val bytes = body.bytes() + + // Tries the correct way first + // if it fails, tries to decrypt as UTF8 nonce, which was how + // 0xChat started encrypting + val decrypted = + info.cipher.decryptOrNull(bytes) ?: if (info.cipher is AESGCM) { + info.cipher.copyUsingUTF8Nonce().decryptOrNull(bytes) + } else { + null + } + + if (decrypted == null) { + return null } + + return newBuilder() + .apply { + body( + decrypted.toResponseBody( + info.mimeType?.toMediaTypeOrNull() ?: body.contentType(), + ), + ) + // removes hints that would make the app requrest partial byte arrays + // in videos, which are impossible to decrypt. + removeHeader("accept-ranges") + // Fixes the size of the body array + header("content-length", decrypted.size.toString()) + // Trusts the mimetype from the event is better than the mimetype from the server + info.mimeType?.let { header("content-type", it) } + }.build() } override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - val response = chain.proceed(request) + val encryptionKeys = cache.get(request.url.toString()) - val cipher = cache.get(request.url.toString()) ?: return response + // We cannot use Range requests (partial byte arrays) + // in encrypted payloads because we won't be able to + // decrypt partial byte arrays. + val newRequest = + if (encryptionKeys != null) { + request + .newBuilder() + .removeHeader("Range") + .build() + } else { + request + } + + val response = chain.proceed(newRequest) + + if (encryptionKeys == null) { + return response + } if (response.isSuccessful) { - return response.decryptOrNullWithErrorCorrection(cipher) ?: response + return response.decryptOrNullWithErrorCorrection(encryptionKeys) ?: response } else { // Log redirections to be able to use the cipher. response.header("Location")?.let { - cache.add(it, cipher) + cache.add(it, encryptionKeys) } } return response diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt index cd0fa1770..6677491e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/EncryptionKeyCache.kt @@ -30,16 +30,27 @@ import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher * This class serves as a key cache to decrypt the body of HTTP calls that need it. */ class EncryptionKeyCache { - val cache = LruCache(100) + val cache = LruCache(100) + + fun add( + url: String?, + decryptInformation: DecryptInformation, + ) { + if (cache.get(url) == null) { + cache.put(url, decryptInformation) + } + } fun add( url: String?, cipher: NostrCipher, - ) { - if (cache.get(url) == null) { - cache.put(url, cipher) - } - } + expectedMimeType: String?, + ) = add(url, DecryptInformation(cipher, expectedMimeType)) - fun get(url: String): NostrCipher? = cache.get(url) + fun get(url: String): DecryptInformation? = cache.get(url) } + +class DecryptInformation( + val cipher: NostrCipher, + val mimeType: String?, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt index 85f418a1b..f1ffc9174 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/HttpClientManager.kt @@ -124,5 +124,6 @@ object HttpClientManager { fun addCipherToCache( url: String, cipher: NostrCipher, - ) = cache.add(url, cipher) + expectedMimeType: String?, + ) = cache.add(url, cipher, expectedMimeType) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index 6bf5b480d..c005df88c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -58,17 +58,18 @@ object MediaSaverToDisk { if (videoUri != null) { if (!videoUri.startsWith("file")) { downloadAndSave( + url = videoUri, + mimeType = mimeType, context = localContext, forceProxy = forceProxy, - url = videoUri, onSuccess = onSuccess, onError = onError, ) } else { save( - context = localContext, localFile = videoUri.toUri().toFile(), mimeType = mimeType, + context = localContext, onSuccess = onSuccess, onError = onError, ) @@ -83,6 +84,7 @@ object MediaSaverToDisk { */ fun downloadAndSave( url: String, + mimeType: String?, forceProxy: Boolean, context: Context, onSuccess: () -> Any?, @@ -121,9 +123,16 @@ object MediaSaverToDisk { val contentType = response.header("Content-Type") checkNotNull(contentType) { "Can't find out the content type" } + val realType = + if (mimeType != null && contentType == "application/octet-stream") { + mimeType + } else { + contentType + } + saveContentQ( displayName = File(url).nameWithoutExtension, - contentType = contentType, + contentType = realType, contentSource = response.body.source(), contentResolver = context.contentResolver, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 17d98c244..c99dc3cb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -308,6 +308,7 @@ private fun saveMediaToGallery( MediaSaverToDisk.downloadAndSave( content.url, + mimeType = content.mimeType, forceProxy = useTor, localContext, onSuccess = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt index f3413aff7..fe6c06844 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt @@ -113,13 +113,13 @@ fun RenderEncryptedFile( val algo = noteEvent.algo() val key = noteEvent.key() val nonce = noteEvent.nonce() + val mimeType = noteEvent.mimeType() if (algo == AESGCM.NAME && key != null && nonce != null) { - HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce)) + HttpClientManager.addCipherToCache(noteEvent.content, AESGCM(key, nonce), mimeType) val content by remember(noteEvent) { - val isImage = noteEvent.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) - val mimeType = noteEvent.mimeType() + val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content) mutableStateOf( if (isImage) { diff --git a/quartz/src/androidTest/assets/trouble_video b/quartz/src/androidTest/assets/trouble_video new file mode 100644 index 000000000..401d5e1df Binary files /dev/null and b/quartz/src/androidTest/assets/trouble_video differ diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt index 5214c0e05..ce15430c9 100644 --- a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip17Dm/AESGCMTest.kt @@ -54,4 +54,22 @@ class AESGCMTest { assertEquals(44201, decrypted.size) } + + @Test + fun videoTest2() { + val myCipher = + AESGCM( + "373d19850ebc8ed5b0fefcca5cd6f27fde9cb6ac54fd32f6b4fad9d68ebe8ee0".hexToByteArray(), + "95e67b6874784a54299b58b8990499bd".hexToByteArray(), + ) + + val encrypted = + getInstrumentation().context.assets.open("trouble_video").use { + it.readAllBytes() + } + + val decrypted = myCipher.decrypt(encrypted) + + assertEquals(1277122, decrypted.size) + } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt index 66e6b1bdb..0a2c9b04f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip04Dm/crypto/AESCBC.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.nip04Dm.crypto +import android.util.Log import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher import com.vitorpamplona.quartz.utils.RandomInstance +import java.security.GeneralSecurityException import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @@ -50,6 +52,14 @@ class AESCBC( doFinal(bytesToDecrypt) } + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (e: GeneralSecurityException) { + Log.w("AESCBC", "Failed to decrypt", e) + null + } + companion object { const val NAME = "aes-cbc" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt index 9007b5bac..391cda120 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/AESGCM.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.quartz.nip17Dm.files.encryption +import android.util.Log import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.RandomInstance +import java.security.GeneralSecurityException import javax.crypto.Cipher import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.SecretKeySpec @@ -56,6 +58,14 @@ class AESGCM( doFinal(bytesToDecrypt) } + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (e: GeneralSecurityException) { + Log.w("AESGCM", "Failed to decrypt", e) + null + } + companion object { const val NAME = "aes-gcm" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt index 0931f0396..d01ff733c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/files/encryption/NostrCipher.kt @@ -26,4 +26,6 @@ interface NostrCipher { fun encrypt(bytesToEncrypt: ByteArray): ByteArray fun decrypt(bytesToDecrypt: ByteArray): ByteArray + + fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? }