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:
Claude
2026-07-14 19:44:56 +00:00
parent e231b0cf5b
commit e734d6400c
7 changed files with 351 additions and 0 deletions
@@ -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
@@ -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,
)
}
@@ -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> {
+1
View File
@@ -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>
@@ -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,
@@ -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?,
)
@@ -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 {