mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(concord): send & receive encrypted image messages (Armada-compatible)
Concord channel messages can now carry images, wire-identical to Soapbox Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext URL is appended to the content and annotated by a NIP-92 `imeta` tag with `encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so the media host and relays only ever see encrypted bytes — the community's E2E guarantee holds. Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage / encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp EncryptedBlobInterceptor keyCache on the receive side — registering each attachment's cipher (keyed by URL) lets the normal feed renderer display the decrypted image with no shared-render changes. Encryption is mandatory (no toggle, and a missing cipher fails closed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
@@ -148,6 +149,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
@@ -330,6 +332,7 @@ import com.vitorpamplona.quartz.utils.DualCase
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import com.vitorpamplona.quartz.utils.containsAny
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -495,9 +498,28 @@ class Account(
|
||||
?.value
|
||||
?.authority
|
||||
if (authority?.isBanned(rumor.pubKey) == true) return
|
||||
registerConcordEncryptedImages(rumor)
|
||||
cache.consumeConcordRumor(communityId, channelIdHex, rumor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register any encrypted image attachments on a Concord message ([ChannelChat.encryptedImagesOf])
|
||||
* so the shared media pipeline can display them: the ciphertext blob's AES-256-GCM key/nonce go
|
||||
* into [com.vitorpamplona.amethyst.AppModules.keyCache], and the OkHttp EncryptedBlobInterceptor
|
||||
* decrypts the blob transparently on fetch (keyed by URL) — the same path NIP-17 encrypted media
|
||||
* uses. Runs for both inbound wraps and our own local echo, so a sent image renders immediately.
|
||||
*/
|
||||
private fun registerConcordEncryptedImages(rumor: Event) {
|
||||
val images = ChannelChat.encryptedImagesOf(rumor)
|
||||
if (images.isEmpty()) return
|
||||
val keyCache = Amethyst.instance.keyCache
|
||||
images.forEach { img ->
|
||||
if (img.algo == AESGCM.NAME) {
|
||||
keyCache.add(img.url, AESGCM(img.key, img.nonce), img.mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies each folded community's metadata (name/icon, channel flags, this account's
|
||||
* membership) onto its [ConcordChannel] objects in the cache, and drops messages from
|
||||
@@ -2067,6 +2089,28 @@ class Account(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a channel message carrying encrypted image attachments ([imetas], built by the composer
|
||||
* from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are
|
||||
* appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no
|
||||
* attachments this is just a plain [sendConcordChannelMessage].
|
||||
*/
|
||||
suspend fun sendConcordChannelImageMessage(
|
||||
communityId: String,
|
||||
channelIdHex: String,
|
||||
text: String,
|
||||
imetas: List<IMetaTag>,
|
||||
): Boolean {
|
||||
if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text)
|
||||
if (!isWriteable()) return false
|
||||
val session = concordSessions.sessionFor(communityId) ?: return false
|
||||
val entry = session.entry
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now())
|
||||
publishConcordWrap(entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that
|
||||
* message. Resolves the chat context from the note's gatherer; today it drives the
|
||||
|
||||
+113
@@ -43,6 +43,7 @@ import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -65,6 +66,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -72,11 +75,15 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSugge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
|
||||
@@ -87,10 +94,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -369,6 +379,21 @@ private fun ConcordMessageComposer(
|
||||
onDispose { newMessageModel.userSuggestions?.reset() }
|
||||
}
|
||||
|
||||
// Encrypted image attachments: a picked image opens this dialog, which encrypts + uploads via the
|
||||
// shared NIP-17 pipeline and sends an Armada-shaped image message on the channel plane.
|
||||
newMessageModel.uploadState?.let { uploadState ->
|
||||
uploadState.multiOrchestrator?.let {
|
||||
ConcordFileUploadDialog(
|
||||
newMessageModel = newMessageModel,
|
||||
state = uploadState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onUpload = { onMessageSent() },
|
||||
onCancel = uploadState::reset,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
newMessageModel.replyTo.value?.let {
|
||||
DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() }
|
||||
ReplyModeToggle(
|
||||
@@ -402,6 +427,9 @@ private fun ConcordMessageComposer(
|
||||
accountViewModel.sendConcordTyping(community, channel)
|
||||
}
|
||||
},
|
||||
onContentReceived = { uri, mimeType ->
|
||||
newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType)))
|
||||
},
|
||||
inputTransformation = MentionPreservingInputTransformation,
|
||||
outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -412,6 +440,19 @@ private fun ConcordMessageComposer(
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
|
||||
) {
|
||||
SelectFromGallery(
|
||||
isUploading = false,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier,
|
||||
onImageChosen = newMessageModel::pickedMedia,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
ThinSendButton(
|
||||
isActive = canPost,
|
||||
@@ -437,3 +478,75 @@ private fun ConcordMessageComposer(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The picked-image confirmation dialog for a Concord channel. Reuses the shared NIP-17 upload
|
||||
* pipeline: it always encrypts (no encryption toggle is shown, and [SuccessfulUploads.toConcordImeta]
|
||||
* fails closed if a cipher is somehow absent), uploads the ciphertext, then sends one Armada-shaped
|
||||
* image message ([Account.sendConcordChannelImageMessage]) carrying every attachment's `imeta`.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConcordFileUploadDialog(
|
||||
newMessageModel: ConcordNewMessageViewModel,
|
||||
state: ChatFileUploadState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onUpload: suspend () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
ChatFileUploadDialog(
|
||||
state = state,
|
||||
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_send_image_title)) },
|
||||
upload = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val community = newMessageModel.communityId
|
||||
val channel = newMessageModel.channelId
|
||||
if (community == null || channel == null) return@launch
|
||||
|
||||
ChatFileUploader(accountViewModel.account).justUploadNIP17(
|
||||
viewState = state,
|
||||
onError = { title, message ->
|
||||
scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
|
||||
},
|
||||
onEncryptedUploadError = { title, message ->
|
||||
scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
|
||||
},
|
||||
context = context,
|
||||
onceUploaded = { uploads ->
|
||||
val imetas = uploads.mapNotNull { it.toConcordImeta() }
|
||||
if (imetas.isNotEmpty()) {
|
||||
accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas)
|
||||
}
|
||||
onUpload()
|
||||
},
|
||||
)
|
||||
|
||||
accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer)
|
||||
accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata)
|
||||
}
|
||||
},
|
||||
onCancel = onCancel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an encrypted upload into the Armada-shaped `imeta` (via [ChannelChat.encryptedImageImeta]).
|
||||
* Returns null when the upload carried no cipher — so a non-encrypted blob is never sent as a Concord
|
||||
* image (fails closed, protecting the community's end-to-end guarantee).
|
||||
*/
|
||||
private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
|
||||
val cipher = cipher ?: return null
|
||||
return ChannelChat.encryptedImageImeta(
|
||||
url = result.url,
|
||||
mimeType = result.mimeTypeBeforeEncryption,
|
||||
dim = result.fileHeader.dim?.toString(),
|
||||
blurhash = result.fileHeader.blurHash?.blurhash,
|
||||
cipher = cipher,
|
||||
originalHash = result.hashBeforeEncryption,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -24,7 +24,9 @@ import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||
@@ -32,10 +34,13 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Composition state for the Concord channel message field, mirroring the other
|
||||
@@ -62,6 +67,10 @@ open class ConcordNewMessageViewModel : ViewModel() {
|
||||
|
||||
var userSuggestions: UserSuggestionState? = null
|
||||
|
||||
// Encrypted image attachments ride through the shared NIP-17 upload pipeline; a picked image
|
||||
// opens the upload dialog, which encrypts + uploads and sends an Armada-shaped image message.
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
open fun init(accountVM: AccountViewModel) {
|
||||
this.accountViewModel = accountVM
|
||||
this.account = accountVM.account
|
||||
@@ -74,6 +83,12 @@ open class ConcordNewMessageViewModel : ViewModel() {
|
||||
// Rank people who have posted in this channel first.
|
||||
priorityPubkeys = { channelAuthors() },
|
||||
)
|
||||
|
||||
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
|
||||
}
|
||||
|
||||
fun pickedMedia(media: ImmutableList<SelectedMedia>) {
|
||||
uploadState?.load(media)
|
||||
}
|
||||
|
||||
private fun channelAuthors(): Set<HexKey> {
|
||||
|
||||
@@ -312,6 +312,7 @@
|
||||
<string name="concord_home_empty">You haven\'t joined any Concord Channels yet. Create one, or open an invite link.</string>
|
||||
<string name="concord_channels_empty">No channels yet.</string>
|
||||
<string name="concord_show_all_channels">Show all channels</string>
|
||||
<string name="concord_send_image_title">Send image</string>
|
||||
<string name="concord_typing_one">%1$s is typing…</string>
|
||||
<string name="concord_typing_two">%1$s and %2$s are typing…</string>
|
||||
<string name="concord_typing_many">Several people are typing…</string>
|
||||
|
||||
+18
@@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
|
||||
/** One decrypted, verified Concord channel message projected for display. */
|
||||
@@ -160,6 +161,23 @@ object ConcordActions {
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an encrypted-seal channel message wrap carrying one or more encrypted image [imetas]
|
||||
* (Armada `encryptAttachments` shape) to publish on the [channel] plane.
|
||||
*/
|
||||
suspend fun buildChannelImageMessage(
|
||||
authorSigner: NostrSigner,
|
||||
channel: GroupKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
text: String,
|
||||
imetas: List<IMetaTag>,
|
||||
createdAt: Long,
|
||||
): Event {
|
||||
val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt)
|
||||
return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true)
|
||||
}
|
||||
|
||||
/** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */
|
||||
suspend fun buildChannelInlineReply(
|
||||
authorSigner: NostrSigner,
|
||||
|
||||
+110
@@ -24,11 +24,20 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
|
||||
/**
|
||||
* Chat Plane message binding (CORD-03).
|
||||
@@ -152,6 +161,90 @@ object ChannelChat {
|
||||
content = content,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an unsigned kind-9 message carrying one or more **encrypted image** attachments
|
||||
* ([imetas]), wire-identical to Soapbox Armada's `encryptAttachments` path so images interop
|
||||
* across Concord clients. Each attachment's ciphertext URL is appended to the text content (the
|
||||
* ones not already present), exactly as Armada assembles it, and each rides as a NIP-92 `imeta`
|
||||
* tag ([encryptedImageImeta]). The message is still a normal channel-bound kind-9, so the shared
|
||||
* feed renders it and the binding is enforced like any other Chat Plane rumor.
|
||||
*/
|
||||
fun imageMessage(
|
||||
authorPubKey: HexKey,
|
||||
channelId: HexKey,
|
||||
epoch: Long,
|
||||
text: String,
|
||||
imetas: List<IMetaTag>,
|
||||
createdAt: Long,
|
||||
): Event {
|
||||
val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) }
|
||||
val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n")
|
||||
return message(
|
||||
authorPubKey = authorPubKey,
|
||||
channelId = channelId,
|
||||
epoch = epoch,
|
||||
text = finalText,
|
||||
createdAt = createdAt,
|
||||
extraTags = imetas.map { it.toTagArray() }.toTypedArray(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the encrypted-image `imeta` tag Armada's `ChatComposer` emits with `encryptAttachments`:
|
||||
* `url` (ciphertext blob), `m` (plaintext mime), `dim`, `blurhash`, plus `encryption-algorithm`
|
||||
* (`aes-gcm`), `decryption-key`, `decryption-nonce` (hex), and `ox` (the *plaintext* SHA-256 for
|
||||
* integrity). Deliberately omits `x` (a ciphertext hash) to match Armada exactly.
|
||||
*/
|
||||
fun encryptedImageImeta(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
dim: String?,
|
||||
blurhash: String?,
|
||||
cipher: AESGCM,
|
||||
originalHash: String?,
|
||||
): IMetaTag =
|
||||
IMetaTagBuilder(url)
|
||||
.apply {
|
||||
mimeType?.let { add("m", it) }
|
||||
dim?.let { add("dim", it) }
|
||||
blurhash?.let { add("blurhash", it) }
|
||||
add(EncryptionAlgo.TAG_NAME, cipher.name())
|
||||
add(EncryptionKey.TAG_NAME, cipher.keyBytes.toHexKey())
|
||||
add(EncryptionNonce.TAG_NAME, cipher.nonce.toHexKey())
|
||||
originalHash?.let { add(OriginalHashTag.TAG_NAME, it) }
|
||||
}.build()
|
||||
|
||||
/**
|
||||
* Parses every **encrypted image** attachment ([ConcordImageAttachment]) carried on [rumor] as an
|
||||
* `imeta` tag with the `aes-gcm` `decryption-key`/`decryption-nonce` fields. A plaintext imeta
|
||||
* (no encryption fields) is ignored here — it renders through the normal media path.
|
||||
*/
|
||||
fun encryptedImagesOf(rumor: Event): List<ConcordImageAttachment> =
|
||||
rumor.tags
|
||||
.mapNotNull { if (it.size >= 2 && it[0] == IMetaTag.TAG_NAME) IMetaTag.parse(it) else null }
|
||||
.flatten()
|
||||
.mapNotNull { it.toEncryptedAttachmentOrNull() }
|
||||
|
||||
private fun IMetaTag.prop(key: String): String? = properties[key]?.firstOrNull()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun IMetaTag.toEncryptedAttachmentOrNull(): ConcordImageAttachment? {
|
||||
val key = prop(EncryptionKey.TAG_NAME) ?: return null
|
||||
val nonce = prop(EncryptionNonce.TAG_NAME) ?: return null
|
||||
val algo = prop(EncryptionAlgo.TAG_NAME) ?: return null
|
||||
val keyBytes = runCatching { key.hexToByteArray() }.getOrNull() ?: return null
|
||||
val nonceBytes = runCatching { nonce.hexToByteArray() }.getOrNull() ?: return null
|
||||
return ConcordImageAttachment(
|
||||
url = url,
|
||||
mimeType = prop("m"),
|
||||
dim = prop("dim"),
|
||||
blurhash = prop("blurhash"),
|
||||
algo = algo,
|
||||
key = keyBytes,
|
||||
nonce = nonceBytes,
|
||||
originalHash = prop(OriginalHashTag.TAG_NAME),
|
||||
)
|
||||
}
|
||||
|
||||
/** Chat Plane typing indicator (CORD-03): a transient "user is composing" heartbeat. */
|
||||
const val KIND_TYPING = 23311
|
||||
|
||||
@@ -194,3 +287,20 @@ object ChannelChat {
|
||||
epoch: Long,
|
||||
): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch)
|
||||
}
|
||||
|
||||
/**
|
||||
* A decrypted-pointer to an **encrypted image** attached to a Concord chat message (CORD-03), parsed
|
||||
* from a NIP-92 `imeta` tag ([ChannelChat.encryptedImagesOf]). The [url] blob is AES-256-GCM
|
||||
* ciphertext on a media host; fetch it, decrypt with [key]/[nonce], and verify the plaintext SHA-256
|
||||
* equals [originalHash] before displaying. Mirrors Soapbox Armada's encrypted attachment for interop.
|
||||
*/
|
||||
class ConcordImageAttachment(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val dim: String?,
|
||||
val blurhash: String?,
|
||||
val algo: String,
|
||||
val key: ByteArray,
|
||||
val nonce: ByteArray,
|
||||
val originalHash: String?,
|
||||
)
|
||||
|
||||
+50
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -119,6 +120,55 @@ class ChannelChatEndToEndTest {
|
||||
assertFalse(ChannelChat.isTyping(ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "hi", 1L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encryptedImageMessageMatchesArmadaWireFormatAndRoundTrips() {
|
||||
val author = KeyPair().pubKey.toHexKey()
|
||||
val cipher = AESGCM(ByteArray(32) { 0x11 }, ByteArray(16) { 0x22 })
|
||||
val url = "https://blossom.example/ciphertext.bin"
|
||||
val ox = "aa".repeat(32)
|
||||
|
||||
val imeta =
|
||||
ChannelChat.encryptedImageImeta(
|
||||
url = url,
|
||||
mimeType = "image/jpeg",
|
||||
dim = "800x600",
|
||||
blurhash = "LKO2",
|
||||
cipher = cipher,
|
||||
originalHash = ox,
|
||||
)
|
||||
val msg = ChannelChat.imageMessage(author, channelIdHex, 0L, "look", listOf(imeta), createdAt = 5L)
|
||||
|
||||
// Still a channel-bound kind-9; the ciphertext url is appended to content (Armada assembly).
|
||||
assertEquals(9, msg.kind)
|
||||
assertTrue(ChannelChat.isBoundTo(msg, channelIdHex, 0L))
|
||||
assertEquals("look\n$url", msg.content)
|
||||
|
||||
// The imeta tag carries exactly Armada's fields: aes-gcm + hex key/nonce + ox, and NO `x`.
|
||||
val imetaTag = msg.tags.first { it[0] == "imeta" }
|
||||
assertTrue(imetaTag.contains("url $url"))
|
||||
assertTrue(imetaTag.contains("m image/jpeg"))
|
||||
assertTrue(imetaTag.contains("dim 800x600"))
|
||||
assertTrue(imetaTag.contains("encryption-algorithm aes-gcm"))
|
||||
assertTrue(imetaTag.contains("decryption-key ${ByteArray(32) { 0x11 }.toHexKey()}"))
|
||||
assertTrue(imetaTag.contains("decryption-nonce ${ByteArray(16) { 0x22 }.toHexKey()}"))
|
||||
assertTrue(imetaTag.contains("ox $ox"))
|
||||
assertTrue(imetaTag.none { it.startsWith("x ") })
|
||||
|
||||
// Receiver parses the attachment back with the same key/nonce for decryption.
|
||||
val parsed = ChannelChat.encryptedImagesOf(msg)
|
||||
assertEquals(1, parsed.size)
|
||||
val att = parsed.first()
|
||||
assertEquals(url, att.url)
|
||||
assertEquals("image/jpeg", att.mimeType)
|
||||
assertEquals("aes-gcm", att.algo)
|
||||
assertEquals(ox, att.originalHash)
|
||||
assertTrue(att.key.contentEquals(ByteArray(32) { 0x11 }))
|
||||
assertTrue(att.nonce.contentEquals(ByteArray(16) { 0x22 }))
|
||||
|
||||
// A plaintext message has no encrypted attachments.
|
||||
assertTrue(ChannelChat.encryptedImagesOf(ChannelChat.message(author, channelIdHex, 0L, "hi", 1L)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonMembersCannotDeriveThePlane() =
|
||||
runTest {
|
||||
|
||||
Reference in New Issue
Block a user