Fixes bug on saving Encrypted files on DMs.

Fixes bug when using partial downloads to display videos on DMs.
This commit is contained in:
Vitor Pamplona 2025-02-25 16:43:15 -05:00
parent d5d289a834
commit e6cd3a99c5
12 changed files with 194 additions and 36 deletions

View File

@ -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())
}
}
}

View File

@ -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

View File

@ -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<String, NostrCipher>(100)
val cache = LruCache<String, DecryptInformation>(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?,
)

View File

@ -124,5 +124,6 @@ object HttpClientManager {
fun addCipherToCache(
url: String,
cipher: NostrCipher,
) = cache.add(url, cipher)
expectedMimeType: String?,
) = cache.add(url, cipher, expectedMimeType)
}

View File

@ -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,
)

View File

@ -308,6 +308,7 @@ private fun saveMediaToGallery(
MediaSaverToDisk.downloadAndSave(
content.url,
mimeType = content.mimeType,
forceProxy = useTor,
localContext,
onSuccess = {

View File

@ -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<BaseMediaContent>(
if (isImage) {

Binary file not shown.

View File

@ -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)
}
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -26,4 +26,6 @@ interface NostrCipher {
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray?
}