feat: Marmot group icons — canonical encryption, feed display, metadata editing

Add first-class support for Marmot (MLS-over-Nostr) group avatars.

Protocol (quartz):
- Implement the canonical `marmot-group-image-v1` scheme: raw ChaCha20-Poly1305
  key + 12-byte nonce, AAD = "marmot-group-image-v1" || 0x00 || media_type,
  image_hash = SHA-256(ciphertext). MarmotGroupImageEncryption emits canonical
  and decrypts both canonical and the deprecated MIP-01 HKDF-seed scheme.
- Add the `image_media_type` field to MarmotGroupData as a trailing TLS field
  (older readers ignore it; disappearing_message_secs stays positionally
  unambiguous). Add withImage/withoutImage helpers.
- MarmotGroupImageCipher (NostrCipher) drives both encrypted upload and
  transparent decrypt-on-download.

Model/manager (commons):
- MarmotGroupChatroom exposes an `image` StateFlow; MarmotManager.syncMetadataTo
  populates it from the group metadata.

Android:
- Show the decrypted group icon in the Messages feed; when a group has no image,
  fall back to the NIP-11 icon of one of its relays (fetched on cache miss).
- Group metadata editing gains an icon picker (add/change/remove) in both the
  create and edit screens; create also gains a description field. Icons are
  encrypted and uploaded to Blossom via UploadOrchestrator, signed with a fresh
  per-image keypair stored as image_upload_key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
This commit is contained in:
Claude
2026-07-14 23:17:34 +00:00
parent 9d5bd48164
commit 339d7c10e7
15 changed files with 1123 additions and 21 deletions
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.LruCache
@@ -94,6 +95,9 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUpload
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconUploader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
@@ -105,6 +109,7 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -2127,10 +2132,24 @@ class AccountViewModel(
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
/**
* Encrypt + upload a picked image as a group avatar (canonical
* `marmot-group-image-v1` scheme). The returned handle is later passed to
* [updateMarmotGroupMetadata] as [MarmotGroupIconChange.Set] to commit it into
* the group's metadata. Uploading is separated from the metadata commit so the
* (slow) Blossom upload can show its own progress before the commit is signed.
*/
suspend fun uploadMarmotGroupIcon(
uri: Uri,
mimeType: String?,
context: Context,
): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, context)
suspend fun updateMarmotGroupMetadata(
nostrGroupId: String,
name: String,
description: String,
icon: MarmotGroupIconChange = MarmotGroupIconChange.Keep,
) {
// Stamp the inviter's outbox relays into the group metadata so that
// every member ends up with a single canonical relay set for kind:445
@@ -2143,18 +2162,30 @@ class AccountViewModel(
account.outboxRelays.flow.value
.map { it.url }
val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId)
val updatedMetadata =
val baseMetadata =
currentMetadata
?.copy(name = name, description = description)
?.withMergedRelays(outboxRelayStrings)
?: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
.bootstrap(
nostrGroupId = nostrGroupId,
creatorPubKey = account.signer.pubKey,
outboxRelays = outboxRelayStrings,
name = name,
description = description,
?: MarmotGroupData.bootstrap(
nostrGroupId = nostrGroupId,
creatorPubKey = account.signer.pubKey,
outboxRelays = outboxRelayStrings,
name = name,
description = description,
)
val updatedMetadata =
when (icon) {
is MarmotGroupIconChange.Keep -> baseMetadata
is MarmotGroupIconChange.Clear -> baseMetadata.withoutImage()
is MarmotGroupIconChange.Set ->
baseMetadata.withImage(
imageHash = icon.upload.imageHash,
imageKey = icon.upload.imageKey,
imageNonce = icon.upload.imageNonce,
imageUploadKey = icon.upload.imageUploadKey,
imageMediaType = icon.upload.mediaType,
)
}
val relays = account.marmotGroupRelays(nostrGroupId)
account.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays)
}
@@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
@@ -42,10 +44,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -58,6 +62,8 @@ fun CreateGroupScreen(
nav: INav,
) {
var groupName by remember { mutableStateOf("") }
var groupDescription by remember { mutableStateOf("") }
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
var isCreating by remember { mutableStateOf(false) }
var showKeyPackageRelayDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -69,6 +75,14 @@ fun CreateGroupScreen(
try {
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
accountViewModel.createMarmotGroup(nostrGroupId)
// Encrypt + upload the picked icon (if any) before the metadata commit,
// so its parameters land in the group's MarmotGroupData extension.
val iconChange =
pickedIcon?.let { media ->
MarmotGroupIconChange.Set(
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, context),
)
} ?: MarmotGroupIconChange.Keep
// Always commit an initial metadata extension so that
// (a) the name (if any) is persisted in MLS extensions
// and survives app restarts,
@@ -80,7 +94,8 @@ fun CreateGroupScreen(
accountViewModel.updateMarmotGroupMetadata(
nostrGroupId = nostrGroupId,
name = groupName.trim(),
description = "",
description = groupDescription.trim(),
icon = iconChange,
)
nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class)
} catch (e: Exception) {
@@ -126,12 +141,39 @@ fun CreateGroupScreen(
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
)
MarmotGroupIconEditor(
groupId = "",
existingImage = null,
pickedMedia = pickedIcon,
removeRequested = false,
enabled = !isCreating,
accountViewModel = accountViewModel,
onPick = { pickedIcon = it },
onRemove = { pickedIcon = null },
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = groupName,
onValueChange = { groupName = it },
label = { Text(stringRes(R.string.marmot_group_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = groupDescription,
onValueChange = { groupDescription = it },
label = { Text(stringRes(R.string.description)) },
placeholder = { Text(stringRes(R.string.marmot_group_description_placeholder)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
enabled = !isCreating,
)
Text(
@@ -43,9 +43,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
@@ -63,14 +65,18 @@ fun EditGroupInfoScreen(
}
val currentName by chatroom.displayName.collectAsStateWithLifecycle()
val currentDescription by chatroom.description.collectAsStateWithLifecycle()
val currentImage by chatroom.image.collectAsStateWithLifecycle()
var name by remember(currentName) { mutableStateOf(currentName ?: "") }
var description by remember(currentDescription) { mutableStateOf(currentDescription ?: "") }
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
var removeIcon by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
val hasChanges = name != (currentName ?: "") || description != (currentDescription ?: "")
val iconChanged = pickedIcon != null || removeIcon
val hasChanges = name != (currentName ?: "") || description != (currentDescription ?: "") || iconChanged
Scaffold(
topBar = {
@@ -81,10 +87,17 @@ fun EditGroupInfoScreen(
isSaving = true
scope.launch(Dispatchers.IO) {
try {
val iconChange =
pickedIcon?.let { media ->
MarmotGroupIconChange.Set(
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, context),
)
} ?: if (removeIcon) MarmotGroupIconChange.Clear else MarmotGroupIconChange.Keep
accountViewModel.updateMarmotGroupMetadata(
nostrGroupId = nostrGroupId,
name = name.trim(),
description = description.trim(),
icon = iconChange,
)
launch(Dispatchers.Main) {
Toast
@@ -119,6 +132,25 @@ fun EditGroupInfoScreen(
) {
Spacer(modifier = Modifier.height(8.dp))
MarmotGroupIconEditor(
groupId = nostrGroupId,
existingImage = currentImage,
pickedMedia = pickedIcon,
removeRequested = removeIcon,
enabled = !isSaving,
accountViewModel = accountViewModel,
onPick = {
pickedIcon = it
removeIcon = false
},
onRemove = {
pickedIcon = null
removeIcon = true
},
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -0,0 +1,72 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
/**
* Resolve the URL from which a Marmot group's encrypted avatar can be loaded, and
* register its decryption cipher in the encrypted-blob HTTP cache so any Coil load
* of that URL transparently yields the decrypted image (via `EncryptedBlobInterceptor`).
*
* The blob is content-addressed on Blossom by [MarmotGroupImage.hash]; because the
* canonical scheme stores only the hash (not a URL), we reconstruct the URL against
* the viewer's default Blossom server. When the blob does not live there, the load
* simply fails and callers fall back to the relay icon.
*
* Returns null when there is no image to show.
*/
@Composable
fun rememberMarmotGroupIconUrl(
image: MarmotGroupImage?,
accountViewModel: AccountViewModel,
): String? {
if (image == null) return null
val serverBaseUrl = accountViewModel.account.settings.defaultFileServer.baseUrl
val url = remember(image.hash, serverBaseUrl) { BlossomServerUrl.blob(serverBaseUrl, image.hash) }
val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce, image.mediaType) }
Amethyst.instance.keyCache.add(url, cipher, image.mediaType)
return url
}
/**
* The NIP-11 icon of the group's first resolvable relay, used as a fallback avatar
* when the group has no image of its own. Fetches the relay's NIP-11 document on a
* cache miss. Returns null when the group has no valid relay or the relay advertises
* no icon.
*/
@Composable
fun loadMarmotRelayIcon(relays: List<String>): String? {
val relay = remember(relays) { relays.firstNotNullOfOrNull { RelayUrlNormalizer.normalizeOrNull(it) } } ?: return null
val relayInfo by loadRelayInfo(relay)
return relayInfo.icon?.ifBlank { null }
}
@@ -0,0 +1,123 @@
/*
* 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
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A circular group-avatar editor used by the create and edit metadata screens.
*
* Renders (in priority order) the freshly-[pickedMedia] image, a placeholder when the
* icon is [removeRequested], or the group's current (decrypted) avatar. Tapping the
* avatar opens the system photo picker; a text button below removes the current icon.
* All selection state is hoisted so the parent screen can turn it into a
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange]
* at save time.
*/
@Composable
fun MarmotGroupIconEditor(
groupId: HexKey,
existingImage: MarmotGroupImage?,
pickedMedia: SelectedMedia?,
removeRequested: Boolean,
enabled: Boolean,
accountViewModel: AccountViewModel,
onPick: (SelectedMedia) -> Unit,
onRemove: () -> Unit,
) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onPick(SelectedMedia(uri, resolver.getType(uri)))
}
val model =
when {
pickedMedia != null -> pickedMedia.uri.toString()
removeRequested -> null
else -> rememberMarmotGroupIconUrl(existingImage, accountViewModel)
}
val hasIcon = pickedMedia != null || (existingImage != null && !removeRequested)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RobohashFallbackAsyncImage(
robot = groupId,
model = model,
contentDescription = stringRes(R.string.marmot_group_icon),
modifier =
Modifier
.size(96.dp)
.clip(CircleShape)
.let { if (enabled) it.clickable { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } else it },
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = enabled,
onClick = { launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
) {
Text(stringRes(if (hasIcon) R.string.marmot_change_photo else R.string.marmot_add_photo))
}
if (hasIcon) {
TextButton(
enabled = enabled,
onClick = onRemove,
) {
Text(stringRes(R.string.marmot_remove_photo))
}
}
}
}
}
@@ -0,0 +1,135 @@
/*
* 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.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
/**
* The parameters produced by encrypting + uploading a new Marmot group avatar,
* ready to be folded into a [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData]
* via [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData.withImage].
*/
class MarmotGroupIconUpload(
/** SHA-256 (hex) of the encrypted blob = its Blossom content hash. */
val imageHash: HexKey,
/** Raw 32-byte ChaCha20-Poly1305 key. */
val imageKey: ByteArray,
/** 12-byte nonce. */
val imageNonce: ByteArray,
/** Raw 32-byte Blossom-auth secret key. */
val imageUploadKey: ByteArray,
/** Canonical MIME type of the plaintext image. */
val mediaType: String,
)
/**
* How a metadata update should treat the group icon.
*/
sealed class MarmotGroupIconChange {
/** Leave the existing icon (if any) untouched. */
data object Keep : MarmotGroupIconChange()
/** Remove the current icon. */
data object Clear : MarmotGroupIconChange()
/** Replace the icon with a freshly-uploaded one. */
class Set(
val upload: MarmotGroupIconUpload,
) : MarmotGroupIconChange()
}
/**
* Encrypts a picked image with the canonical `marmot-group-image-v1` scheme and
* uploads the ciphertext to Blossom, signing the upload authorization with a fresh
* keypair (so any admin holding `image_upload_key` can later replace/delete it).
*
* Reuses [UploadOrchestrator.uploadEncrypted] for compression, metadata stripping,
* upload, and re-download verification — the same pipeline as MIP-04 message media.
*/
class MarmotGroupIconUploader(
val account: Account,
) {
suspend fun upload(
uri: Uri,
mimeType: String?,
server: ServerName,
context: Context,
): MarmotGroupIconUpload {
val mediaType = Mip04MediaEncryption.canonicalizeMimeType(mimeType ?: DEFAULT_MIME)
val cipher = MarmotGroupImageCipher.forNewImage(mediaType)
val uploadKey = MarmotGroupImageEncryption.generateUploadKey()
val uploadSigner = NostrSignerInternal(KeyPair(privKey = uploadKey))
val state =
UploadOrchestrator().uploadEncrypted(
uri = uri,
mimeType = mediaType,
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.MEDIUM,
encrypt = cipher,
server = server,
account = account,
context = context,
stripMetadata = true,
forcedSigner = uploadSigner,
)
if (state is UploadingState.Finished && state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val serverResult = state.result
val hash =
serverResult.uploadedHash
?: throw IllegalStateException("Blossom server did not return a content hash for the group icon")
return MarmotGroupIconUpload(
imageHash = hash,
imageKey = cipher.imageKey,
imageNonce = cipher.imageNonce,
imageUploadKey = uploadKey,
mediaType = mediaType,
)
}
val message =
if (state is UploadingState.Error) {
stringRes(context, state.errorResource, *state.params)
} else {
"Group icon upload failed"
}
throw IllegalStateException(message)
}
companion object {
private const val DEFAULT_MIME = "image/jpeg"
}
}
@@ -76,7 +76,9 @@ import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote
@@ -318,11 +320,22 @@ private fun MarmotGroupRoomCompose(
nav: INav,
) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val image by chatroom.image.collectAsStateWithLifecycle()
val relays by chatroom.relays.collectAsStateWithLifecycle()
val author = lastMessage.author
val noteEvent = lastMessage.event
val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}"
// Prefer the group's own (encrypted) avatar; when it has none, fall back to the
// NIP-11 icon of one of the group's relays (fetched on a cache miss).
val channelPicture =
if (image != null) {
rememberMarmotGroupIconUrl(image, accountViewModel)
} else {
loadMarmotRelayIcon(relays)
}
val lastContent =
if (author != null && noteEvent != null) {
val authorName by observeUserName(author, accountViewModel)
@@ -335,7 +348,7 @@ private fun MarmotGroupRoomCompose(
ChannelName(
channelIdHex = chatroom.nostrGroupId,
channelPicture = null,
channelPicture = channelPicture,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) },
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
+4
View File
@@ -4059,6 +4059,10 @@
<string name="marmot_group_name_placeholder">Enter group name</string>
<string name="marmot_group_description_placeholder">Enter group description (optional)</string>
<string name="marmot_edit_info_footer">Changes will be committed to the group via MLS and propagated to all members.</string>
<string name="marmot_group_icon">Group icon</string>
<string name="marmot_add_photo">Add photo</string>
<string name="marmot_change_photo">Change photo</string>
<string name="marmot_remove_photo">Remove photo</string>
<string name="marmot_group_info_updated">Group info updated</string>
<string name="marmot_failed_to_update">Failed to update: %1$s</string>
<string name="marmot_failed_to_create_group">Failed to create group: %1$s</string>
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage
import com.vitorpamplona.quartz.marmot.GroupEventResult
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
import com.vitorpamplona.quartz.marmot.MarmotOutboundProcessor
@@ -742,6 +743,17 @@ class MarmotManager(
}
chatroom.adminPubkeys.value = metadata.adminPubkeys
chatroom.relays.value = metadata.relays
chatroom.image.value =
if (metadata.hasImage()) {
MarmotGroupImage(
hash = metadata.imageHash!!,
key = metadata.imageKey!!,
nonce = metadata.imageNonce!!,
mediaType = metadata.imageMediaType,
)
} else {
null
}
}
val previousCount = chatroom.members.value.size
val members = memberPubkeys(nostrGroupId)
@@ -48,6 +48,13 @@ class MarmotGroupChatroom(
var messages: Set<Note> = setOf()
var displayName = MutableStateFlow<String?>(null)
var description = MutableStateFlow<String?>(null)
/**
* The group's encrypted avatar image parameters (Blossom hash + decryption key/nonce),
* or null when the group has no image set. Front ends fetch the blob by hash and decrypt
* it; when null they fall back to the host relay's NIP-11 icon.
*/
var image = MutableStateFlow<MarmotGroupImage?>(null)
var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList())
var relays = MutableStateFlow<List<String>>(emptyList())
var memberCount = MutableStateFlow(0)
@@ -0,0 +1,61 @@
/*
* 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.commons.model.marmotGroups
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* The parameters needed to fetch and decrypt a Marmot group's avatar image.
*
* Extracted from the group's [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData]
* so front ends can render the icon: the encrypted blob is content-addressed on Blossom by
* [hash], and decrypted with [key]/[nonce] (and [mediaType] for the AEAD associated data)
* via [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption].
*/
@Immutable
class MarmotGroupImage(
/** SHA-256 (hex) of the encrypted blob — the Blossom content hash. */
val hash: HexKey,
/** Raw ChaCha20-Poly1305 key (canonical scheme) or HKDF seed (legacy). */
val key: ByteArray,
/** 12-byte ChaCha20-Poly1305 nonce. */
val nonce: ByteArray,
/** Canonical plaintext MIME type; null for legacy groups predating the field. */
val mediaType: String?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MarmotGroupImage) return false
return hash == other.hash &&
key.contentEquals(other.key) &&
nonce.contentEquals(other.nonce) &&
mediaType == other.mediaType
}
override fun hashCode(): Int {
var result = hash.hashCode()
result = 31 * result + key.contentHashCode()
result = 31 * result + nonce.contentHashCode()
result = 31 * result + (mediaType?.hashCode() ?: 0)
return result
}
}
@@ -49,14 +49,21 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
* RelayUrl relays<0..2^16-1>;
* opaque image_hash<0..32>;
* opaque image_key<0..32>; // HKDF seed for encryption key derivation
* opaque image_key<0..32>; // canonical: raw ChaCha20-Poly1305 key
* opaque image_nonce<0..12>;
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
* opaque image_upload_key<0..32>; // canonical: raw Blossom-auth secret key
* opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever,
* // 8 bytes big-endian uint64 = expiration secs
* // (value 0 is rejected)
* opaque image_media_type<0..128>; // trailing: canonical MIME of the plaintext image
* } NostrGroupData;
* ```
*
* The image fields carry the canonical `marmot.group.blossom.image.v1` app-component
* data (see [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are RAW keys,
* not HKDF seeds; `image_hash` is the SHA-256 of the encrypted blob. `image_media_type`
* is appended after `disappearing_message_secs` so older readers ignore it (forward
* compatibility); it feeds the AEAD associated data on decrypt.
*/
@Immutable
data class MarmotGroupData(
@@ -80,13 +87,13 @@ data class MarmotGroupData(
val adminPubkeys: List<HexKey> = emptyList(),
/** Relay URLs for group message distribution. SHOULD contain at least one. */
val relays: List<String> = emptyList(),
/** SHA-256 hash of the encrypted group image (hex). Empty if no image. */
/** SHA-256 hash (hex) of the ENCRYPTED group image blob (= its Blossom hash). Null if no image. */
val imageHash: HexKey? = null,
/** HKDF seed for deriving the image encryption key. Empty if no image. */
/** Raw 32-byte ChaCha20-Poly1305 key for the image blob (canonical scheme). Null if no image. */
val imageKey: ByteArray? = null,
/** ChaCha20-Poly1305 nonce for image encryption. Empty if no image. */
/** 12-byte ChaCha20-Poly1305 nonce for image encryption. Null if no image. */
val imageNonce: ByteArray? = null,
/** HKDF seed for deriving the Blossom upload keypair. Empty if no image. */
/** Raw 32-byte secret key of the fresh Nostr keypair that authorizes Blossom writes. Null if no image. */
val imageUploadKey: ByteArray? = null,
/**
* Disappearing-message duration in seconds (v3+).
@@ -95,6 +102,12 @@ data class MarmotGroupData(
* Per MIP-01, a value of `0` MUST be rejected.
*/
val disappearingMessageSecs: ULong? = null,
/**
* Canonical MIME type of the plaintext image (e.g. `image/jpeg`), fed into the
* `marmot-group-image-v1` AEAD associated data. `null` for legacy groups that
* predate the field; the decryptor then falls back to the deprecated scheme.
*/
val imageMediaType: String? = null,
) {
init {
require(version > 0) { "MarmotGroupData version 0 is reserved/invalid" }
@@ -112,6 +125,35 @@ data class MarmotGroupData(
/** Whether this group has an encrypted image set */
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
/**
* Return a copy carrying the given (already-encrypted-and-uploaded) group image.
* Keeps all image-field knowledge in one place so the UI and the CLI stay in sync.
*/
fun withImage(
imageHash: HexKey,
imageKey: ByteArray,
imageNonce: ByteArray,
imageUploadKey: ByteArray,
imageMediaType: String,
): MarmotGroupData =
copy(
imageHash = imageHash,
imageKey = imageKey,
imageNonce = imageNonce,
imageUploadKey = imageUploadKey,
imageMediaType = imageMediaType,
)
/** Return a copy with the group image cleared. */
fun withoutImage(): MarmotGroupData =
copy(
imageHash = null,
imageKey = null,
imageNonce = null,
imageUploadKey = null,
imageMediaType = null,
)
/**
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
*
@@ -166,10 +208,13 @@ data class MarmotGroupData(
writer.putOpaqueVarInt(imageNonce ?: ByteArray(0))
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps
// the wire format byte-for-byte compatible with older implementations (MDK v2).
if (version >= 3) {
// disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
// Emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps the wire
// format byte-for-byte compatible with older implementations (MDK v2). It is ALSO
// emitted (as an empty 0-byte field) whenever image_media_type follows, so the
// trailing field stays positionally unambiguous on decode.
val emitDisappearing = version >= 3 || imageMediaType != null
if (emitDisappearing) {
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
val out = ByteArray(8)
@@ -183,6 +228,12 @@ data class MarmotGroupData(
writer.putOpaqueVarInt(disappearingBytes)
}
// Trailing image_media_type (canonical marmot-group-image-v1). Only emitted when
// set; older readers ignore trailing bytes (MIP-01 forward compatibility).
if (imageMediaType != null) {
writer.putOpaqueVarInt(imageMediaType.encodeToByteArray())
}
return writer.toByteArray()
}
@@ -268,6 +319,7 @@ data class MarmotGroupData(
* opaque image_nonce<V>
* opaque image_upload_key<V>
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* opaque image_media_type<V> // trailing: canonical image MIME (empty = none)
* ```
*
* Unknown trailing bytes from future versions are silently ignored for
@@ -336,6 +388,15 @@ data class MarmotGroupData(
}
}
// Trailing image_media_type (canonical marmot-group-image-v1). Absent for
// legacy groups; an empty value decodes to null.
val imageMediaType =
if (reader.hasRemaining) {
reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.decodeToString()
} else {
null
}
MarmotGroupData(
version = version,
nostrGroupId = nostrGroupId,
@@ -348,6 +409,7 @@ data class MarmotGroupData(
imageNonce = imageNonce,
imageUploadKey = imageUploadKey,
disappearingMessageSecs = disappearingMessageSecs,
imageMediaType = imageMediaType,
)
} catch (_: Exception) {
null
@@ -0,0 +1,72 @@
/*
* 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.mip01Groups
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
/**
* [NostrCipher] for a Marmot group avatar, implementing the canonical
* `marmot-group-image-v1` scheme (see [MarmotGroupImageEncryption]).
*
* The same instance serves two paths:
* - **Upload** — the file-upload pipeline calls [encrypt] over the (compressed)
* image bytes; the resulting blob is stored on Blossom and addressed by
* `SHA-256(ciphertext)`. The [imageKey]/[imageNonce] are generated up front so
* the caller can persist them into the group's [MarmotGroupData].
* - **Display** — registered in the encrypted-blob HTTP cache keyed by the blob
* URL, so a fetched avatar is transparently decrypted via [decryptOrNull]
* (which also opens blobs from the deprecated MIP-01 scheme).
*/
class MarmotGroupImageCipher(
/** Raw 32-byte ChaCha20-Poly1305 key (canonical) or HKDF seed (legacy fallback). */
val imageKey: ByteArray,
/** 12-byte nonce. */
val imageNonce: ByteArray,
/** Canonical MIME type of the plaintext image; null only for legacy blobs on decrypt. */
val mediaType: String?,
) : NostrCipher {
override fun name(): String = MarmotGroupImageEncryption.AAD_LABEL
override fun encrypt(bytesToEncrypt: ByteArray): ByteArray {
val type = requireNotNull(mediaType) { "media type is required to encrypt a group image" }
return ChaCha20Poly1305.encrypt(bytesToEncrypt, MarmotGroupImageEncryption.buildAad(type), imageNonce, imageKey)
}
override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = decryptOrNull(bytesToDecrypt) ?: throw IllegalStateException("Failed to decrypt Marmot group image")
override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = MarmotGroupImageEncryption.decryptAny(bytesToDecrypt, imageKey, imageNonce, mediaType)
companion object {
/**
* Build a cipher with a freshly-generated key + nonce, ready to encrypt a new
* avatar. The generated [imageKey]/[imageNonce] are exposed on the returned
* instance so the caller can persist them into [MarmotGroupData].
*/
fun forNewImage(mediaType: String): MarmotGroupImageCipher =
MarmotGroupImageCipher(
imageKey = RandomInstance.bytes(MarmotGroupImageEncryption.KEY_LENGTH),
imageNonce = RandomInstance.bytes(MarmotGroupImageEncryption.NONCE_LENGTH),
mediaType = mediaType,
)
}
}
@@ -0,0 +1,188 @@
/*
* 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.mip01Groups
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* Marmot group image (avatar) encryption.
*
* Implements the canonical `marmot.group.blossom.image.v1` app component scheme
* (the successor to the deprecated MIP-01 image scheme). The plaintext avatar is
* encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded as an opaque
* blob to a Blossom server, addressed by the SHA-256 of the *ciphertext*.
*
* The encryption parameters live inside the group's [MarmotGroupData] extension:
* - `image_key` — the raw 32-byte ChaCha20-Poly1305 key (NOT an HKDF seed).
* - `image_nonce` — the 12-byte nonce.
* - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash).
* - `image_upload_key` — the raw 32-byte secret key of a fresh Nostr keypair used
* to authorize Blossom writes (see [MarmotGroupData.imageUploadKey]).
* - `media_type` — the canonical MIME type of the plaintext image.
*
* ```
* aad = "marmot-group-image-v1" || 0x00 || media_type
* encrypted_blob = ChaCha20-Poly1305.encrypt(image_key, image_nonce, plaintext, aad)
* image_hash = SHA-256(encrypted_blob)
* ```
*
* A fetching client MUST verify that the fetched bytes hash to `image_hash`
* before decrypting.
*
* ### Backward compatibility (parse-both)
* Amethyst never shipped the deprecated MIP-01 image scheme (no client code ever
* populated the image fields), but other clients might have. For robustness,
* [decryptAny] first tries the canonical raw-key scheme and, on authentication
* failure, falls back to the deprecated scheme where `image_key` is an HKDF seed
* ([Mip01ImageCrypto.deriveImageEncryptionKey]) and the AEAD carries no AAD.
*/
object MarmotGroupImageEncryption {
/** ASCII label mixed into the AEAD associated data. */
const val AAD_LABEL = "marmot-group-image-v1"
const val KEY_LENGTH = 32
const val NONCE_LENGTH = 12
private val NULL_SEPARATOR = byteArrayOf(0x00)
private val EMPTY_AAD = ByteArray(0)
/**
* Build the AEAD associated data: `"marmot-group-image-v1" || 0x00 || media_type`.
* The media type is canonicalized the same way MIP-04 canonicalizes it
* (lowercased, trimmed, parameters stripped) so both peers derive identical bytes.
*/
fun buildAad(mediaType: String): ByteArray {
val label = AAD_LABEL.encodeToByteArray()
val mime = Mip04MediaEncryption.canonicalizeMimeType(mediaType).encodeToByteArray()
val out = ByteArray(label.size + 1 + mime.size)
label.copyInto(out, 0)
NULL_SEPARATOR.copyInto(out, label.size)
mime.copyInto(out, label.size + 1)
return out
}
/**
* Result of encrypting a group image, ready to be uploaded to Blossom and
* folded into a [MarmotGroupData].
*/
class Encrypted(
/** The encrypted blob to upload to Blossom (ciphertext || 16-byte tag). */
val ciphertext: ByteArray,
/** Random 32-byte ChaCha20-Poly1305 key — store as `image_key`. */
val imageKey: ByteArray,
/** Random 12-byte nonce — store as `image_nonce`. */
val imageNonce: ByteArray,
/** SHA-256 of [ciphertext] (hex) — store as `image_hash`; also the Blossom hash. */
val imageHash: HexKey,
)
/**
* Encrypt a plaintext image with a freshly-generated key + nonce, per the
* canonical scheme. Returns the ciphertext to upload plus the parameters to
* persist in [MarmotGroupData].
*/
fun encrypt(
plaintext: ByteArray,
mediaType: String,
): Encrypted {
val imageKey = RandomInstance.bytes(KEY_LENGTH)
val imageNonce = RandomInstance.bytes(NONCE_LENGTH)
val ciphertext = ChaCha20Poly1305.encrypt(plaintext, buildAad(mediaType), imageNonce, imageKey)
return Encrypted(
ciphertext = ciphertext,
imageKey = imageKey,
imageNonce = imageNonce,
imageHash = sha256(ciphertext).toHexKey(),
)
}
/**
* Decrypt a group image blob using the canonical raw-key scheme.
*
* @throws IllegalStateException on authentication failure.
*/
fun decrypt(
ciphertext: ByteArray,
imageKey: ByteArray,
imageNonce: ByteArray,
mediaType: String,
): ByteArray = ChaCha20Poly1305.decrypt(ciphertext, buildAad(mediaType), imageNonce, imageKey)
/**
* Decrypt a group image blob, trying the canonical raw-key scheme first and
* falling back to the deprecated MIP-01 HKDF-seed scheme.
*
* Returns null if neither scheme authenticates (wrong key, corrupt blob, or an
* unknown future scheme).
*
* @param mediaType canonical MIME type from [MarmotGroupData.imageMediaType];
* may be null for legacy groups that predate the `media_type` field, in which
* case only the legacy fallback is attempted.
*/
fun decryptAny(
ciphertext: ByteArray,
imageKey: ByteArray,
imageNonce: ByteArray,
mediaType: String?,
): ByteArray? {
if (imageKey.size == KEY_LENGTH && imageNonce.size == NONCE_LENGTH && mediaType != null) {
try {
return decrypt(ciphertext, imageKey, imageNonce, mediaType)
} catch (_: Exception) {
// fall through to the deprecated scheme
}
}
return decryptLegacyOrNull(ciphertext, imageKey, imageNonce)
}
/**
* Deprecated MIP-01 image scheme: `image_key` is an HKDF seed rather than the
* raw AEAD key, and the AEAD carries no associated data. Kept only so we can
* still open avatars produced by pre-canonical clients.
*/
private fun decryptLegacyOrNull(
ciphertext: ByteArray,
imageKeySeed: ByteArray,
imageNonce: ByteArray,
): ByteArray? =
try {
if (imageKeySeed.size != Mip01ImageCrypto.OUTPUT_LENGTH || imageNonce.size != NONCE_LENGTH) {
null
} else {
val key = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed)
ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, key)
}
} catch (_: Exception) {
null
}
/**
* Generate the raw 32-byte secret key of a fresh Nostr keypair to authorize
* Blossom writes — store as [MarmotGroupData.imageUploadKey]. Any admin that
* later holds this value can re-sign uploads/deletions for the blob.
*/
fun generateUploadKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH)
}
@@ -0,0 +1,248 @@
/*
* 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
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class MarmotGroupImageTest {
private val nostrGroupId = "aa".repeat(32)
private val plaintext = "PNGDATA-a-fake-avatar-image-payload".encodeToByteArray()
// ------------------------------------------------------------ encryption
@Test
fun encrypt_thenDecrypt_roundTrips() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png")
assertEquals(MarmotGroupImageEncryption.KEY_LENGTH, enc.imageKey.size)
assertEquals(MarmotGroupImageEncryption.NONCE_LENGTH, enc.imageNonce.size)
val decrypted =
MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/png")
assertContentEquals(plaintext, decrypted)
}
@Test
fun imageHash_isSha256OfCiphertext() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/jpeg")
assertEquals(sha256(enc.ciphertext).toHexKey(), enc.imageHash)
}
@Test
fun mediaType_isCanonicalizedInAad() {
// "IMAGE/PNG; charset=binary" canonicalizes to "image/png" — must still decrypt with "image/png".
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "IMAGE/PNG; charset=binary")
val decrypted =
MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/png")
assertContentEquals(plaintext, decrypted)
}
@Test
fun decrypt_wrongMediaType_fails() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png")
assertFailsWith<IllegalStateException> {
MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/jpeg")
}
}
@Test
fun decryptAny_canonical_succeeds() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/webp")
val out =
MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/webp")
assertNotNull(out)
assertContentEquals(plaintext, out)
}
@Test
fun decryptAny_fallsBackToDeprecatedHkdfScheme() {
// Produce a blob with the DEPRECATED scheme: image_key is an HKDF seed, no AAD.
val seed = RandomInstance.bytes(32)
val nonce = RandomInstance.bytes(12)
val legacyKey = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
val legacyBlob = ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), nonce, legacyKey)
// decryptAny tries canonical first (seed-as-raw-key + media AAD → fails), then legacy.
val out = MarmotGroupImageEncryption.decryptAny(legacyBlob, seed, nonce, "image/png")
assertNotNull(out)
assertContentEquals(plaintext, out)
}
@Test
fun decryptAny_garbage_returnsNull() {
val out =
MarmotGroupImageEncryption.decryptAny(
RandomInstance.bytes(64),
RandomInstance.bytes(32),
RandomInstance.bytes(12),
"image/png",
)
assertNull(out)
}
@Test
fun cipher_encryptDecrypt_roundTrips_asUsedByUploadAndDisplay() {
// The upload path builds a fresh cipher, encrypts, and stores its key/nonce;
// the display path rebuilds the same cipher from those fields and decrypts.
val uploadCipher = MarmotGroupImageCipher.forNewImage("image/png")
val blob = uploadCipher.encrypt(plaintext)
val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce, "image/png")
assertContentEquals(plaintext, displayCipher.decrypt(blob))
assertContentEquals(plaintext, displayCipher.decryptOrNull(blob))
}
@Test
fun cipher_decryptOrNull_wrongKey_returnsNull() {
val uploadCipher = MarmotGroupImageCipher.forNewImage("image/png")
val blob = uploadCipher.encrypt(plaintext)
val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce, "image/png")
assertNull(wrong.decryptOrNull(blob))
}
// ------------------------------------------------------------ wire format
@Test
fun wire_roundTrips_withImageAndMediaType() {
val original =
MarmotGroupData(
version = 2,
nostrGroupId = nostrGroupId,
name = "Otters",
description = "river friends",
adminPubkeys = listOf("bb".repeat(32)),
relays = listOf("wss://relay.example/"),
imageHash = "cc".repeat(32),
imageKey = "dd".repeat(32).hexToByteArray(),
imageNonce = "ee".repeat(12).hexToByteArray(),
imageUploadKey = "ff".repeat(32).hexToByteArray(),
imageMediaType = "image/png",
)
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
assertEquals("Otters", decoded.name)
assertEquals("river friends", decoded.description)
assertEquals("cc".repeat(32), decoded.imageHash)
assertContentEquals("dd".repeat(32).hexToByteArray(), decoded.imageKey)
assertContentEquals("ee".repeat(12).hexToByteArray(), decoded.imageNonce)
assertContentEquals("ff".repeat(32).hexToByteArray(), decoded.imageUploadKey)
assertEquals("image/png", decoded.imageMediaType)
assertNull(decoded.disappearingMessageSecs)
assertTrue(decoded.hasImage())
}
@Test
fun wire_roundTrips_withoutImage() {
val original =
MarmotGroupData(
version = 2,
nostrGroupId = nostrGroupId,
name = "Plain",
adminPubkeys = listOf("bb".repeat(32)),
relays = listOf("wss://relay.example/"),
)
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
assertEquals("Plain", decoded.name)
assertNull(decoded.imageMediaType)
assertNull(decoded.imageHash)
assertTrue(!decoded.hasImage())
}
@Test
fun wire_roundTrips_v3Disappearing_withImage() {
val original =
MarmotGroupData(
version = 3,
nostrGroupId = nostrGroupId,
name = "Ephemeral",
adminPubkeys = listOf("bb".repeat(32)),
relays = emptyList(),
imageHash = "cc".repeat(32),
imageKey = "dd".repeat(32).hexToByteArray(),
imageNonce = "ee".repeat(12).hexToByteArray(),
imageUploadKey = "ff".repeat(32).hexToByteArray(),
imageMediaType = "image/jpeg",
disappearingMessageSecs = 3600UL,
)
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
assertEquals(3600UL, decoded.disappearingMessageSecs)
assertEquals("image/jpeg", decoded.imageMediaType)
assertTrue(decoded.hasImage())
}
@Test
fun wire_v2WithMediaType_readByLegacyDecoderIgnoresTrailing() {
// Emitting media_type at v2 forces an empty disappearing field to keep alignment.
// A reader that stops at disappearing (older logic) must still parse cleanly and
// see no disappearing timer.
val withImage =
MarmotGroupData(
version = 2,
nostrGroupId = nostrGroupId,
name = "Compat",
adminPubkeys = listOf("bb".repeat(32)),
relays = emptyList(),
imageHash = "cc".repeat(32),
imageKey = "dd".repeat(32).hexToByteArray(),
imageNonce = "ee".repeat(12).hexToByteArray(),
imageUploadKey = "ff".repeat(32).hexToByteArray(),
imageMediaType = "image/png",
)
val decoded = assertNotNull(MarmotGroupData.decodeTls(withImage.encodeTls()))
assertNull(decoded.disappearingMessageSecs)
assertEquals("image/png", decoded.imageMediaType)
}
@Test
fun withImage_andWithoutImage_helpers() {
val base =
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "Base",
adminPubkeys = listOf("bb".repeat(32)),
)
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png")
val withImg =
base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32), "image/png")
assertTrue(withImg.hasImage())
assertEquals("image/png", withImg.imageMediaType)
val cleared = withImg.withoutImage()
assertTrue(!cleared.hasImage())
assertNull(cleared.imageMediaType)
assertNull(cleared.imageUploadKey)
}
}