Adds support for encrypted media uploads on NIP-17 DMs

This commit is contained in:
Vitor Pamplona
2024-12-23 20:30:22 -05:00
parent f839565152
commit 94c74a1e0c
45 changed files with 1556 additions and 332 deletions
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"
}
}
@@ -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,