Adds support for encrypted media uploads on NIP-17 DMs
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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.quartz.crypto.nip17
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils.decrypt
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AESGCMTest {
|
||||
val decryptionNonce = "01e77c94bd5aba3e3cbb69594e7ba07c"
|
||||
val decryptionKey = "c128ecffab90ee7810e3df08e7fb2cc39a8d40f24201f48b2b36e23b34ac50ee"
|
||||
|
||||
val cipher = AESGCM(decryptionKey.hexToByteArray(), decryptionNonce.toByteArray(Charsets.UTF_8))
|
||||
|
||||
@Test
|
||||
fun encryptDecrypt() {
|
||||
val encrypted = cipher.encrypt("Testing".toByteArray(Charsets.UTF_8))
|
||||
val decrypted = cipher.decrypt(encrypted)
|
||||
|
||||
assertEquals("Testing", String(decrypted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageTest() {
|
||||
val image =
|
||||
getInstrumentation().context.assets.open("ovxxk2vz.jpg").use {
|
||||
it.readAllBytes()
|
||||
}
|
||||
|
||||
val decrypted = cipher.decrypt(image)
|
||||
|
||||
assertEquals(44201, decrypted.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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.quartz.crypto.nip17
|
||||
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
interface NostrCipher {
|
||||
fun name(): String
|
||||
|
||||
fun encrypt(bytesToEncrypt: ByteArray): ByteArray
|
||||
|
||||
fun decrypt(bytesToDecrypt: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
class AESGCM(
|
||||
val keyBytes: ByteArray = CryptoUtils.random(32),
|
||||
val nonce: ByteArray = CryptoUtils.random(16),
|
||||
) : NostrCipher {
|
||||
private fun newCipher() = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
|
||||
private fun keySpec() = SecretKeySpec(keyBytes, "AES")
|
||||
|
||||
private fun param() = GCMParameterSpec(128, nonce)
|
||||
|
||||
override fun name() = NAME
|
||||
|
||||
fun copyUsingUTF8Nonce(): AESGCM =
|
||||
AESGCM(
|
||||
keyBytes,
|
||||
nonce.toHexKey().toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
|
||||
override fun encrypt(bytesToEncrypt: ByteArray): ByteArray =
|
||||
with(newCipher()) {
|
||||
init(Cipher.ENCRYPT_MODE, keySpec(), param())
|
||||
doFinal(bytesToEncrypt)
|
||||
}
|
||||
|
||||
override fun decrypt(bytesToDecrypt: ByteArray): ByteArray =
|
||||
with(newCipher()) {
|
||||
init(Cipher.DECRYPT_MODE, keySpec(), param())
|
||||
doFinal(bytesToDecrypt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "aes-gcm"
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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.quartz.events
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@Immutable
|
||||
class ChatMessageEncryptedFileHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
ChatroomKeyable,
|
||||
NIP17Group {
|
||||
/** Recipients intended to receive this conversation */
|
||||
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
|
||||
|
||||
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
|
||||
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun talkingWith(oneSideHex: String): Set<HexKey> {
|
||||
val listedPubKeys = recipientsPubKey()
|
||||
|
||||
val result =
|
||||
if (pubKey == oneSideHex) {
|
||||
listedPubKeys.toSet().minus(oneSideHex)
|
||||
} else {
|
||||
listedPubKeys.plus(pubKey).toSet().minus(oneSideHex)
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
// talking to myself
|
||||
return setOf(pubKey)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
||||
|
||||
fun url() = content
|
||||
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
|
||||
fun alt() = tags.firstOrNull { it.size > 1 && it[0] == ALT }?.get(1)
|
||||
|
||||
fun algo() = tags.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_ALGORITHM }?.get(1)
|
||||
|
||||
fun key() =
|
||||
tags
|
||||
.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_KEY }
|
||||
?.get(1)
|
||||
?.runCatching { this.hexToByteArray() }
|
||||
?.getOrNull()
|
||||
|
||||
fun nonce() =
|
||||
tags
|
||||
.firstOrNull { it.size > 1 && it[0] == ENCRYPTION_NONCE }
|
||||
?.get(1)
|
||||
?.runCatching { this.hexToByteArray() }
|
||||
?.getOrNull()
|
||||
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
|
||||
fun originalHash() = tags.firstOrNull { it.size > 1 && it[0] == ORIGINAL_HASH }?.get(1)
|
||||
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)?.let { Dimension.parse(it) }
|
||||
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val KIND = 15
|
||||
const val ALT_DESCRIPTION = "Encrypted file in chat"
|
||||
|
||||
const val MIME_TYPE = "file-type"
|
||||
|
||||
const val ENCRYPTION_ALGORITHM = "encryption-algorithm"
|
||||
const val ENCRYPTION_KEY = "decryption-key"
|
||||
const val ENCRYPTION_NONCE = "decryption-nonce"
|
||||
|
||||
const val FILE_SIZE = "size"
|
||||
const val DIMENSION = "dim"
|
||||
const val BLUR_HASH = "blurhash"
|
||||
const val HASH = "x"
|
||||
const val ORIGINAL_HASH = "ox"
|
||||
|
||||
const val ALT = "alt"
|
||||
|
||||
fun buildTags(
|
||||
to: List<HexKey>,
|
||||
repliesTo: List<HexKey>? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
): Array<Array<String>> {
|
||||
val repliesHex = repliesTo?.map { arrayOf("e", it) } ?: emptyList()
|
||||
|
||||
return (
|
||||
to.map { arrayOf("p", it) } + repliesHex +
|
||||
listOfNotNull(
|
||||
contentType?.let { arrayOf(MIME_TYPE, it) },
|
||||
arrayOf(ENCRYPTION_ALGORITHM, algo),
|
||||
arrayOf(ENCRYPTION_KEY, key.toHexKey()),
|
||||
nonce?.let { arrayOf(ENCRYPTION_NONCE, it.toHexKey()) },
|
||||
alt?.ifBlank { null }?.let { arrayOf(ALT, it) } ?: arrayOf(ALT, ALT_DESCRIPTION),
|
||||
originalHash?.let { arrayOf(ORIGINAL_HASH, it) },
|
||||
hash?.let { arrayOf(HASH, it) },
|
||||
size?.let { arrayOf(FILE_SIZE, it.toString()) },
|
||||
dimensions?.let { arrayOf(DIMENSION, it.toString()) },
|
||||
blurhash?.let { arrayOf(BLUR_HASH, it) },
|
||||
sensitiveContent?.let {
|
||||
if (it) {
|
||||
arrayOf("content-warning", "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
).toTypedArray()
|
||||
}
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
to: List<HexKey>,
|
||||
repliesTo: List<HexKey>? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
signer: NostrSigner,
|
||||
isDraft: Boolean,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
onReady: (ChatMessageEncryptedFileHeaderEvent) -> Unit,
|
||||
) {
|
||||
val tags = buildTags(to, repliesTo, contentType, algo, key, nonce, originalHash, hash, size, dimensions, blurhash, sensitiveContent, alt)
|
||||
if (isDraft) {
|
||||
signer.assembleRumor(createdAt, KIND, tags, url, onReady)
|
||||
} else {
|
||||
signer.sign(createdAt, KIND, tags, url, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,8 @@ class ChatMessageEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
ChatroomKeyable {
|
||||
ChatroomKeyable,
|
||||
NIP17Group {
|
||||
/** Recipients intended to receive this conversation */
|
||||
fun recipientsPubKey() = tags.mapNotNull { if (it.size > 1 && it[0] == "p") it[1] else null }
|
||||
|
||||
@@ -62,6 +63,8 @@ class ChatMessageEvent(
|
||||
return result
|
||||
}
|
||||
|
||||
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
|
||||
|
||||
override fun chatroomKey(toRemove: String): ChatroomKey = ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
||||
|
||||
companion object {
|
||||
@@ -111,6 +114,10 @@ class ChatMessageEvent(
|
||||
}
|
||||
}
|
||||
|
||||
interface NIP17Group {
|
||||
fun groupMembers(): Set<HexKey>
|
||||
}
|
||||
|
||||
interface ChatroomKeyable {
|
||||
fun chatroomKey(toRemove: HexKey): ChatroomKey
|
||||
}
|
||||
|
||||
@@ -59,6 +59,20 @@ class EventFactory {
|
||||
ChannelMessageEvent.KIND -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMetadataEvent.KIND -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMuteUserEvent.KIND -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND -> {
|
||||
if (id.isBlank()) {
|
||||
ChatMessageEncryptedFileHeaderEvent(
|
||||
Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey(),
|
||||
pubKey,
|
||||
createdAt,
|
||||
tags,
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
} else {
|
||||
ChatMessageEncryptedFileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
ChatMessageEvent.KIND -> {
|
||||
if (id.isBlank()) {
|
||||
ChatMessageEvent(
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.events
|
||||
|
||||
import com.vitorpamplona.quartz.encoders.Dimension
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.IMetaTag
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
@@ -120,6 +121,65 @@ class NIP17Factory {
|
||||
}
|
||||
}
|
||||
|
||||
fun createEncryptedFileNIP17(
|
||||
url: String,
|
||||
to: List<HexKey>,
|
||||
repliesToHex: List<HexKey>? = null,
|
||||
contentType: String?,
|
||||
algo: String,
|
||||
key: ByteArray,
|
||||
nonce: ByteArray? = null,
|
||||
originalHash: String? = null,
|
||||
hash: String? = null,
|
||||
size: Int? = null,
|
||||
dimensions: Dimension? = null,
|
||||
blurhash: String? = null,
|
||||
sensitiveContent: Boolean? = null,
|
||||
alt: String?,
|
||||
draftTag: String? = null,
|
||||
signer: NostrSigner,
|
||||
onReady: (Result) -> Unit,
|
||||
) {
|
||||
val senderPublicKey = signer.pubKey
|
||||
|
||||
ChatMessageEncryptedFileHeaderEvent.create(
|
||||
url = url,
|
||||
to = to,
|
||||
repliesTo = repliesToHex,
|
||||
contentType = contentType,
|
||||
algo = algo,
|
||||
key = key,
|
||||
nonce = nonce,
|
||||
originalHash = originalHash,
|
||||
hash = hash,
|
||||
size = size,
|
||||
dimensions = dimensions,
|
||||
blurhash = blurhash,
|
||||
sensitiveContent = sensitiveContent,
|
||||
alt = alt,
|
||||
signer = signer,
|
||||
isDraft = draftTag != null,
|
||||
) { senderMessage ->
|
||||
if (draftTag != null) {
|
||||
onReady(
|
||||
Result(
|
||||
msg = senderMessage,
|
||||
wraps = listOf(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps ->
|
||||
onReady(
|
||||
Result(
|
||||
msg = senderMessage,
|
||||
wraps = wraps,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createReactionWithinGroup(
|
||||
content: String,
|
||||
originalNote: EventInterface,
|
||||
|
||||
Reference in New Issue
Block a user