feat: implement MIP-04 encrypted media upload for Marmot groups

Adds full MIP-04 (Encrypted Media) support for Marmot group chats:

Protocol layer (quartz):
- Mip04MediaEncryption: ChaCha20-Poly1305 AEAD with MLS exporter key
  derivation (HKDF-Expand), random nonces, and AAD binding
- Mip04Cipher/Mip04NostrCipher: NostrCipher adapters for the existing
  EncryptedBlobInterceptor download-and-decrypt pipeline
- Mip04IMetaTag: NIP-92 imeta tag builder/parser with MIP-04 fields
  (url, m, filename, x, n, v=mip04-v2)
- MlsGroupManager.mediaExporterSecret(): MLS-Exporter("marmot",
  "encrypted-media", 32)

Upload flow (amethyst):
- MarmotFileUploader: per-file Mip04NostrCipher → existing
  UploadOrchestrator.uploadEncrypted pipeline (compression, stripping,
  Blossom upload)
- MarmotFileSender: builds imeta tags and sends kind:9 inner events
  through the MLS group message pipeline
- MarmotGroupMessageComposer: adds SelectFromGallery leading icon and
  ChatFileUploadDialog (mirrors NIP-17 DM pattern)

Display flow:
- RenderMarmotEncryptedMedia: parses imeta v=mip04-v2 tags, derives
  Mip04Cipher, registers in EncryptionKeyCache, renders via
  ZoomableContentView (same path as NIP-17 encrypted files)
- MarmotGroupList.noteToGroupIndex: reverse index for mapping notes
  back to their group ID (needed for exporter secret lookup)
- ChatMessageCompose NoteRow: routes MIP-04 events to the new renderer

https://claude.ai/code/session_01TckzZLpJdmE6p198DHBQ5i
This commit is contained in:
Claude
2026-04-16 02:08:45 +00:00
parent 3356059d50
commit 5f5d576f1e
13 changed files with 1054 additions and 5 deletions
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event>(
kind = 9,
description = url,
) {
com.vitorpamplona.quartz.nip92IMeta.imeta(imeta)
if (!caption.isNullOrEmpty()) {
com.vitorpamplona.quartz.nip31Alts.alt(caption)
}
}
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(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)
}
@@ -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)
}
}
@@ -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<Color>,
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<Color>,
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<BaseMediaContent>(
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<Color>,
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)
@@ -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<SelectedMedia>) -> 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,
)
}
@@ -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<Mip04UploadResult>) {
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,
)
}
}
}
@@ -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<Mip04UploadResult>) -> Unit,
) {
val multiOrchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(multiOrchestrator.hasNonMedia())
val results = mutableListOf<Mip04UploadResult>()
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"
}
}
@@ -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.
@@ -36,6 +36,8 @@ class MarmotGroupList(
var rooms = LargeCache<HexKey, MarmotGroupChatroom>()
private set
private val noteToGroupIndex = LargeCache<HexKey, HexKey>()
private val _groupListChanges = MutableSharedFlow<HexKey>(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"
@@ -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
}
}
@@ -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()
@@ -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,
)
@@ -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
}
}
@@ -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.
*