Fixes bug on saving Encrypted files on DMs.
Fixes bug when using partial downloads to display videos on DMs.
This commit is contained in:
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-22
@@ -20,51 +20,85 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.okhttp
|
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.AESGCM
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
|
||||||
class EncryptedBlobInterceptor(
|
class EncryptedBlobInterceptor(
|
||||||
val cache: EncryptionKeyCache,
|
val cache: EncryptionKeyCache,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
fun Response.decrypt(cipher: NostrCipher): Response {
|
private fun Response.decryptOrNullWithErrorCorrection(info: DecryptInformation): Response? {
|
||||||
val body = peekBody(Long.MAX_VALUE)
|
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? =
|
// Only tries to decrypt if the content-type is a byte array
|
||||||
try {
|
if (body.contentType().toString() != "application/octet-stream") {
|
||||||
decrypt(cipher)
|
return null
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("EncryptedBlobInterceptor", "Failed to decrypt", e)
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Response.decryptOrNullWithErrorCorrection(cipher: NostrCipher): Response? {
|
val bytes = body.bytes()
|
||||||
return decryptOrNull(cipher) ?: return if (cipher is AESGCM) {
|
|
||||||
decryptOrNull(cipher.copyUsingUTF8Nonce())
|
// Tries the correct way first
|
||||||
} else {
|
// if it fails, tries to decrypt as UTF8 nonce, which was how
|
||||||
null
|
// 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 {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val request = chain.request()
|
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) {
|
if (response.isSuccessful) {
|
||||||
return response.decryptOrNullWithErrorCorrection(cipher) ?: response
|
return response.decryptOrNullWithErrorCorrection(encryptionKeys) ?: response
|
||||||
} else {
|
} else {
|
||||||
// Log redirections to be able to use the cipher.
|
// Log redirections to be able to use the cipher.
|
||||||
response.header("Location")?.let {
|
response.header("Location")?.let {
|
||||||
cache.add(it, cipher)
|
cache.add(it, encryptionKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|||||||
+18
-7
@@ -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.
|
* This class serves as a key cache to decrypt the body of HTTP calls that need it.
|
||||||
*/
|
*/
|
||||||
class EncryptionKeyCache {
|
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(
|
fun add(
|
||||||
url: String?,
|
url: String?,
|
||||||
cipher: NostrCipher,
|
cipher: NostrCipher,
|
||||||
) {
|
expectedMimeType: String?,
|
||||||
if (cache.get(url) == null) {
|
) = add(url, DecryptInformation(cipher, expectedMimeType))
|
||||||
cache.put(url, cipher)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(url: String): NostrCipher? = cache.get(url)
|
fun get(url: String): DecryptInformation? = cache.get(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DecryptInformation(
|
||||||
|
val cipher: NostrCipher,
|
||||||
|
val mimeType: String?,
|
||||||
|
)
|
||||||
|
|||||||
+2
-1
@@ -124,5 +124,6 @@ object HttpClientManager {
|
|||||||
fun addCipherToCache(
|
fun addCipherToCache(
|
||||||
url: String,
|
url: String,
|
||||||
cipher: NostrCipher,
|
cipher: NostrCipher,
|
||||||
) = cache.add(url, cipher)
|
expectedMimeType: String?,
|
||||||
|
) = cache.add(url, cipher, expectedMimeType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,17 +58,18 @@ object MediaSaverToDisk {
|
|||||||
if (videoUri != null) {
|
if (videoUri != null) {
|
||||||
if (!videoUri.startsWith("file")) {
|
if (!videoUri.startsWith("file")) {
|
||||||
downloadAndSave(
|
downloadAndSave(
|
||||||
|
url = videoUri,
|
||||||
|
mimeType = mimeType,
|
||||||
context = localContext,
|
context = localContext,
|
||||||
forceProxy = forceProxy,
|
forceProxy = forceProxy,
|
||||||
url = videoUri,
|
|
||||||
onSuccess = onSuccess,
|
onSuccess = onSuccess,
|
||||||
onError = onError,
|
onError = onError,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
save(
|
save(
|
||||||
context = localContext,
|
|
||||||
localFile = videoUri.toUri().toFile(),
|
localFile = videoUri.toUri().toFile(),
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
|
context = localContext,
|
||||||
onSuccess = onSuccess,
|
onSuccess = onSuccess,
|
||||||
onError = onError,
|
onError = onError,
|
||||||
)
|
)
|
||||||
@@ -83,6 +84,7 @@ object MediaSaverToDisk {
|
|||||||
*/
|
*/
|
||||||
fun downloadAndSave(
|
fun downloadAndSave(
|
||||||
url: String,
|
url: String,
|
||||||
|
mimeType: String?,
|
||||||
forceProxy: Boolean,
|
forceProxy: Boolean,
|
||||||
context: Context,
|
context: Context,
|
||||||
onSuccess: () -> Any?,
|
onSuccess: () -> Any?,
|
||||||
@@ -121,9 +123,16 @@ object MediaSaverToDisk {
|
|||||||
val contentType = response.header("Content-Type")
|
val contentType = response.header("Content-Type")
|
||||||
checkNotNull(contentType) { "Can't find out the content type" }
|
checkNotNull(contentType) { "Can't find out the content type" }
|
||||||
|
|
||||||
|
val realType =
|
||||||
|
if (mimeType != null && contentType == "application/octet-stream") {
|
||||||
|
mimeType
|
||||||
|
} else {
|
||||||
|
contentType
|
||||||
|
}
|
||||||
|
|
||||||
saveContentQ(
|
saveContentQ(
|
||||||
displayName = File(url).nameWithoutExtension,
|
displayName = File(url).nameWithoutExtension,
|
||||||
contentType = contentType,
|
contentType = realType,
|
||||||
contentSource = response.body.source(),
|
contentSource = response.body.source(),
|
||||||
contentResolver = context.contentResolver,
|
contentResolver = context.contentResolver,
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -308,6 +308,7 @@ private fun saveMediaToGallery(
|
|||||||
|
|
||||||
MediaSaverToDisk.downloadAndSave(
|
MediaSaverToDisk.downloadAndSave(
|
||||||
content.url,
|
content.url,
|
||||||
|
mimeType = content.mimeType,
|
||||||
forceProxy = useTor,
|
forceProxy = useTor,
|
||||||
localContext,
|
localContext,
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
|
|||||||
+3
-3
@@ -113,13 +113,13 @@ fun RenderEncryptedFile(
|
|||||||
val algo = noteEvent.algo()
|
val algo = noteEvent.algo()
|
||||||
val key = noteEvent.key()
|
val key = noteEvent.key()
|
||||||
val nonce = noteEvent.nonce()
|
val nonce = noteEvent.nonce()
|
||||||
|
val mimeType = noteEvent.mimeType()
|
||||||
|
|
||||||
if (algo == AESGCM.NAME && key != null && nonce != null) {
|
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 content by remember(noteEvent) {
|
||||||
val isImage = noteEvent.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content)
|
val isImage = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(noteEvent.content)
|
||||||
val mimeType = noteEvent.mimeType()
|
|
||||||
|
|
||||||
mutableStateOf<BaseMediaContent>(
|
mutableStateOf<BaseMediaContent>(
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
|
|||||||
Binary file not shown.
@@ -54,4 +54,22 @@ class AESGCMTest {
|
|||||||
|
|
||||||
assertEquals(44201, decrypted.size)
|
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
|
package com.vitorpamplona.quartz.nip04Dm.crypto
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher
|
import com.vitorpamplona.quartz.nip17Dm.files.encryption.NostrCipher
|
||||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.IvParameterSpec
|
import javax.crypto.spec.IvParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
@@ -50,6 +52,14 @@ class AESCBC(
|
|||||||
doFinal(bytesToDecrypt)
|
doFinal(bytesToDecrypt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? =
|
||||||
|
try {
|
||||||
|
decrypt(bytesToDecrypt)
|
||||||
|
} catch (e: GeneralSecurityException) {
|
||||||
|
Log.w("AESCBC", "Failed to decrypt", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "aes-cbc"
|
const val NAME = "aes-cbc"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip17Dm.files.encryption
|
package com.vitorpamplona.quartz.nip17Dm.files.encryption
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
@@ -56,6 +58,14 @@ class AESGCM(
|
|||||||
doFinal(bytesToDecrypt)
|
doFinal(bytesToDecrypt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? =
|
||||||
|
try {
|
||||||
|
decrypt(bytesToDecrypt)
|
||||||
|
} catch (e: GeneralSecurityException) {
|
||||||
|
Log.w("AESGCM", "Failed to decrypt", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val NAME = "aes-gcm"
|
const val NAME = "aes-gcm"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,6 @@ interface NostrCipher {
|
|||||||
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
|
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
|
||||||
|
|
||||||
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
|
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
|
||||||
|
|
||||||
|
fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray?
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user