diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1613cecb7..f87d3c054 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1458,6 +1458,30 @@ class AccountViewModel( account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) } + suspend fun sendMarmotGroupMediaMessage( + nostrGroupId: String, + url: String, + imeta: com.vitorpamplona.quartz.nip92IMeta.IMetaTag, + caption: String? = null, + ) { + val template = + com.vitorpamplona.quartz.nip01Core.signers.eventTemplate( + kind = 9, + description = url, + ) { + com.vitorpamplona.quartz.nip92IMeta.imeta(imeta) + if (!caption.isNullOrEmpty()) { + com.vitorpamplona.quartz.nip31Alts.alt(caption) + } + } + val innerEvent = account.signer.sign(template) + val relays = marmotGroupRelays(nostrGroupId) + account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) + } + + fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = + account.marmotManager?.mediaExporterSecret(nostrGroupId) + suspend fun createMarmotGroup(nostrGroupId: String) { account.createMarmotGroup(nostrGroupId) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 27a811b3a..e2d5fac54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -64,7 +64,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChan import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.IncognitoBadge import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat @@ -405,11 +407,12 @@ fun NoteRow( nav: INav, ) { Row(verticalAlignment = Alignment.CenterVertically) { - when (note.event) { - is ChannelCreateEvent -> RenderCreateChannelNote(note, bgColor, accountViewModel, nav) - is ChannelMetadataEvent -> RenderChangeChannelMetadataNote(note, bgColor, accountViewModel, nav) - is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav) - is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav) + when { + note.event is ChannelCreateEvent -> RenderCreateChannelNote(note, bgColor, accountViewModel, nav) + note.event is ChannelMetadataEvent -> RenderChangeChannelMetadataNote(note, bgColor, accountViewModel, nav) + note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav) + note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav) + hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav) else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt new file mode 100644 index 000000000..79790ad5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderMarmotEncryptedMedia.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04Cipher +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaMeta +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.toMip04MediaMeta +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import kotlinx.collections.immutable.persistentListOf + +/** + * Check if a Note's event has MIP-04 encrypted media imeta tags. + */ +fun hasMip04Media(event: Event?): Boolean { + if (event == null) return false + val imetas = event.imetas() + return imetas.any { it.toMip04MediaMeta() != null } +} + +/** + * Renders MIP-04 encrypted media in a Marmot group chat message. + * + * Parses the imeta tags from the event, derives the decryption cipher + * from the MLS exporter secret, registers it in the encryption key cache + * so the [EncryptedBlobInterceptor] can decrypt on download, and displays + * the media using the standard [ZoomableContentView]. + */ +@Composable +fun RenderMarmotEncryptedMedia( + note: Note, + bgColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event ?: return + val imetas = remember(event) { event.imetas() } + val mip04Meta = remember(imetas) { imetas.firstNotNullOfOrNull { it.toMip04MediaMeta() } } + + if (mip04Meta == null) { + RenderDecryptionError(note, bgColor, accountViewModel, nav) + return + } + + // Find the nostrGroupId via the reverse index in MarmotGroupList + val nostrGroupId = remember(note) { findGroupIdForNote(note, accountViewModel) } + val exporterSecret = + remember(nostrGroupId) { + nostrGroupId?.let { accountViewModel.marmotMediaExporterSecret(it) } + } + + if (exporterSecret == null) { + RenderDecryptionError(note, bgColor, accountViewModel, nav) + return + } + + RenderMip04Content(note, mip04Meta, exporterSecret, bgColor, accountViewModel, nav) +} + +@Composable +private fun RenderMip04Content( + note: Note, + meta: Mip04MediaMeta, + exporterSecret: ByteArray, + bgColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Register the MIP-04 cipher in the encryption key cache so the + // EncryptedBlobInterceptor can decrypt the downloaded blob. + val cipher = + remember(meta) { + Mip04Cipher( + exporterSecret = exporterSecret, + nonce = meta.nonceBytes, + originalFileHash = meta.originalFileHashBytes, + mimeType = meta.mimeType, + filename = meta.filename, + ) + } + Amethyst.instance.keyCache.add(meta.url, cipher, meta.mimeType) + + val description = note.event?.alt() + val isImage = meta.mimeType.startsWith("image/") || RichTextParser.isImageUrl(meta.url) + val dim = meta.dimensions?.let { DimensionTag.parse(it) } + + val content by remember(meta) { + mutableStateOf( + if (isImage) { + EncryptedMediaUrlImage( + url = meta.url, + description = description, + hash = meta.originalFileHash, + blurhash = meta.blurhash, + dim = dim, + uri = note.toNostrUri(), + mimeType = meta.mimeType, + encryptionAlgo = meta.version, + encryptionKey = exporterSecret, + encryptionNonce = meta.nonceBytes, + ) + } else { + EncryptedMediaUrlVideo( + url = meta.url, + description = description, + hash = meta.originalFileHash, + blurhash = meta.blurhash, + dim = dim, + uri = note.toNostrUri(), + authorName = note.author?.toBestDisplayName(), + mimeType = meta.mimeType, + encryptionAlgo = meta.version, + encryptionKey = exporterSecret, + encryptionNonce = meta.nonceBytes, + ) + }, + ) + } + + ZoomableContentView( + content, + persistentListOf(content), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel, + ) +} + +@Composable +private fun RenderDecryptionError( + note: Note, + bgColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + TranslatableRichTextViewer( + content = stringRes(id = R.string.could_not_decrypt_the_message), + canPreview = true, + quotesLeft = 0, + modifier = Modifier, + tags = EmptyTagList, + backgroundColor = bgColor, + id = note.idHex, + callbackUri = note.toNostrUri(), + accountViewModel = accountViewModel, + nav = nav, + ) +} + +/** + * Finds the Marmot group ID for a note via the reverse index + * maintained by [MarmotGroupList]. + */ +private fun findGroupIdForNote( + note: Note, + accountViewModel: AccountViewModel, +): String? = accountViewModel.account.marmotGroupList.groupIdForNote(note.idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt index 787d12cc4..3728bc97f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup import android.widget.Toast import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.material3.MaterialTheme @@ -34,18 +36,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf 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 +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +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 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.marmotGroup.send.MarmotFileSender +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotFileUploader +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.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -54,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -107,6 +120,7 @@ fun MarmotGroupChatView( MarmotGroupMessageComposer( nostrGroupId = nostrGroupId, accountViewModel = accountViewModel, + nav = nav, onMessageSent = { feedViewModel.feedState.sendToTop() }, @@ -118,6 +132,7 @@ fun MarmotGroupChatView( fun MarmotGroupMessageComposer( nostrGroupId: HexKey, accountViewModel: AccountViewModel, + nav: INav, onMessageSent: suspend () -> Unit, ) { val scope = rememberCoroutineScope() @@ -125,6 +140,27 @@ fun MarmotGroupMessageComposer( val canPost by remember { derivedStateOf { messageState.text.isNotBlank() } } val context = LocalContext.current + var isUploading by remember { mutableStateOf(false) } + val uploadState = + remember { + ChatFileUploadState( + defaultServer = accountViewModel.account.settings.defaultFileServer, + defaultStripMetadata = accountViewModel.account.settings.stripLocationOnUpload, + ) + } + + // Upload dialog + uploadState.multiOrchestrator?.let { + MarmotGroupFileUploadDialog( + nostrGroupId = nostrGroupId, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } + Column(modifier = EditFieldModifier) { ThinPaddingTextField( state = messageState, @@ -136,6 +172,14 @@ fun MarmotGroupMessageComposer( color = MaterialTheme.colorScheme.placeholderText, ) }, + leadingIcon = { + MarmotGalleryLeadingIcon( + isUploading = isUploading, + onImageChosen = { selectedMedia -> + uploadState.load(selectedMedia) + }, + ) + }, trailingIcon = { ThinSendButton( isActive = canPost, @@ -170,3 +214,83 @@ fun MarmotGroupMessageComposer( ) } } + +@Composable +private fun MarmotGalleryLeadingIcon( + isUploading: Boolean, + onImageChosen: (ImmutableList) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + ) { + SelectFromGallery( + isUploading = isUploading, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier, + onImageChosen = onImageChosen, + ) + } +} + +@Composable +private fun MarmotGroupFileUploadDialog( + nostrGroupId: HexKey, + state: ChatFileUploadState, + accountViewModel: AccountViewModel, + nav: INav, + onUpload: suspend () -> Unit, + onCancel: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + ChatFileUploadDialog( + state = state, + title = { + val chatroom = + remember(nostrGroupId) { + accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) + } + Text(chatroom.displayName.value ?: "Marmot Group") + }, + upload = { + scope.launch(Dispatchers.IO) { + val exporterSecret = accountViewModel.marmotMediaExporterSecret(nostrGroupId) + if (exporterSecret == null) { + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Not a member of this group", + Toast.LENGTH_SHORT, + ).show() + } + return@launch + } + + MarmotFileUploader(accountViewModel.account).uploadMip04( + viewState = state, + exporterSecret = exporterSecret, + onError = { title, message -> + scope.launch(Dispatchers.Main) { + Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() + } + }, + context = context, + onceUploaded = { uploads -> + MarmotFileSender(nostrGroupId, accountViewModel).send(uploads) + onUpload() + }, + ) + + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) + } + }, + onCancel = onCancel, + accountViewModel = accountViewModel, + nav = nav, + isNip17 = false, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt new file mode 100644 index 000000000..b9f24db75 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileSender.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.screen.loggedIn.chats.marmotGroup.send + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.buildMip04IMetaTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Sends uploaded MIP-04 encrypted media as Marmot group messages. + * Each upload result becomes a separate kind:9 message with an imeta tag. + */ +class MarmotFileSender( + val nostrGroupId: HexKey, + val accountViewModel: AccountViewModel, +) { + suspend fun send(uploads: List) { + for (upload in uploads) { + val imeta = + buildMip04IMetaTag( + url = upload.url, + mimeType = upload.mimeType, + filename = upload.filename, + originalFileHash = upload.originalFileHash, + nonce = upload.nonce, + dimensions = upload.dimensions, + blurhash = upload.blurhash, + ) + + accountViewModel.sendMarmotGroupMediaMessage( + nostrGroupId = nostrGroupId, + url = upload.url, + imeta = imeta, + caption = upload.caption, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt new file mode 100644 index 000000000..e6e058d70 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotFileUploader.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 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.amethyst.ui.screen.loggedIn.chats.marmotGroup.send + +import android.content.Context +import android.net.Uri +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04NostrCipher + +/** + * MIP-04 upload result containing all info needed to build the imeta tag. + */ +class Mip04UploadResult( + val url: String, + val mimeType: String, + val filename: String, + val originalFileHash: ByteArray, + val nonce: ByteArray, + val dimensions: String?, + val blurhash: String?, + val caption: String?, +) + +/** + * Handles MIP-04 encrypted media upload for Marmot groups. + * + * Uses the existing [UploadOrchestrator.uploadEncrypted] pipeline but + * provides a per-file [Mip04NostrCipher] for MIP-04 key derivation. + */ +class MarmotFileUploader( + val account: Account, +) { + suspend fun uploadMip04( + viewState: ChatFileUploadState, + exporterSecret: ByteArray, + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: suspend (List) -> Unit, + ) { + val multiOrchestrator = viewState.multiOrchestrator ?: return + viewState.mediaUploadTracker.startUpload(multiOrchestrator.hasNonMedia()) + + val results = mutableListOf() + val quality = MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider) + + val count = multiOrchestrator.size() + for (i in 0 until count) { + val item = multiOrchestrator.get(i) + val media = item.media + val mimeType = media.mimeType ?: "application/octet-stream" + val filename = resolveFilename(context, media.uri, mimeType) + + val cipher = Mip04NostrCipher(exporterSecret, mimeType, filename) + + item.orchestrator.uploadEncrypted( + uri = media.uri, + mimeType = mimeType, + alt = viewState.caption.ifEmpty { null }, + contentWarningReason = viewState.contentWarningReason, + compressionQuality = quality, + encrypt = cipher, + server = viewState.selectedServer, + account = account, + context = context, + stripMetadata = viewState.stripMetadata, + ) + + val state = item.orchestrator.progressState.value + if (state is UploadingState.Finished && state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val serverResult = state.result as UploadOrchestrator.OrchestratorResult.ServerResult + results.add( + Mip04UploadResult( + url = serverResult.url, + mimeType = mimeType, + filename = filename, + originalFileHash = cipher.originalFileHash, + nonce = cipher.nonce, + dimensions = serverResult.fileHeader.dim?.toString(), + blurhash = serverResult.fileHeader.blurHash?.blurhash, + caption = viewState.caption.ifEmpty { null }, + ), + ) + } else { + val errorMessage = + if (state is UploadingState.Error) { + stringRes(context, state.errorResource, *state.params) + } else { + "Upload failed for $filename" + } + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + errorMessage, + ) + viewState.mediaUploadTracker.finishUpload() + return + } + } + + onceUploaded(results) + viewState.reset() + viewState.mediaUploadTracker.finishUpload() + } + + private fun resolveFilename( + context: Context, + uri: Uri, + mimeType: String, + ): String { + val cursor = context.contentResolver.query(uri, null, null, null, null) + val name = + cursor?.use { + val idx = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && it.moveToFirst()) it.getString(idx) else null + } + if (name != null) return name + + val ext = + when { + mimeType.startsWith("image/jpeg") -> "jpg" + mimeType.startsWith("image/png") -> "png" + mimeType.startsWith("image/gif") -> "gif" + mimeType.startsWith("image/webp") -> "webp" + mimeType.startsWith("video/mp4") -> "mp4" + mimeType.startsWith("video/webm") -> "webm" + mimeType.startsWith("audio/") -> "m4a" + else -> "bin" + } + return "media.$ext" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index ef7fea4e1..d56abe2c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -403,6 +403,12 @@ class MarmotManager( */ fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch + /** + * Get the MIP-04 media exporter secret for a group. + * MLS-Exporter("marmot", "encrypted-media", 32) + */ + fun mediaExporterSecret(nostrGroupId: HexKey): ByteArray = groupManager.mediaExporterSecret(nostrGroupId) + /** * Get the MIP-01 group metadata from the MLS GroupContext extensions. * Returns null if the group doesn't exist or has no MarmotGroupData extension. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt index 30d693085..db34fd791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -36,6 +36,8 @@ class MarmotGroupList( var rooms = LargeCache() private set + private val noteToGroupIndex = LargeCache() + private val _groupListChanges = MutableSharedFlow(0, 20, BufferOverflow.DROP_OLDEST) val groupListChanges = _groupListChanges @@ -56,6 +58,7 @@ class MarmotGroupList( chatroom.addMessageSync(msg) } if (added) { + noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } if (isSelfAuthored) { chatroom.ownerSentMessage = true } @@ -73,6 +76,7 @@ class MarmotGroupList( ) { val chatroom = getOrCreateGroup(nostrGroupId) if (chatroom.restoreMessageSync(msg)) { + noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId } if (msg.author?.pubkeyHex == ownerPubKey) { chatroom.ownerSentMessage = true } @@ -80,6 +84,8 @@ class MarmotGroupList( } } + fun groupIdForNote(noteId: HexKey): HexKey? = noteToGroupIndex.get(noteId) + /** * Mark a group as "known" by the local user — used right after the user * creates a group, so the creator doesn't appear under "New Requests" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04Cipher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04Cipher.kt new file mode 100644 index 000000000..d5569d5ca --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04Cipher.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 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.marmot.mip04EncryptedMedia + +import com.vitorpamplona.quartz.utils.ciphers.NostrCipher + +/** + * NostrCipher adapter for MIP-04 encrypted media decryption. + * + * This wraps the MIP-04 decryption parameters so encrypted media can be + * decrypted by the existing [EncryptedBlobInterceptor] OkHttp interceptor. + * The interceptor calls [decrypt]/[decryptOrNull] on downloaded bytes, + * and this cipher delegates to [Mip04MediaEncryption.decrypt]. + * + * Note: [encrypt] is not used by the interceptor path but is provided + * for completeness. + */ +class Mip04Cipher( + val exporterSecret: ByteArray, + val nonce: ByteArray, + val originalFileHash: ByteArray, + val mimeType: String, + val filename: String, +) : NostrCipher { + override fun name(): String = Mip04MediaEncryption.VERSION + + override fun encrypt(bytesToEncrypt: ByteArray): ByteArray { + val result = Mip04MediaEncryption.encrypt(bytesToEncrypt, exporterSecret, mimeType, filename) + return result.ciphertext + } + + override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + Mip04MediaEncryption.decrypt( + ciphertextWithTag = bytesToDecrypt, + exporterSecret = exporterSecret, + nonce = nonce, + originalFileHash = originalFileHash, + mimeType = mimeType, + filename = filename, + ) + + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt new file mode 100644 index 000000000..248887a5e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04IMetaTag.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 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.marmot.mip04EncryptedMedia + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder + +/** + * MIP-04 imeta tag field names per the spec. + */ +object Mip04Fields { + const val URL = "url" + const val MIME_TYPE = "m" + const val FILENAME = "filename" + const val DIMENSIONS = "dim" + const val BLURHASH = "blurhash" + const val THUMBHASH = "thumbhash" + const val FILE_HASH = "x" + const val NONCE = "n" + const val VERSION = "v" +} + +/** + * Parsed MIP-04 encrypted media metadata from an imeta tag. + */ +data class Mip04MediaMeta( + val url: String, + val mimeType: String, + val filename: String, + val originalFileHash: String, + val nonce: String, + val version: String, + val dimensions: String? = null, + val blurhash: String? = null, + val thumbhash: String? = null, +) { + val nonceBytes: ByteArray get() = nonce.hexToByteArray() + val originalFileHashBytes: ByteArray get() = originalFileHash.hexToByteArray() + + val isV2: Boolean get() = version == Mip04MediaEncryption.VERSION +} + +/** + * Parse an IMetaTag into MIP-04 media metadata. + * Returns null if the tag is not a valid MIP-04 v2 imeta. + */ +fun IMetaTag.toMip04MediaMeta(): Mip04MediaMeta? { + val mimeType = properties[Mip04Fields.MIME_TYPE]?.firstOrNull() ?: return null + val filename = properties[Mip04Fields.FILENAME]?.firstOrNull() ?: return null + val fileHash = properties[Mip04Fields.FILE_HASH]?.firstOrNull() ?: return null + val nonce = properties[Mip04Fields.NONCE]?.firstOrNull() ?: return null + val version = properties[Mip04Fields.VERSION]?.firstOrNull() ?: return null + + if (version != Mip04MediaEncryption.VERSION) return null + if (nonce.length != 24) return null // 12 bytes = 24 hex chars + + return Mip04MediaMeta( + url = url, + mimeType = mimeType, + filename = filename, + originalFileHash = fileHash, + nonce = nonce, + version = version, + dimensions = properties[Mip04Fields.DIMENSIONS]?.firstOrNull(), + blurhash = properties[Mip04Fields.BLURHASH]?.firstOrNull(), + thumbhash = properties[Mip04Fields.THUMBHASH]?.firstOrNull(), + ) +} + +/** + * Build an MIP-04 imeta tag from encryption results and file metadata. + */ +fun buildMip04IMetaTag( + url: String, + mimeType: String, + filename: String, + originalFileHash: ByteArray, + nonce: ByteArray, + dimensions: String? = null, + blurhash: String? = null, +): IMetaTag = + IMetaTagBuilder(url).apply { + add(Mip04Fields.MIME_TYPE, Mip04MediaEncryption.canonicalizeMimeType(mimeType)) + add(Mip04Fields.FILENAME, filename) + add(Mip04Fields.FILE_HASH, originalFileHash.toHexKey()) + add(Mip04Fields.NONCE, nonce.toHexKey()) + add(Mip04Fields.VERSION, Mip04MediaEncryption.VERSION) + dimensions?.let { add(Mip04Fields.DIMENSIONS, it) } + blurhash?.let { add(Mip04Fields.BLURHASH, it) } + }.build() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt new file mode 100644 index 000000000..7fd2aa01e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04MediaEncryption.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 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.marmot.mip04EncryptedMedia + +import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * MIP-04 Encrypted Media (Version 2) implementation. + * + * Encrypts/decrypts media files for Marmot groups using ChaCha20-Poly1305 AEAD + * with keys derived from MLS exporter secrets. + * + * Key derivation: + * exporter_secret = MLS-Exporter("marmot", "encrypted-media", 32) + * file_key = HKDF-Expand(exporter_secret, context, 32) + * nonce = Random(12) + * + * Context layout: + * "mip04-v2" || 0x00 || file_hash(32) || 0x00 || mime_type || 0x00 || filename || 0x00 || "key" + * + * AAD layout: + * "mip04-v2" || 0x00 || file_hash(32) || 0x00 || mime_type || 0x00 || filename + */ +object Mip04MediaEncryption { + const val VERSION = "mip04-v2" + const val EXPORTER_LABEL = "marmot" + const val EXPORTER_CONTEXT = "encrypted-media" + const val EXPORTER_KEY_LENGTH = 32 + + private const val KEY_LENGTH = 32 + private const val NONCE_LENGTH = 12 + private val SCHEME_BYTES = VERSION.encodeToByteArray() + private val KEY_SUFFIX = "key".encodeToByteArray() + private val NULL_SEPARATOR = byteArrayOf(0x00) + + /** + * Derive the MIP-04 file encryption key from the MLS exporter secret. + * + * @param exporterSecret 32-byte MLS-Exporter("marmot", "encrypted-media", 32) + * @param originalFileHash SHA256 hash of the original (unencrypted) file content (32 bytes) + * @param mimeType canonical MIME type (e.g. "image/jpeg") + * @param filename original filename (e.g. "photo.jpg") + * @return 32-byte file encryption key + */ + fun deriveFileKey( + exporterSecret: ByteArray, + originalFileHash: ByteArray, + mimeType: String, + filename: String, + ): ByteArray { + require(exporterSecret.size == EXPORTER_KEY_LENGTH) { + "Exporter secret must be $EXPORTER_KEY_LENGTH bytes" + } + require(originalFileHash.size == 32) { + "File hash must be 32 bytes" + } + + val info = buildContext(originalFileHash, mimeType, filename, KEY_SUFFIX) + return MlsCryptoProvider.hkdfExpand(exporterSecret, info, KEY_LENGTH) + } + + /** + * Encrypt a file's bytes using MIP-04 v2. + * + * @param plaintext original file bytes + * @param exporterSecret 32-byte MLS exporter secret + * @param mimeType canonical MIME type + * @param filename original filename + * @return encryption result with ciphertext, nonce, and file hash + */ + fun encrypt( + plaintext: ByteArray, + exporterSecret: ByteArray, + mimeType: String, + filename: String, + ): Mip04EncryptionResult { + val originalFileHash = sha256(plaintext) + val fileKey = deriveFileKey(exporterSecret, originalFileHash, mimeType, filename) + val nonce = RandomInstance.bytes(NONCE_LENGTH) + val aad = buildAad(originalFileHash, mimeType, filename) + val ciphertextWithTag = ChaCha20Poly1305.encrypt(plaintext, aad, nonce, fileKey) + + return Mip04EncryptionResult( + ciphertext = ciphertextWithTag, + nonce = nonce, + originalFileHash = originalFileHash, + ) + } + + /** + * Decrypt a file's bytes using MIP-04 v2. + * + * @param ciphertextWithTag encrypted bytes (ciphertext + 16-byte Poly1305 tag) + * @param exporterSecret 32-byte MLS exporter secret + * @param nonce 12-byte nonce from the imeta tag + * @param originalFileHash SHA256 hash of the original file (from imeta "x" field) + * @param mimeType canonical MIME type (from imeta "m" field) + * @param filename original filename (from imeta "filename" field) + * @return decrypted file bytes + * @throws IllegalStateException if authentication fails + */ + fun decrypt( + ciphertextWithTag: ByteArray, + exporterSecret: ByteArray, + nonce: ByteArray, + originalFileHash: ByteArray, + mimeType: String, + filename: String, + ): ByteArray { + require(nonce.size == NONCE_LENGTH) { "Nonce must be $NONCE_LENGTH bytes" } + require(originalFileHash.size == 32) { "File hash must be 32 bytes" } + + val fileKey = deriveFileKey(exporterSecret, originalFileHash, mimeType, filename) + val aad = buildAad(originalFileHash, mimeType, filename) + val decrypted = ChaCha20Poly1305.decrypt(ciphertextWithTag, aad, nonce, fileKey) + + // Integrity verification: SHA256(decrypted) must match originalFileHash + val actualHash = sha256(decrypted) + check(actualHash.contentEquals(originalFileHash)) { + "Integrity verification failed: decrypted file hash does not match original" + } + + return decrypted + } + + /** + * Build the HKDF-Expand context for key derivation: + * "mip04-v2" || 0x00 || file_hash(32) || 0x00 || mime_type || 0x00 || filename || 0x00 || suffix + */ + private fun buildContext( + fileHash: ByteArray, + mimeType: String, + filename: String, + suffix: ByteArray, + ): ByteArray { + val mimeBytes = canonicalizeMimeType(mimeType).encodeToByteArray() + val filenameBytes = filename.encodeToByteArray() + + val size = SCHEME_BYTES.size + 1 + fileHash.size + 1 + mimeBytes.size + 1 + filenameBytes.size + 1 + suffix.size + val result = ByteArray(size) + var offset = 0 + + SCHEME_BYTES.copyInto(result, offset); offset += SCHEME_BYTES.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + fileHash.copyInto(result, offset); offset += fileHash.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + mimeBytes.copyInto(result, offset); offset += mimeBytes.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + filenameBytes.copyInto(result, offset); offset += filenameBytes.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + suffix.copyInto(result, offset) + + return result + } + + /** + * Build the AAD (Additional Authenticated Data): + * "mip04-v2" || 0x00 || file_hash(32) || 0x00 || mime_type || 0x00 || filename + */ + private fun buildAad( + fileHash: ByteArray, + mimeType: String, + filename: String, + ): ByteArray { + val mimeBytes = canonicalizeMimeType(mimeType).encodeToByteArray() + val filenameBytes = filename.encodeToByteArray() + + val size = SCHEME_BYTES.size + 1 + fileHash.size + 1 + mimeBytes.size + 1 + filenameBytes.size + val result = ByteArray(size) + var offset = 0 + + SCHEME_BYTES.copyInto(result, offset); offset += SCHEME_BYTES.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + fileHash.copyInto(result, offset); offset += fileHash.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + mimeBytes.copyInto(result, offset); offset += mimeBytes.size + NULL_SEPARATOR.copyInto(result, offset); offset += 1 + filenameBytes.copyInto(result, offset) + + return result + } + + /** + * Canonicalize a MIME type per MIP-04: + * - Lowercase + * - Trim whitespace + * - Strip parameters (e.g., "; charset=utf-8") + */ + fun canonicalizeMimeType(mimeType: String): String = + mimeType.trim().lowercase().substringBefore(";").trim() +} + +class Mip04EncryptionResult( + val ciphertext: ByteArray, + val nonce: ByteArray, + val originalFileHash: ByteArray, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04NostrCipher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04NostrCipher.kt new file mode 100644 index 000000000..1d79da319 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip04EncryptedMedia/Mip04NostrCipher.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 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.marmot.mip04EncryptedMedia + +import com.vitorpamplona.quartz.utils.ciphers.NostrCipher + +/** + * NostrCipher implementation for MIP-04 media encryption. + * + * Used as an adapter to integrate MIP-04 encryption into the existing + * [EncryptFiles] upload pipeline. After calling [encrypt], the [nonce] + * and [originalFileHash] fields are populated and can be read to build + * the imeta tag. + * + * @param exporterSecret 32-byte MLS-Exporter("marmot", "encrypted-media", 32) + * @param mimeType canonical MIME type for key derivation context + * @param filename original filename for key derivation context + */ +class Mip04NostrCipher( + val exporterSecret: ByteArray, + val mimeType: String, + val filename: String, +) : NostrCipher { + var nonce: ByteArray = ByteArray(0) + private set + + var originalFileHash: ByteArray = ByteArray(0) + private set + + override fun name(): String = Mip04MediaEncryption.VERSION + + override fun encrypt(bytesToEncrypt: ByteArray): ByteArray { + val result = Mip04MediaEncryption.encrypt(bytesToEncrypt, exporterSecret, mimeType, filename) + nonce = result.nonce + originalFileHash = result.originalFileHash + return result.ciphertext + } + + override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = + Mip04MediaEncryption.decrypt( + ciphertextWithTag = bytesToDecrypt, + exporterSecret = exporterSecret, + nonce = nonce, + originalFileHash = originalFileHash, + mimeType = mimeType, + filename = filename, + ) + + override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = + try { + decrypt(bytesToDecrypt) + } catch (_: Exception) { + null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 65c99ba55..7baddccba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -446,6 +446,17 @@ class MlsGroupManager( 32, ) + /** + * Export the MIP-04 encrypted media key for a group. + * MLS-Exporter("marmot", "encrypted-media", 32) + */ + fun mediaExporterSecret(nostrGroupId: HexKey): ByteArray = + requireGroup(nostrGroupId).exporterSecret( + "marmot", + "encrypted-media".encodeToByteArray(), + 32, + ) + /** * Return exporter secrets from retained epochs for a group. *