mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
+39
-8
@@ -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)
|
||||
}
|
||||
|
||||
+43
-1
@@ -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(
|
||||
|
||||
+33
-1
@@ -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 },
|
||||
|
||||
+72
@@ -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 }
|
||||
}
|
||||
+123
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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"
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+12
@@ -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)
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+61
@@ -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
|
||||
}
|
||||
}
|
||||
+72
-10
@@ -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
|
||||
|
||||
+72
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+188
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user