diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8a03fc1da2..4eb809c271 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 7a70aef232..a1346bdf51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -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(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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt index 1314597009..9fe962e98a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/EditGroupInfoScreen.kt @@ -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(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 }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt new file mode 100644 index 0000000000..27abf2ec10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt @@ -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 = 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? { + val relay = remember(relays) { relays.firstNotNullOfOrNull { RelayUrlNormalizer.normalizeOrNull(it) } } ?: return null + val relayInfo by loadRelayInfo(relay) + return relayInfo.icon?.ifBlank { null } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt new file mode 100644 index 0000000000..30a19c9da2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconEditor.kt @@ -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)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt new file mode 100644 index 0000000000..f207c99a99 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt @@ -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" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 33c5682ff7..34c008d4e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5eca19c786..51de54a1d0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4138,6 +4138,10 @@ Enter group name Enter group description (optional) Changes will be committed to the group via MLS and propagated to all members. + Group icon + Add photo + Change photo + Remove photo Group info updated Failed to update: %1$s Failed to create group: %1$s diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt index fe8bf98aef..7f3dd85719 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -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) }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 841adf2c37..fee26ab50a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -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 [--server URL] [--mime TYPE]` + */ + suspend fun setImage( + dataDir: DataDir, + rest: Array, + ): 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 ` */ + suspend fun clearImage( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "group clear-image ") + return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() } + } + private suspend fun edit( dataDir: DataDir, rawGid: HexKey, + extra: Map = 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 } diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index 04b8334512..15a49f79a2 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -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 diff --git a/cli/tests/marmot/patches/whitenoise-defaults-env.patch b/cli/tests/marmot/patches/whitenoise-defaults-env.patch deleted file mode 100644 index 57cc16cd4f..0000000000 --- a/cli/tests/marmot/patches/whitenoise-defaults-env.patch +++ /dev/null @@ -1,24 +0,0 @@ ---- a/src/whitenoise/relays.rs -+++ b/src/whitenoise/relays.rs -@@ -98,6 +98,21 @@ - } - - pub(crate) fn defaults() -> Vec { -+ // 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 = 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 { diff --git a/cli/tests/marmot/patches/whitenoise-discovery-env.patch b/cli/tests/marmot/patches/whitenoise-discovery-env.patch deleted file mode 100644 index 8fb0fec0aa..0000000000 --- a/cli/tests/marmot/patches/whitenoise-discovery-env.patch +++ /dev/null @@ -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 { -+ // 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 = 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", diff --git a/cli/tests/marmot/patches/whitenoise-mock-keyring.patch b/cli/tests/marmot/patches/whitenoise-mock-keyring.patch index f346b43520..ed729fcf91 100644 --- a/cli/tests/marmot/patches/whitenoise-mock-keyring.patch +++ b/cli/tests/marmot/patches/whitenoise-mock-keyring.patch @@ -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() { diff --git a/cli/tests/marmot/setup.sh b/cli/tests/marmot/setup.sh index 386a2bf3f1..6d03eb44fd 100644 --- a/cli/tests/marmot/setup.sh +++ b/cli/tests/marmot/setup.sh @@ -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" diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 03763d4207..46fd29f63c 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -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 +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index aabe40ffe0..66283cfa32 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 432f90fbd7..16e27c49fe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -48,6 +48,13 @@ class MarmotGroupChatroom( var messages: Set = setOf() var displayName = MutableStateFlow(null) var description = MutableStateFlow(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(null) var adminPubkeys = MutableStateFlow>(emptyList()) var relays = MutableStateFlow>(emptyList()) var memberCount = MutableStateFlow(0) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt new file mode 100644 index 0000000000..9fbf267f25 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupImage.kt @@ -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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index fff65761cd..ae0e358e4d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -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 = emptyList(), /** Relay URLs for group message distribution. SHOULD contain at least one. */ val relays: List = 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 -> diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt new file mode 100644 index 0000000000..29c28acd0a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageCipher.kt @@ -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), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt new file mode 100644 index 0000000000..a47974b5f7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupImageEncryption.kt @@ -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) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt new file mode 100644 index 0000000000..6af28d836a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotGroupImageTest.kt @@ -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 { + 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) + } +}