mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-17 21:31:57 +01:00
Fixes bug on saving Encrypted files on DMs.
Fixes bug when using partial downloads to display videos on DMs.
This commit is contained in:
parent
d5d289a834
commit
e6cd3a99c5
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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?,
|
||||
)
|
||||
|
@ -124,5 +124,6 @@ object HttpClientManager {
|
||||
fun addCipherToCache(
|
||||
url: String,
|
||||
cipher: NostrCipher,
|
||||
) = cache.add(url, cipher)
|
||||
expectedMimeType: String?,
|
||||
) = cache.add(url, cipher, expectedMimeType)
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -308,6 +308,7 @@ private fun saveMediaToGallery(
|
||||
|
||||
MediaSaverToDisk.downloadAndSave(
|
||||
content.url,
|
||||
mimeType = content.mimeType,
|
||||
forceProxy = useTor,
|
||||
localContext,
|
||||
onSuccess = {
|
||||
|
@ -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) {
|
||||
|
BIN
quartz/src/androidTest/assets/trouble_video
Normal file
BIN
quartz/src/androidTest/assets/trouble_video
Normal file
Binary file not shown.
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -26,4 +26,6 @@ interface NostrCipher {
|
||||
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
|
||||
|
||||
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
|
||||
|
||||
fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray?
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user