diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b61da3d44a..b5f477560a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, + ): 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 2cecc809d1..0121df89f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index bc22137a99..039319003d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -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(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) { + uploadState?.load(media) } private fun channelAuthors(): Set { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ed79270878..0a01231dad 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -312,6 +312,7 @@ You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. Show all channels + Send image %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index f44b0c427f..dce6d62b3a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -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, + 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index a2477da820..e878436ae4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -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, + 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 = + 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?, +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index 632f53a99b..0c371e1749 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -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 {