Merge pull request #3568 from vitorpamplona/claude/marmot-group-icons-tlcwa1

Add MIP-01 v2 group avatar encryption and upload support
This commit is contained in:
Vitor Pamplona
2026-07-15 08:14:25 -04:00
committed by GitHub
23 changed files with 1291 additions and 114 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
@@ -95,6 +96,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
@@ -106,6 +110,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
@@ -2194,10 +2199,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
@@ -2210,18 +2229,29 @@ 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,
)
}
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,12 @@ fun CreateGroupScreen(
nav: INav,
) {
var groupName by remember { mutableStateOf("") }
var groupDescription by remember { mutableStateOf("") }
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
// Stable seed for the placeholder avatar shown before an icon is picked. The real
// group id is generated per creation attempt (so retries don't collide), so this is
// a separate cosmetic seed rather than "".
val avatarSeed = remember { RandomInstance.bytes(32).toHexKey() }
var isCreating by remember { mutableStateOf(false) }
var showKeyPackageRelayDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -69,6 +79,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 +98,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 +145,39 @@ fun CreateGroupScreen(
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
)
MarmotGroupIconEditor(
groupId = avatarSeed,
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,106 @@
/*
* 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.produceState
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.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
/**
* 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]; the canonical
* scheme stores only the hash, and the icon usually lives on the *uploader's* Blossom
* server rather than the viewer's. So we resolve it through [Amethyst.blossomResolver],
* which probes the viewer's default server (passed as a first-try `xs` hint) and the
* group admins' configured servers (via their pubkeys as `as` authors, BUD-03). While
* that async probe runs, we optimistically load from the viewer's default server so the
* common case (shared server) shows instantly; if nothing resolves, callers fall back to
* the relay icon.
*
* Returns null when there is no image to show.
*/
@Composable
fun rememberMarmotGroupIconUrl(
image: MarmotGroupImage?,
accountViewModel: AccountViewModel,
adminPubkeys: List<HexKey> = emptyList(),
): String? {
if (image == null) return null
val serverBaseUrl = accountViewModel.account.settings.defaultFileServer.baseUrl
val fallbackUrl = remember(image.hash, serverBaseUrl) { BlossomServerUrl.blob(serverBaseUrl, image.hash) }
// A blossom: URI carrying the default server as a first-try hint and the admins as
// authors. Extension "bin" keeps the resolver's HEAD check type-agnostic, matching the
// application/octet-stream encrypted blob.
val blossomUri =
remember(image.hash, serverBaseUrl, adminPubkeys) {
BlossomUri(
sha256 = image.hash,
extension = "bin",
servers = listOf(serverBaseUrl),
authors = adminPubkeys,
size = null,
).toUriString()
}
val resolver = Amethyst.instance.blossomResolver
val url by produceState(resolver.cachedFindServer(blossomUri)?.serverUrl ?: fallbackUrl, blossomUri) {
value = resolver.findServers(blossomUri)?.serverUrl ?: fallbackUrl
}
val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce) }
// Register the cipher only when the URL or cipher changes (not on every recomposition),
// and synchronously during composition so the interceptor can decrypt before Coil fetches.
// The plaintext MIME isn't stored (MIP-01 v2), so Coil sniffs the format from the bytes.
remember(url, cipher) {
Amethyst.instance.keyCache.add(url, cipher, null)
url
}
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,152 @@
/*
* 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.MediaCompressor
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.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.Log
import java.io.File
/**
* 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,
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). */
val imageKey: ByteArray,
/** 12-byte nonce. */
val imageNonce: ByteArray,
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). */
val imageUploadKey: ByteArray,
)
/**
* 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 MIP-01 v2 scheme (see [MarmotGroupImageEncryption])
* and uploads the ciphertext to Blossom, signing the upload authorization with the
* keypair derived from `image_upload_key` (so any admin holding that seed can later
* replace/delete the blob).
*
* 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 {
// Compress/downscale up front — avatars don't need full resolution, and a smaller
// blob is cheaper for every member to fetch. The MIP-01 crypto uses no AAD and does
// not bind the MIME type, so the (possibly transcoded) output type is irrelevant to
// decryption; we just hand the compressed bytes to the encrypting uploader.
val compressed = MediaCompressor().compress(uri, mimeType, CompressorQuality.MEDIUM, context.applicationContext)
val uploadMime = compressed.contentType ?: mimeType ?: DEFAULT_MIME
val cipher = MarmotGroupImageCipher.forNewImage()
val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey()
val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed)))
try {
val state =
UploadOrchestrator().uploadEncrypted(
uri = compressed.uri,
mimeType = uploadMime,
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.UNCOMPRESSED,
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 = uploadKeySeed,
)
}
val message =
if (state is UploadingState.Error) {
stringRes(context, state.errorResource, *state.params)
} else {
"Group icon upload failed"
}
throw IllegalStateException(message)
} finally {
// Delete the intermediate compressed temp file (compress returns the original
// URI unchanged when it skips compression, so only delete a distinct temp).
if (compressed.uri != uri) {
try {
compressed.uri.path?.let { path -> File(path).takeIf { it.exists() }?.delete() }
} catch (e: Exception) {
Log.w("MarmotGroupIconUploader", "Failed to delete temp icon file", e)
}
}
}
}
companion object {
private const val DEFAULT_MIME = "image/jpeg"
}
}
@@ -83,7 +83,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.concord.ConcordCommunityPill
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
@@ -340,11 +342,23 @@ private fun MarmotGroupRoomCompose(
nav: INav,
) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val image by chatroom.image.collectAsStateWithLifecycle()
val relays by chatroom.relays.collectAsStateWithLifecycle()
val adminPubkeys by chatroom.adminPubkeys.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, adminPubkeys)
} else {
loadMarmotRelayIcon(relays)
}
val lastContent =
if (author != null && noteEvent != null) {
val authorName by observeUserName(author, accountViewModel)
@@ -357,7 +371,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
@@ -4138,6 +4138,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>
@@ -41,6 +41,8 @@ object GroupCommands {
"rename" to { rest -> GroupMetadataCommands.rename(dataDir, rest) },
"promote" to { rest -> GroupMetadataCommands.promote(dataDir, rest) },
"demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) },
"set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) },
"clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) },
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
),
@@ -20,15 +20,22 @@
*/
package com.vitorpamplona.amethyst.cli.commands
import com.vitorpamplona.amethyst.cli.Args
import com.vitorpamplona.amethyst.cli.Context
import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import java.io.File
/**
* Metadata-only commits: rename, promote/demote. Each loads current metadata,
* edits the right field, publishes a GCE commit to the group relays.
* Metadata-only commits: rename, promote/demote, set/clear image. Each loads current
* metadata, edits the right field, publishes a GCE commit to the group relays.
*/
object GroupMetadataCommands {
suspend fun rename(
@@ -64,9 +71,65 @@ object GroupMetadataCommands {
}
}
/**
* Set the group avatar (MIP-01 v2): encrypt the image, optionally push the
* ciphertext to Blossom (`--server`), and commit the image fields into the group
* metadata. The encryption is byte-for-byte interoperable with mdk/whitenoise.
*
* `group set-image <gid> <image-file> [--server URL] [--mime TYPE]`
*/
suspend fun setImage(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val gid = args.positional(0, "gid")
val path = args.positional(1, "image-file")
val server = args.flag("server")
val file = File(path)
if (!file.isFile) return Output.error("bad_args", "no such file: $path")
val plaintext = file.readBytes()
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey()
// Optionally push the encrypted blob to Blossom, signed by the keypair derived
// from image_upload_key so an admin holding the seed can later replace/delete it.
var uploadedUrl: String? = null
if (server != null) {
val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed)))
val tmp = File.createTempFile("marmot-icon", ".bin")
try {
tmp.writeBytes(enc.ciphertext)
val auth = BlossomAuth.createUploadAuth(enc.imageHash, enc.ciphertext.size.toLong(), "Group image", uploadSigner)
val result = BlossomClient().upload(tmp, "application/octet-stream", server, auth)
if (result.sha256 != null && result.sha256 != enc.imageHash) {
return Output.error("hash_mismatch", "blossom returned ${result.sha256}, expected ${enc.imageHash}")
}
uploadedUrl = result.url
} finally {
tmp.delete()
}
}
return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur ->
cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed)
}
}
/** Remove the group avatar. `group clear-image <gid>` */
suspend fun clearImage(
dataDir: DataDir,
rest: Array<String>,
): Int {
if (rest.isEmpty()) return Output.error("bad_args", "group clear-image <gid>")
return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() }
}
private suspend fun edit(
dataDir: DataDir,
rawGid: HexKey,
extra: Map<String, Any?> = emptyMap(),
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
): Int {
Context.open(dataDir).use { ctx ->
@@ -96,7 +159,7 @@ object GroupMetadataCommands {
"epoch" to ctx.marmot.groupEpoch(gid),
"commit_event_id" to commit.signedEvent.id,
"published_to" to ack.filterValues { it }.keys.map { it.url },
),
) + extra,
)
return 0
}
@@ -128,6 +128,7 @@ test_05_b_adds_a_existing
test_06_member_removal
test_07_metadata_rename
test_08_admin_promote_demote
test_17_group_image_commit
test_09_reply_react_unreact
test_10_concurrent_commits
test_11_leave_group
@@ -1,24 +0,0 @@
--- a/src/whitenoise/relays.rs
+++ b/src/whitenoise/relays.rs
@@ -98,6 +98,21 @@
}
pub(crate) fn defaults() -> Vec<Relay> {
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
+ // (comma-separated list) when present so newly created accounts only
+ // ever get our loopback relay baked into their NIP-65 / inbox /
+ // key-package lists. Without this override, `create-identity` stamps
+ // the hard-coded public set into the account's relay lists, and every
+ // later activate / publish burns connection budget on unreachable
+ // sockets — enough to break inbox-plane activation and drop kind:1059.
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
+ let parsed: Vec<Relay> = from_env
+ .split(',').map(str::trim).filter(|s| !s.is_empty())
+ .filter_map(|u| RelayUrl::parse(u).ok())
+ .map(|url| Relay::new(&url))
+ .collect();
+ if !parsed.is_empty() { return parsed; }
+ }
let urls: &[&str] = if cfg!(debug_assertions) {
&["ws://localhost:8080", "ws://localhost:7777"]
} else {
@@ -1,23 +0,0 @@
--- a/src/relay_control/discovery.rs
+++ b/src/relay_control/discovery.rs
@@ -87,6 +87,20 @@
/// Initial curated relay set from the planning doc.
pub(crate) fn curated_default_relays() -> Vec<RelayUrl> {
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
+ // (comma-separated list) when present, so the harness can force wnd
+ // to use a loopback relay instead of the baked-in public set.
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
+ let parsed: Vec<RelayUrl> = from_env
+ .split(',')
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .filter_map(|u| RelayUrl::parse(u).ok())
+ .collect();
+ if !parsed.is_empty() {
+ return parsed;
+ }
+ }
[
"wss://index.hzrd149.com",
"wss://indexer.coracle.social",
@@ -1,18 +1,17 @@
--- a/src/bin/wnd.rs
+++ b/src/bin/wnd.rs
@@ -22,6 +22,15 @@
--- a/crates/whitenoise-cli/src/bin/wnd.rs
+++ b/crates/whitenoise-cli/src/bin/wnd.rs
@@ -44,6 +44,14 @@ async fn main() -> whitenoise_cli::Result<()> {
let args = Args::parse();
let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref());
+ // marmot-interop-headless patch: allow sandboxes/CI without a real
+ // kernel keyring to fall back to the integration-tests mock keyring
+ // by setting $WHITENOISE_MOCK_KEYRING=1. Requires the daemon to be
+ // built with --features cli,integration-tests. No effect otherwise.
+ #[cfg(feature = "integration-tests")]
+ // marmot-interop-headless patch: allow sandboxes / CI without a real kernel
+ // keyring to fall back to the integration-tests mock keyring store by setting
+ // $WHITENOISE_MOCK_KEYRING=1. Requires building the binaries with
+ // `--features whitenoise/integration-tests` (the harness does).
+ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() {
+ Whitenoise::initialize_mock_keyring_store();
+ }
+
let wn_config = WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
Whitenoise::initialize_whitenoise(wn_config).await?;
let mut wn_config =
WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
if !args.discovery_relays.is_empty() {
+30 -34
View File
@@ -52,32 +52,27 @@ preflight() {
2>&1 | tee -a "$LOG_FILE"
fi
# Four harness-only patches to wnd so it runs fully offline / in
# sandboxes that block outbound + kernel keyring:
# 1. discovery-env: honour $WHITENOISE_DISCOVERY_RELAYS so we can
# point wnd at our loopback relay instead of the baked-in public
# set. Without it wnd exits with NoRelayConnections.
# 2. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
# Two harness-only patches to whitenoise-rs so it runs in sandboxes that
# block the kernel keyring:
# 1. mock-keyring: honour $WHITENOISE_MOCK_KEYRING so wnd uses the
# integration-tests mock keyring store when the kernel keyutils
# syscalls are blocked (common in containers / CI).
# 3. defaults-env: reuse the same env var so `Relay::defaults()`
# (what `create-identity` stamps into the new account's NIP-65 /
# inbox / key-package lists) points at the loopback relay too.
# Without it every account wnd creates carries damus.io /
# primal.net / nos.lol, and every later activate / publish burns
# connection budget on unreachable sockets — enough to break the
# account-inbox subscription plane and drop kind:1059 delivery.
# 4. skip-unprocessable-retry: when mdk-core returns
# `MlsMessageUnprocessable` (pre-membership commit, too-old epoch)
# the message is provably undecryptable — retrying it ten times
# with exponential backoff (total ~17 min) just blocks later
# decryptable commits behind a queue of doomed retries, which in
# the harness manifests as "A already left" / "name unchanged"
# timeouts. The patch treats that error as terminal.
# syscalls are blocked (common in containers / CI). Compiled in via
# `--features whitenoise/integration-tests` on the build below.
# 2. skip-unprocessable-retry: when mdk-core returns a terminal MLS
# error (MlsMessageUnprocessable / PreviouslyFailed / MdkCoreError)
# the message is provably undecryptable — retrying it ten times with
# exponential backoff (~17 min) just blocks later decryptable commits
# behind a queue of doomed retries, which in the harness manifests as
# "A already left" / "name unchanged" timeouts. The patch treats those
# errors as terminal.
#
# The relay-override patches this harness used to carry (discovery-env /
# defaults-env) are gone: upstream wnd now takes native --discovery-relays
# and --default-account-relays flags (passed in start_daemon), which do the
# same job without patching. wn/wnd also moved into the crates/whitenoise-cli
# workspace member — the mock-keyring patch targets that path.
local -a patches=(
"whitenoise-discovery-env.patch"
"whitenoise-mock-keyring.patch"
"whitenoise-defaults-env.patch"
"whitenoise-skip-unprocessable-retry.patch"
)
# Apply each patch with a real exit-code check. The previous version
@@ -114,7 +109,8 @@ preflight() {
for attempt in $(seq 1 $max); do
step "building wn + wnd (attempt $attempt/$max, ~5 min first run)"
( cd "$WN_REPO" && \
cargo build --release --features cli,integration-tests --bin wn --bin wnd ) \
cargo build --release -p whitenoise-cli \
--features whitenoise/integration-tests --bin wn --bin wnd ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$WN_BIN" && -x "$WND_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "wn/wnd build failed (likely transient 503 from rustup or crates.io) — retrying"
@@ -243,17 +239,17 @@ start_daemon() {
-exec rm -rf {} + 2>/dev/null || true
fi
mkdir -p "$data_dir/logs" "$data_dir/release"
# Env vars consumed by the two harness-only wnd patches applied in
# preflight:
# WHITENOISE_DISCOVERY_RELAYS — forces the discovery plane at our
# loopback relay (kills the "can't reach nos.lol" exit path).
# WHITENOISE_MOCK_KEYRING — swaps in the integration-tests mock
# secret store so wnd doesn't fall over when the kernel blocks
# keyutils syscalls.
# Both are harmless on a real host with connectivity + a real keyring.
WHITENOISE_DISCOVERY_RELAYS="$RELAY_URL" \
WHITENOISE_MOCK_KEYRING=1 \
# --discovery-relays / --default-account-relays are native wnd flags that
# force both the discovery plane and freshly-created accounts' NIP-65 / inbox
# / key-package lists onto our loopback relay (kills the "can't reach nos.lol"
# exit path and stops accounts from carrying unreachable public relays).
#
# WHITENOISE_MOCK_KEYRING=1 is consumed by the mock-keyring patch: it swaps in
# the integration-tests mock secret store so wnd doesn't fall over when the
# kernel blocks keyutils syscalls. Harmless on a real host with a real keyring.
WHITENOISE_MOCK_KEYRING=1 \
nohup "$WND_BIN" --data-dir "$data_dir" --logs-dir "$data_dir/logs" \
--discovery-relays "$RELAY_URL" --default-account-relays "$RELAY_URL" \
>"$data_dir/logs/stdout.log" 2>"$data_dir/logs/stderr.log" &
local pid=$!
echo "$pid" > "$data_dir/pid"
+55
View File
@@ -189,3 +189,58 @@ test_11_leave_group() {
record_result "$id" fail "A still in B's member list after leave"
fi
}
# Regression guard for the Marmot group-icon feature: setting a group image writes
# the MIP-01 v2 image fields into the NostrGroupData extension. mdk-core (the library
# whitenoise uses) rejects ANY trailing bytes in that extension at a known version, so
# a mis-encoded image commit would make the whole group unprocessable for wn and stall
# it a full epoch behind A.
#
# We prove wn applied the image commit the hard way: A sets an image, then sends an
# application message at the POST-image epoch. wn can only decrypt that message if it
# advanced past the image-bearing GCE commit — so wn receiving it is direct evidence
# the image extension parsed. (Once it parses, whitenoise even tries to fetch the
# avatar from Blossom via background_sync_group_image_cache_if_needed.)
#
# A single application message is used rather than a second commit on purpose: two
# commits fired 3s apart race wn's per-epoch processing and give a flaky signal.
test_17_group_image_commit() {
banner "Test 17 — Group image commit stays parseable on whitenoise (MIP-01 v2)"
local id="17 group image"
local gid mls_gid
gid=$(load_state GROUP_02 || true)
mls_gid=$(load_state GROUP_02_MLS || true)
if [[ -z "${gid:-}" || -z "${mls_gid:-}" ]]; then
record_result "$id" skip "no GROUP_02"; return
fi
# Skip cleanly if A is no longer a member of GROUP_02 (a later test may have removed
# A) — this test only makes sense while A can still commit to the group.
if ! wn_b --json groups members "$mls_gid" 2>/dev/null \
| jq -e --arg p "$A_HEX" '(.result // .) | .[]? | select((.pubkey // .public_key) == $p)' \
>/dev/null 2>&1; then
record_result "$id" skip "A not in GROUP_02"; return
fi
# Contents are irrelevant — amy encrypts whatever bytes it's given; the interop
# question is purely whether the resulting image extension parses on wn.
local img="$STATE_DIR/marmot-icon.bin"
head -c 1024 /dev/urandom >"$img" 2>/dev/null || printf 'fake-avatar-bytes-for-interop' >"$img"
if ! amy_json marmot group set-image "$gid" "$img" >/dev/null; then
record_result "$id" fail "amy set-image failed"; return
fi
sleep 3
local tag="post-image-ping-from-amethyst"
if ! amy_json marmot message send "$gid" "$tag" >/dev/null; then
record_result "$id" fail "amy post-image send failed"; return
fi
if wait_for_message B "$mls_gid" "$tag" 120; then
record_result "$id" pass
else
record_result "$id" fail "wn could not decrypt A's post-image message — image commit not applied"
fi
}
@@ -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,16 @@ 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!!,
)
} 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,58 @@
/*
* 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] via
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption] (MIP-01 v2:
* [key] is an HKDF seed).
*/
@Immutable
class MarmotGroupImage(
/** SHA-256 (hex) of the encrypted blob — the Blossom content hash. */
val hash: HexKey,
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). */
val key: ByteArray,
/** 12-byte ChaCha20-Poly1305 nonce. */
val nonce: ByteArray,
) {
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)
}
override fun hashCode(): Int {
var result = hash.hashCode()
result = 31 * result + key.contentHashCode()
result = 31 * result + nonce.contentHashCode()
return result
}
}
@@ -49,14 +49,22 @@ 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>; // MIP-01 v2: HKDF seed for the AEAD key
* opaque image_nonce<0..12>;
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
* opaque image_upload_key<0..32>; // MIP-01 v2: HKDF seed for the Blossom-auth key
* opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever,
* // 8 bytes big-endian uint64 = expiration secs
* // (value 0 is rejected)
* } NostrGroupData;
* ```
*
* The image fields carry the group avatar under the MIP-01 v2 scheme (see
* [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are HKDF **seeds**;
* `image_hash` is the SHA-256 of the encrypted blob. This is the exact field layout
* mdk-core's v1/v2 `NostrGroupDataExtension` parser expects — the image fields are the
* last ones it reads — so populating an avatar stays byte-compatible with
* whitenoise/mdk (no trailing bytes). The plaintext MIME type is intentionally NOT
* stored here: mdk rejects any trailing bytes at a known version and has no such field.
*/
@Immutable
data class MarmotGroupData(
@@ -80,13 +88,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. */
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). 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. */
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). Null if no image. */
val imageUploadKey: ByteArray? = null,
/**
* Disappearing-message duration in seconds (v3+).
@@ -112,6 +120,32 @@ 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,
): MarmotGroupData =
copy(
imageHash = imageHash,
imageKey = imageKey,
imageNonce = imageNonce,
imageUploadKey = imageUploadKey,
)
/** Return a copy with the group image cleared. */
fun withoutImage(): MarmotGroupData =
copy(
imageHash = null,
imageKey = null,
imageNonce = null,
imageUploadKey = null,
)
/**
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
*
@@ -167,8 +201,9 @@ data class MarmotGroupData(
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).
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps the
// wire format byte-for-byte compatible with mdk's v1/v2 NostrGroupDataExtension
// parser (which ends at image_upload_key and rejects any trailing bytes).
if (version >= 3) {
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
@@ -0,0 +1,71 @@
/*
* 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 MIP-01 v2 scheme (see
* [MarmotGroupImageEncryption]) — byte-for-byte interoperable with mdk/whitenoise.
*
* 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] seed / [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]
* (v2 first, then the v1 raw-key fallback).
*/
class MarmotGroupImageCipher(
/** 32-byte HKDF seed stored as `image_key` (the AEAD key is derived from it). */
val imageKey: ByteArray,
/** 12-byte nonce. */
val imageNonce: ByteArray,
) : NostrCipher {
override fun name(): String = "mip01-image-encryption-v2"
override fun encrypt(bytesToEncrypt: ByteArray): ByteArray {
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey)
return ChaCha20Poly1305.encrypt(bytesToEncrypt, EMPTY_AAD, imageNonce, aeadKey)
}
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)
companion object {
private val EMPTY_AAD = ByteArray(0)
/**
* Build a cipher with a freshly-generated seed + 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(): MarmotGroupImageCipher =
MarmotGroupImageCipher(
imageKey = RandomInstance.bytes(MarmotGroupImageEncryption.KEY_LENGTH),
imageNonce = RandomInstance.bytes(MarmotGroupImageEncryption.NONCE_LENGTH),
)
}
}
@@ -0,0 +1,152 @@
/*
* 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.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 — MIP-01 v2.
*
* This is byte-for-byte interoperable with the reference implementation
* (`mdk-core`'s `extension/group_image.rs`, used by whitenoise): a group avatar
* is encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded to Blossom,
* addressed by the SHA-256 of the *ciphertext*.
*
* The parameters live inside the group's [MarmotGroupData] extension:
* - `image_key` — a 32-byte HKDF **seed**. The AEAD key is
* `HKDF-SHA256(salt=∅, ikm=image_key, info="mip01-image-encryption-v2", 32)`
* (see [Mip01ImageCrypto.deriveImageEncryptionKey]).
* - `image_nonce` — the 12-byte ChaCha20-Poly1305 nonce (used verbatim).
* - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash).
* - `image_upload_key` — a 32-byte HKDF **seed**. The Blossom-auth secp256k1
* secret is `HKDF-SHA256(salt=∅, ikm=image_upload_key, info="mip01-blossom-upload-v2", 32)`
* (see [Mip01ImageCrypto.deriveBlossomUploadSeed]).
*
* The AEAD uses **no associated data** (empty AAD), matching MIP-01. The plaintext
* MIME type is descriptive metadata only — it is deliberately NOT bound into the
* crypto and NOT stored on the wire, because mdk's `NostrGroupDataExtension` parser
* rejects any trailing bytes at a known version and has no media_type field, so
* storing it would break group parsing for whitenoise/mdk members.
*
* A fetching client MUST verify that the fetched bytes hash to `image_hash` before
* decrypting.
*
* ### Version fallback (parse both), mirroring mdk
* [decryptAny] first tries v2 (HKDF-derived key); on failure it falls back to v1,
* where `image_key` is used directly as the AEAD key. New images are always v2.
*/
object MarmotGroupImageEncryption {
const val KEY_LENGTH = 32
const val NONCE_LENGTH = 12
private val EMPTY_AAD = ByteArray(0)
/**
* 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 HKDF seed — 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 seed + nonce (MIP-01 v2).
* Returns the ciphertext to upload plus the parameters to persist in
* [MarmotGroupData].
*/
fun encrypt(plaintext: ByteArray): Encrypted {
val imageKeySeed = RandomInstance.bytes(KEY_LENGTH)
val imageNonce = RandomInstance.bytes(NONCE_LENGTH)
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed)
val ciphertext = ChaCha20Poly1305.encrypt(plaintext, EMPTY_AAD, imageNonce, aeadKey)
return Encrypted(
ciphertext = ciphertext,
imageKey = imageKeySeed,
imageNonce = imageNonce,
imageHash = sha256(ciphertext).toHexKey(),
)
}
/**
* Decrypt a group image blob (MIP-01 v2): `image_key` is an HKDF seed.
*
* @throws IllegalStateException on authentication failure.
*/
fun decrypt(
ciphertext: ByteArray,
imageKey: ByteArray,
imageNonce: ByteArray,
): ByteArray {
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey)
return ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, aeadKey)
}
/**
* Decrypt a group image blob, trying v2 (HKDF-derived key) first and falling
* back to v1 (raw `image_key`), exactly like mdk. Returns null if neither
* authenticates.
*/
fun decryptAny(
ciphertext: ByteArray,
imageKey: ByteArray,
imageNonce: ByteArray,
): ByteArray? {
if (imageKey.size != KEY_LENGTH || imageNonce.size != NONCE_LENGTH) return null
// v2: image_key is an HKDF seed.
try {
return decrypt(ciphertext, imageKey, imageNonce)
} catch (_: Exception) {
// fall through to v1
}
// v1: image_key is the AEAD key directly.
return try {
ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, imageKey)
} catch (_: Exception) {
null
}
}
/**
* Generate the 32-byte HKDF **seed** stored as [MarmotGroupData.imageUploadKey].
* The actual Blossom-auth secp256k1 secret is derived from it via
* [deriveUploadKeypairSecret].
*/
fun generateUploadKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH)
/**
* Derive the 32-byte secp256k1 secret used to authorize Blossom writes for the
* avatar from the stored `image_upload_key` seed (MIP-01 v2).
*/
fun deriveUploadKeypairSecret(imageUploadKey: ByteArray): ByteArray = Mip01ImageCrypto.deriveBlossomUploadSeed(imageUploadKey)
}
@@ -0,0 +1,267 @@
/*
* 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.marmot.mls.codec.TlsReader
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()
private val emptyAad = ByteArray(0)
// ------------------------------------------------------------ encryption (MIP-01 v2)
@Test
fun encrypt_thenDecrypt_roundTrips() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
assertEquals(MarmotGroupImageEncryption.KEY_LENGTH, enc.imageKey.size)
assertEquals(MarmotGroupImageEncryption.NONCE_LENGTH, enc.imageNonce.size)
val decrypted = MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce)
assertContentEquals(plaintext, decrypted)
}
@Test
fun imageHash_isSha256OfCiphertext() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
assertEquals(sha256(enc.ciphertext).toHexKey(), enc.imageHash)
}
/**
* Byte-for-byte interop guard: the AEAD key MUST be
* HKDF(image_key, "mip01-image-encryption-v2") with empty AAD — exactly what mdk's
* group_image.rs does. If this drifts, Amethyst and whitenoise stop interoperating.
*/
@Test
fun scheme_matchesMdk_hkdfSeedAndEmptyAad() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
val derivedKey = Mip01ImageCrypto.deriveImageEncryptionKey(enc.imageKey)
val manual = ChaCha20Poly1305.decrypt(enc.ciphertext, emptyAad, enc.imageNonce, derivedKey)
assertContentEquals(plaintext, manual)
}
@Test
fun decrypt_wrongSeed_fails() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
assertFailsWith<IllegalStateException> {
MarmotGroupImageEncryption.decrypt(enc.ciphertext, RandomInstance.bytes(32), enc.imageNonce)
}
}
@Test
fun decryptAny_v2_succeeds() {
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
val out = MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce)
assertNotNull(out)
assertContentEquals(plaintext, out)
}
@Test
fun decryptAny_fallsBackToV1RawKey() {
// v1: image_key is used directly as the AEAD key (no HKDF), empty AAD — mdk's fallback.
val rawKey = RandomInstance.bytes(32)
val nonce = RandomInstance.bytes(12)
val v1Blob = ChaCha20Poly1305.encrypt(plaintext, emptyAad, nonce, rawKey)
val out = MarmotGroupImageEncryption.decryptAny(v1Blob, rawKey, nonce)
assertNotNull(out)
assertContentEquals(plaintext, out)
}
@Test
fun decryptAny_garbage_returnsNull() {
val out =
MarmotGroupImageEncryption.decryptAny(
RandomInstance.bytes(64),
RandomInstance.bytes(32),
RandomInstance.bytes(12),
)
assertNull(out)
}
@Test
fun uploadKeypairSecret_isDeterministicAndDistinctFromSeed() {
val seed = RandomInstance.bytes(32)
val s1 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed)
val s2 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed)
assertEquals(32, s1.size)
assertContentEquals(s1, s2)
assertTrue(!s1.contentEquals(seed), "upload secret must be derived, not the raw seed")
}
@Test
fun cipher_encryptDecrypt_roundTrips_asUsedByUploadAndDisplay() {
val uploadCipher = MarmotGroupImageCipher.forNewImage()
val blob = uploadCipher.encrypt(plaintext)
val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce)
assertContentEquals(plaintext, displayCipher.decrypt(blob))
assertContentEquals(plaintext, displayCipher.decryptOrNull(blob))
}
@Test
fun cipher_decryptOrNull_wrongSeed_returnsNull() {
val uploadCipher = MarmotGroupImageCipher.forNewImage()
val blob = uploadCipher.encrypt(plaintext)
val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce)
assertNull(wrong.decryptOrNull(blob))
}
// ------------------------------------------------------------ wire format
@Test
fun wire_roundTrips_withImage() {
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(),
)
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
assertEquals("Otters", decoded.name)
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)
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.imageHash)
assertTrue(!decoded.hasImage())
}
/**
* INTEROP GUARD: at version 2, a group WITH an image must serialize with the image
* fields as the LAST fields and ZERO trailing bytes — exactly what mdk-core's
* `TlsNostrGroupDataExtensionV1V2` parser consumes. mdk rejects any trailing bytes at a
* known version, so a stray byte here silently breaks the group for whitenoise/mdk
* members. This test reproduces mdk's v1/v2 field consumption and asserts nothing is
* left over.
*/
@Test
fun wire_v2WithImage_hasNoTrailingBytesForMdk() {
val withImage =
MarmotGroupData(
version = 2,
nostrGroupId = nostrGroupId,
name = "Compat",
description = "d",
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(),
)
val reader = TlsReader(withImage.encodeTls())
reader.readUint16() // version
reader.readBytes(32) // nostr_group_id
reader.readOpaqueVarInt() // name
reader.readOpaqueVarInt() // description
reader.readOpaqueVarInt() // admin_pubkeys
reader.readOpaqueVarInt() // relays
reader.readOpaqueVarInt() // image_hash
reader.readOpaqueVarInt() // image_key
reader.readOpaqueVarInt() // image_nonce
reader.readOpaqueVarInt() // image_upload_key
assertTrue(
!reader.hasRemaining,
"v2 image extension has trailing bytes past image_upload_key → mdk would reject it",
)
}
@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(),
disappearingMessageSecs = 3600UL,
)
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
assertEquals(3600UL, decoded.disappearingMessageSecs)
assertTrue(decoded.hasImage())
}
@Test
fun withImage_andWithoutImage_helpers() {
val base =
MarmotGroupData(
nostrGroupId = nostrGroupId,
name = "Base",
adminPubkeys = listOf("bb".repeat(32)),
)
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
val withImg = base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32))
assertTrue(withImg.hasImage())
val cleared = withImg.withoutImage()
assertTrue(!cleared.hasImage())
assertNull(cleared.imageUploadKey)
}
}