mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3568 from vitorpamplona/claude/marmot-group-icons-tlcwa1
Add MIP-01 v2 group avatar encryption and upload support
This commit is contained in:
+38
-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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+47
-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,12 @@ fun CreateGroupScreen(
|
||||
nav: INav,
|
||||
) {
|
||||
var groupName by remember { mutableStateOf("") }
|
||||
var groupDescription by remember { mutableStateOf("") }
|
||||
var pickedIcon by remember { mutableStateOf<SelectedMedia?>(null) }
|
||||
// Stable seed for the placeholder avatar shown before an icon is picked. The real
|
||||
// group id is generated per creation attempt (so retries don't collide), so this is
|
||||
// a separate cosmetic seed rather than "".
|
||||
val avatarSeed = remember { RandomInstance.bytes(32).toHexKey() }
|
||||
var isCreating by remember { mutableStateOf(false) }
|
||||
var showKeyPackageRelayDialog by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -69,6 +79,14 @@ fun CreateGroupScreen(
|
||||
try {
|
||||
val nostrGroupId = RandomInstance.bytes(32).toHexKey()
|
||||
accountViewModel.createMarmotGroup(nostrGroupId)
|
||||
// Encrypt + upload the picked icon (if any) before the metadata commit,
|
||||
// so its parameters land in the group's MarmotGroupData extension.
|
||||
val iconChange =
|
||||
pickedIcon?.let { media ->
|
||||
MarmotGroupIconChange.Set(
|
||||
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, context),
|
||||
)
|
||||
} ?: MarmotGroupIconChange.Keep
|
||||
// Always commit an initial metadata extension so that
|
||||
// (a) the name (if any) is persisted in MLS extensions
|
||||
// and survives app restarts,
|
||||
@@ -80,7 +98,8 @@ fun CreateGroupScreen(
|
||||
accountViewModel.updateMarmotGroupMetadata(
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = groupName.trim(),
|
||||
description = "",
|
||||
description = groupDescription.trim(),
|
||||
icon = iconChange,
|
||||
)
|
||||
nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class)
|
||||
} catch (e: Exception) {
|
||||
@@ -126,12 +145,39 @@ fun CreateGroupScreen(
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
|
||||
MarmotGroupIconEditor(
|
||||
groupId = avatarSeed,
|
||||
existingImage = null,
|
||||
pickedMedia = pickedIcon,
|
||||
removeRequested = false,
|
||||
enabled = !isCreating,
|
||||
accountViewModel = accountViewModel,
|
||||
onPick = { pickedIcon = it },
|
||||
onRemove = { pickedIcon = null },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = groupName,
|
||||
onValueChange = { groupName = it },
|
||||
label = { Text(stringRes(R.string.marmot_group_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !isCreating,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = groupDescription,
|
||||
onValueChange = { groupDescription = it },
|
||||
label = { Text(stringRes(R.string.description)) },
|
||||
placeholder = { Text(stringRes(R.string.marmot_group_description_placeholder)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5,
|
||||
enabled = !isCreating,
|
||||
)
|
||||
|
||||
Text(
|
||||
|
||||
+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 },
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
|
||||
/**
|
||||
* Resolve the URL from which a Marmot group's encrypted avatar can be loaded, and
|
||||
* register its decryption cipher in the encrypted-blob HTTP cache so any Coil load
|
||||
* of that URL transparently yields the decrypted image (via `EncryptedBlobInterceptor`).
|
||||
*
|
||||
* The blob is content-addressed on Blossom by [MarmotGroupImage.hash]; the canonical
|
||||
* scheme stores only the hash, and the icon usually lives on the *uploader's* Blossom
|
||||
* server rather than the viewer's. So we resolve it through [Amethyst.blossomResolver],
|
||||
* which probes the viewer's default server (passed as a first-try `xs` hint) and the
|
||||
* group admins' configured servers (via their pubkeys as `as` authors, BUD-03). While
|
||||
* that async probe runs, we optimistically load from the viewer's default server so the
|
||||
* common case (shared server) shows instantly; if nothing resolves, callers fall back to
|
||||
* the relay icon.
|
||||
*
|
||||
* Returns null when there is no image to show.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMarmotGroupIconUrl(
|
||||
image: MarmotGroupImage?,
|
||||
accountViewModel: AccountViewModel,
|
||||
adminPubkeys: List<HexKey> = emptyList(),
|
||||
): String? {
|
||||
if (image == null) return null
|
||||
|
||||
val serverBaseUrl = accountViewModel.account.settings.defaultFileServer.baseUrl
|
||||
val fallbackUrl = remember(image.hash, serverBaseUrl) { BlossomServerUrl.blob(serverBaseUrl, image.hash) }
|
||||
|
||||
// A blossom: URI carrying the default server as a first-try hint and the admins as
|
||||
// authors. Extension "bin" keeps the resolver's HEAD check type-agnostic, matching the
|
||||
// application/octet-stream encrypted blob.
|
||||
val blossomUri =
|
||||
remember(image.hash, serverBaseUrl, adminPubkeys) {
|
||||
BlossomUri(
|
||||
sha256 = image.hash,
|
||||
extension = "bin",
|
||||
servers = listOf(serverBaseUrl),
|
||||
authors = adminPubkeys,
|
||||
size = null,
|
||||
).toUriString()
|
||||
}
|
||||
|
||||
val resolver = Amethyst.instance.blossomResolver
|
||||
val url by produceState(resolver.cachedFindServer(blossomUri)?.serverUrl ?: fallbackUrl, blossomUri) {
|
||||
value = resolver.findServers(blossomUri)?.serverUrl ?: fallbackUrl
|
||||
}
|
||||
|
||||
val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce) }
|
||||
// Register the cipher only when the URL or cipher changes (not on every recomposition),
|
||||
// and synchronously during composition so the interceptor can decrypt before Coil fetches.
|
||||
// The plaintext MIME isn't stored (MIP-01 v2), so Coil sniffs the format from the bytes.
|
||||
remember(url, cipher) {
|
||||
Amethyst.instance.keyCache.add(url, cipher, null)
|
||||
url
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* The NIP-11 icon of the group's first resolvable relay, used as a fallback avatar
|
||||
* when the group has no image of its own. Fetches the relay's NIP-11 document on a
|
||||
* cache miss. Returns null when the group has no valid relay or the relay advertises
|
||||
* no icon.
|
||||
*/
|
||||
@Composable
|
||||
fun loadMarmotRelayIcon(relays: List<String>): String? {
|
||||
val relay = remember(relays) { relays.firstNotNullOfOrNull { RelayUrlNormalizer.normalizeOrNull(it) } } ?: return null
|
||||
val relayInfo by loadRelayInfo(relay)
|
||||
return relayInfo.icon?.ifBlank { null }
|
||||
}
|
||||
+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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -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"
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -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,
|
||||
|
||||
@@ -4138,6 +4138,10 @@
|
||||
<string name="marmot_group_name_placeholder">Enter group name</string>
|
||||
<string name="marmot_group_description_placeholder">Enter group description (optional)</string>
|
||||
<string name="marmot_edit_info_footer">Changes will be committed to the group via MLS and propagated to all members.</string>
|
||||
<string name="marmot_group_icon">Group icon</string>
|
||||
<string name="marmot_add_photo">Add photo</string>
|
||||
<string name="marmot_change_photo">Change photo</string>
|
||||
<string name="marmot_remove_photo">Remove photo</string>
|
||||
<string name="marmot_group_info_updated">Group info updated</string>
|
||||
<string name="marmot_failed_to_update">Failed to update: %1$s</string>
|
||||
<string name="marmot_failed_to_create_group">Failed to create group: %1$s</string>
|
||||
|
||||
@@ -41,6 +41,8 @@ object GroupCommands {
|
||||
"rename" to { rest -> GroupMetadataCommands.rename(dataDir, rest) },
|
||||
"promote" to { rest -> GroupMetadataCommands.promote(dataDir, rest) },
|
||||
"demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) },
|
||||
"set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) },
|
||||
"clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) },
|
||||
"remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) },
|
||||
"leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) },
|
||||
),
|
||||
|
||||
+66
-3
@@ -20,15 +20,22 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Metadata-only commits: rename, promote/demote. Each loads current metadata,
|
||||
* edits the right field, publishes a GCE commit to the group relays.
|
||||
* Metadata-only commits: rename, promote/demote, set/clear image. Each loads current
|
||||
* metadata, edits the right field, publishes a GCE commit to the group relays.
|
||||
*/
|
||||
object GroupMetadataCommands {
|
||||
suspend fun rename(
|
||||
@@ -64,9 +71,65 @@ object GroupMetadataCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the group avatar (MIP-01 v2): encrypt the image, optionally push the
|
||||
* ciphertext to Blossom (`--server`), and commit the image fields into the group
|
||||
* metadata. The encryption is byte-for-byte interoperable with mdk/whitenoise.
|
||||
*
|
||||
* `group set-image <gid> <image-file> [--server URL] [--mime TYPE]`
|
||||
*/
|
||||
suspend fun setImage(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val gid = args.positional(0, "gid")
|
||||
val path = args.positional(1, "image-file")
|
||||
val server = args.flag("server")
|
||||
val file = File(path)
|
||||
if (!file.isFile) return Output.error("bad_args", "no such file: $path")
|
||||
|
||||
val plaintext = file.readBytes()
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey()
|
||||
|
||||
// Optionally push the encrypted blob to Blossom, signed by the keypair derived
|
||||
// from image_upload_key so an admin holding the seed can later replace/delete it.
|
||||
var uploadedUrl: String? = null
|
||||
if (server != null) {
|
||||
val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed)))
|
||||
val tmp = File.createTempFile("marmot-icon", ".bin")
|
||||
try {
|
||||
tmp.writeBytes(enc.ciphertext)
|
||||
val auth = BlossomAuth.createUploadAuth(enc.imageHash, enc.ciphertext.size.toLong(), "Group image", uploadSigner)
|
||||
val result = BlossomClient().upload(tmp, "application/octet-stream", server, auth)
|
||||
if (result.sha256 != null && result.sha256 != enc.imageHash) {
|
||||
return Output.error("hash_mismatch", "blossom returned ${result.sha256}, expected ${enc.imageHash}")
|
||||
}
|
||||
uploadedUrl = result.url
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur ->
|
||||
cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the group avatar. `group clear-image <gid>` */
|
||||
suspend fun clearImage(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.isEmpty()) return Output.error("bad_args", "group clear-image <gid>")
|
||||
return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() }
|
||||
}
|
||||
|
||||
private suspend fun edit(
|
||||
dataDir: DataDir,
|
||||
rawGid: HexKey,
|
||||
extra: Map<String, Any?> = emptyMap(),
|
||||
mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData,
|
||||
): Int {
|
||||
Context.open(dataDir).use { ctx ->
|
||||
@@ -96,7 +159,7 @@ object GroupMetadataCommands {
|
||||
"epoch" to ctx.marmot.groupEpoch(gid),
|
||||
"commit_event_id" to commit.signedEvent.id,
|
||||
"published_to" to ack.filterValues { it }.keys.map { it.url },
|
||||
),
|
||||
) + extra,
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ test_05_b_adds_a_existing
|
||||
test_06_member_removal
|
||||
test_07_metadata_rename
|
||||
test_08_admin_promote_demote
|
||||
test_17_group_image_commit
|
||||
test_09_reply_react_unreact
|
||||
test_10_concurrent_commits
|
||||
test_11_leave_group
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
--- a/src/whitenoise/relays.rs
|
||||
+++ b/src/whitenoise/relays.rs
|
||||
@@ -98,6 +98,21 @@
|
||||
}
|
||||
|
||||
pub(crate) fn defaults() -> Vec<Relay> {
|
||||
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
|
||||
+ // (comma-separated list) when present so newly created accounts only
|
||||
+ // ever get our loopback relay baked into their NIP-65 / inbox /
|
||||
+ // key-package lists. Without this override, `create-identity` stamps
|
||||
+ // the hard-coded public set into the account's relay lists, and every
|
||||
+ // later activate / publish burns connection budget on unreachable
|
||||
+ // sockets — enough to break inbox-plane activation and drop kind:1059.
|
||||
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
|
||||
+ let parsed: Vec<Relay> = from_env
|
||||
+ .split(',').map(str::trim).filter(|s| !s.is_empty())
|
||||
+ .filter_map(|u| RelayUrl::parse(u).ok())
|
||||
+ .map(|url| Relay::new(&url))
|
||||
+ .collect();
|
||||
+ if !parsed.is_empty() { return parsed; }
|
||||
+ }
|
||||
let urls: &[&str] = if cfg!(debug_assertions) {
|
||||
&["ws://localhost:8080", "ws://localhost:7777"]
|
||||
} else {
|
||||
@@ -1,23 +0,0 @@
|
||||
--- a/src/relay_control/discovery.rs
|
||||
+++ b/src/relay_control/discovery.rs
|
||||
@@ -87,6 +87,20 @@
|
||||
|
||||
/// Initial curated relay set from the planning doc.
|
||||
pub(crate) fn curated_default_relays() -> Vec<RelayUrl> {
|
||||
+ // marmot-interop-headless patch: honour $WHITENOISE_DISCOVERY_RELAYS
|
||||
+ // (comma-separated list) when present, so the harness can force wnd
|
||||
+ // to use a loopback relay instead of the baked-in public set.
|
||||
+ if let Ok(from_env) = std::env::var("WHITENOISE_DISCOVERY_RELAYS") {
|
||||
+ let parsed: Vec<RelayUrl> = from_env
|
||||
+ .split(',')
|
||||
+ .map(str::trim)
|
||||
+ .filter(|s| !s.is_empty())
|
||||
+ .filter_map(|u| RelayUrl::parse(u).ok())
|
||||
+ .collect();
|
||||
+ if !parsed.is_empty() {
|
||||
+ return parsed;
|
||||
+ }
|
||||
+ }
|
||||
[
|
||||
"wss://index.hzrd149.com",
|
||||
"wss://indexer.coracle.social",
|
||||
@@ -1,18 +1,17 @@
|
||||
--- a/src/bin/wnd.rs
|
||||
+++ b/src/bin/wnd.rs
|
||||
@@ -22,6 +22,15 @@
|
||||
--- a/crates/whitenoise-cli/src/bin/wnd.rs
|
||||
+++ b/crates/whitenoise-cli/src/bin/wnd.rs
|
||||
@@ -44,6 +44,14 @@ async fn main() -> whitenoise_cli::Result<()> {
|
||||
let args = Args::parse();
|
||||
let config = Config::resolve(args.data_dir.as_ref(), args.logs_dir.as_ref());
|
||||
|
||||
+ // marmot-interop-headless patch: allow sandboxes/CI without a real
|
||||
+ // kernel keyring to fall back to the integration-tests mock keyring
|
||||
+ // by setting $WHITENOISE_MOCK_KEYRING=1. Requires the daemon to be
|
||||
+ // built with --features cli,integration-tests. No effect otherwise.
|
||||
+ #[cfg(feature = "integration-tests")]
|
||||
+ // marmot-interop-headless patch: allow sandboxes / CI without a real kernel
|
||||
+ // keyring to fall back to the integration-tests mock keyring store by setting
|
||||
+ // $WHITENOISE_MOCK_KEYRING=1. Requires building the binaries with
|
||||
+ // `--features whitenoise/integration-tests` (the harness does).
|
||||
+ if std::env::var("WHITENOISE_MOCK_KEYRING").is_ok() {
|
||||
+ Whitenoise::initialize_mock_keyring_store();
|
||||
+ }
|
||||
+
|
||||
let wn_config = WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
|
||||
Whitenoise::initialize_whitenoise(wn_config).await?;
|
||||
|
||||
let mut wn_config =
|
||||
WhitenoiseConfig::new(&config.data_dir, &config.logs_dir, KEYRING_SERVICE_ID);
|
||||
if !args.discovery_relays.is_empty() {
|
||||
|
||||
+30
-34
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+11
@@ -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)
|
||||
|
||||
+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)
|
||||
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+43
-8
@@ -49,14 +49,22 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
|
||||
* RelayUrl relays<0..2^16-1>;
|
||||
* opaque image_hash<0..32>;
|
||||
* opaque image_key<0..32>; // HKDF seed for encryption key derivation
|
||||
* opaque image_key<0..32>; // MIP-01 v2: HKDF seed for the AEAD key
|
||||
* opaque image_nonce<0..12>;
|
||||
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
|
||||
* opaque image_upload_key<0..32>; // MIP-01 v2: HKDF seed for the Blossom-auth key
|
||||
* opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever,
|
||||
* // 8 bytes big-endian uint64 = expiration secs
|
||||
* // (value 0 is rejected)
|
||||
* } NostrGroupData;
|
||||
* ```
|
||||
*
|
||||
* The image fields carry the group avatar under the MIP-01 v2 scheme (see
|
||||
* [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are HKDF **seeds**;
|
||||
* `image_hash` is the SHA-256 of the encrypted blob. This is the exact field layout
|
||||
* mdk-core's v1/v2 `NostrGroupDataExtension` parser expects — the image fields are the
|
||||
* last ones it reads — so populating an avatar stays byte-compatible with
|
||||
* whitenoise/mdk (no trailing bytes). The plaintext MIME type is intentionally NOT
|
||||
* stored here: mdk rejects any trailing bytes at a known version and has no such field.
|
||||
*/
|
||||
@Immutable
|
||||
data class MarmotGroupData(
|
||||
@@ -80,13 +88,13 @@ data class MarmotGroupData(
|
||||
val adminPubkeys: List<HexKey> = emptyList(),
|
||||
/** Relay URLs for group message distribution. SHOULD contain at least one. */
|
||||
val relays: List<String> = emptyList(),
|
||||
/** SHA-256 hash of the encrypted group image (hex). Empty if no image. */
|
||||
/** SHA-256 hash (hex) of the ENCRYPTED group image blob (= its Blossom hash). Null if no image. */
|
||||
val imageHash: HexKey? = null,
|
||||
/** HKDF seed for deriving the image encryption key. Empty if no image. */
|
||||
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). Null if no image. */
|
||||
val imageKey: ByteArray? = null,
|
||||
/** ChaCha20-Poly1305 nonce for image encryption. Empty if no image. */
|
||||
/** 12-byte ChaCha20-Poly1305 nonce for image encryption. Null if no image. */
|
||||
val imageNonce: ByteArray? = null,
|
||||
/** HKDF seed for deriving the Blossom upload keypair. Empty if no image. */
|
||||
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). Null if no image. */
|
||||
val imageUploadKey: ByteArray? = null,
|
||||
/**
|
||||
* Disappearing-message duration in seconds (v3+).
|
||||
@@ -112,6 +120,32 @@ data class MarmotGroupData(
|
||||
/** Whether this group has an encrypted image set */
|
||||
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
|
||||
|
||||
/**
|
||||
* Return a copy carrying the given (already-encrypted-and-uploaded) group image.
|
||||
* Keeps all image-field knowledge in one place so the UI and the CLI stay in sync.
|
||||
*/
|
||||
fun withImage(
|
||||
imageHash: HexKey,
|
||||
imageKey: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
imageUploadKey: ByteArray,
|
||||
): MarmotGroupData =
|
||||
copy(
|
||||
imageHash = imageHash,
|
||||
imageKey = imageKey,
|
||||
imageNonce = imageNonce,
|
||||
imageUploadKey = imageUploadKey,
|
||||
)
|
||||
|
||||
/** Return a copy with the group image cleared. */
|
||||
fun withoutImage(): MarmotGroupData =
|
||||
copy(
|
||||
imageHash = null,
|
||||
imageKey = null,
|
||||
imageNonce = null,
|
||||
imageUploadKey = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
|
||||
*
|
||||
@@ -167,8 +201,9 @@ data class MarmotGroupData(
|
||||
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
|
||||
|
||||
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
|
||||
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps
|
||||
// the wire format byte-for-byte compatible with older implementations (MDK v2).
|
||||
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps the
|
||||
// wire format byte-for-byte compatible with mdk's v1/v2 NostrGroupDataExtension
|
||||
// parser (which ends at image_upload_key and rejects any trailing bytes).
|
||||
if (version >= 3) {
|
||||
val disappearingBytes =
|
||||
disappearingMessageSecs?.let { secs ->
|
||||
|
||||
+71
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot.mip01Groups
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Marmot group image (avatar) encryption — MIP-01 v2.
|
||||
*
|
||||
* This is byte-for-byte interoperable with the reference implementation
|
||||
* (`mdk-core`'s `extension/group_image.rs`, used by whitenoise): a group avatar
|
||||
* is encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded to Blossom,
|
||||
* addressed by the SHA-256 of the *ciphertext*.
|
||||
*
|
||||
* The parameters live inside the group's [MarmotGroupData] extension:
|
||||
* - `image_key` — a 32-byte HKDF **seed**. The AEAD key is
|
||||
* `HKDF-SHA256(salt=∅, ikm=image_key, info="mip01-image-encryption-v2", 32)`
|
||||
* (see [Mip01ImageCrypto.deriveImageEncryptionKey]).
|
||||
* - `image_nonce` — the 12-byte ChaCha20-Poly1305 nonce (used verbatim).
|
||||
* - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash).
|
||||
* - `image_upload_key` — a 32-byte HKDF **seed**. The Blossom-auth secp256k1
|
||||
* secret is `HKDF-SHA256(salt=∅, ikm=image_upload_key, info="mip01-blossom-upload-v2", 32)`
|
||||
* (see [Mip01ImageCrypto.deriveBlossomUploadSeed]).
|
||||
*
|
||||
* The AEAD uses **no associated data** (empty AAD), matching MIP-01. The plaintext
|
||||
* MIME type is descriptive metadata only — it is deliberately NOT bound into the
|
||||
* crypto and NOT stored on the wire, because mdk's `NostrGroupDataExtension` parser
|
||||
* rejects any trailing bytes at a known version and has no media_type field, so
|
||||
* storing it would break group parsing for whitenoise/mdk members.
|
||||
*
|
||||
* A fetching client MUST verify that the fetched bytes hash to `image_hash` before
|
||||
* decrypting.
|
||||
*
|
||||
* ### Version fallback (parse both), mirroring mdk
|
||||
* [decryptAny] first tries v2 (HKDF-derived key); on failure it falls back to v1,
|
||||
* where `image_key` is used directly as the AEAD key. New images are always v2.
|
||||
*/
|
||||
object MarmotGroupImageEncryption {
|
||||
const val KEY_LENGTH = 32
|
||||
const val NONCE_LENGTH = 12
|
||||
|
||||
private val EMPTY_AAD = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Result of encrypting a group image, ready to be uploaded to Blossom and
|
||||
* folded into a [MarmotGroupData].
|
||||
*/
|
||||
class Encrypted(
|
||||
/** The encrypted blob to upload to Blossom (ciphertext || 16-byte tag). */
|
||||
val ciphertext: ByteArray,
|
||||
/** Random 32-byte HKDF seed — store as `image_key`. */
|
||||
val imageKey: ByteArray,
|
||||
/** Random 12-byte nonce — store as `image_nonce`. */
|
||||
val imageNonce: ByteArray,
|
||||
/** SHA-256 of [ciphertext] (hex) — store as `image_hash`; also the Blossom hash. */
|
||||
val imageHash: HexKey,
|
||||
)
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext image with a freshly-generated seed + nonce (MIP-01 v2).
|
||||
* Returns the ciphertext to upload plus the parameters to persist in
|
||||
* [MarmotGroupData].
|
||||
*/
|
||||
fun encrypt(plaintext: ByteArray): Encrypted {
|
||||
val imageKeySeed = RandomInstance.bytes(KEY_LENGTH)
|
||||
val imageNonce = RandomInstance.bytes(NONCE_LENGTH)
|
||||
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed)
|
||||
val ciphertext = ChaCha20Poly1305.encrypt(plaintext, EMPTY_AAD, imageNonce, aeadKey)
|
||||
return Encrypted(
|
||||
ciphertext = ciphertext,
|
||||
imageKey = imageKeySeed,
|
||||
imageNonce = imageNonce,
|
||||
imageHash = sha256(ciphertext).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a group image blob (MIP-01 v2): `image_key` is an HKDF seed.
|
||||
*
|
||||
* @throws IllegalStateException on authentication failure.
|
||||
*/
|
||||
fun decrypt(
|
||||
ciphertext: ByteArray,
|
||||
imageKey: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
): ByteArray {
|
||||
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey)
|
||||
return ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, aeadKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a group image blob, trying v2 (HKDF-derived key) first and falling
|
||||
* back to v1 (raw `image_key`), exactly like mdk. Returns null if neither
|
||||
* authenticates.
|
||||
*/
|
||||
fun decryptAny(
|
||||
ciphertext: ByteArray,
|
||||
imageKey: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
): ByteArray? {
|
||||
if (imageKey.size != KEY_LENGTH || imageNonce.size != NONCE_LENGTH) return null
|
||||
|
||||
// v2: image_key is an HKDF seed.
|
||||
try {
|
||||
return decrypt(ciphertext, imageKey, imageNonce)
|
||||
} catch (_: Exception) {
|
||||
// fall through to v1
|
||||
}
|
||||
|
||||
// v1: image_key is the AEAD key directly.
|
||||
return try {
|
||||
ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, imageKey)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the 32-byte HKDF **seed** stored as [MarmotGroupData.imageUploadKey].
|
||||
* The actual Blossom-auth secp256k1 secret is derived from it via
|
||||
* [deriveUploadKeypairSecret].
|
||||
*/
|
||||
fun generateUploadKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH)
|
||||
|
||||
/**
|
||||
* Derive the 32-byte secp256k1 secret used to authorize Blossom writes for the
|
||||
* avatar from the stored `image_upload_key` seed (MIP-01 v2).
|
||||
*/
|
||||
fun deriveUploadKeypairSecret(imageUploadKey: ByteArray): ByteArray = Mip01ImageCrypto.deriveBlossomUploadSeed(imageUploadKey)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MarmotGroupImageTest {
|
||||
private val nostrGroupId = "aa".repeat(32)
|
||||
private val plaintext = "PNGDATA-a-fake-avatar-image-payload".encodeToByteArray()
|
||||
|
||||
private val emptyAad = ByteArray(0)
|
||||
|
||||
// ------------------------------------------------------------ encryption (MIP-01 v2)
|
||||
|
||||
@Test
|
||||
fun encrypt_thenDecrypt_roundTrips() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
|
||||
assertEquals(MarmotGroupImageEncryption.KEY_LENGTH, enc.imageKey.size)
|
||||
assertEquals(MarmotGroupImageEncryption.NONCE_LENGTH, enc.imageNonce.size)
|
||||
|
||||
val decrypted = MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce)
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageHash_isSha256OfCiphertext() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
assertEquals(sha256(enc.ciphertext).toHexKey(), enc.imageHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-for-byte interop guard: the AEAD key MUST be
|
||||
* HKDF(image_key, "mip01-image-encryption-v2") with empty AAD — exactly what mdk's
|
||||
* group_image.rs does. If this drifts, Amethyst and whitenoise stop interoperating.
|
||||
*/
|
||||
@Test
|
||||
fun scheme_matchesMdk_hkdfSeedAndEmptyAad() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val derivedKey = Mip01ImageCrypto.deriveImageEncryptionKey(enc.imageKey)
|
||||
val manual = ChaCha20Poly1305.decrypt(enc.ciphertext, emptyAad, enc.imageNonce, derivedKey)
|
||||
assertContentEquals(plaintext, manual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decrypt_wrongSeed_fails() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
assertFailsWith<IllegalStateException> {
|
||||
MarmotGroupImageEncryption.decrypt(enc.ciphertext, RandomInstance.bytes(32), enc.imageNonce)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptAny_v2_succeeds() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val out = MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce)
|
||||
assertNotNull(out)
|
||||
assertContentEquals(plaintext, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptAny_fallsBackToV1RawKey() {
|
||||
// v1: image_key is used directly as the AEAD key (no HKDF), empty AAD — mdk's fallback.
|
||||
val rawKey = RandomInstance.bytes(32)
|
||||
val nonce = RandomInstance.bytes(12)
|
||||
val v1Blob = ChaCha20Poly1305.encrypt(plaintext, emptyAad, nonce, rawKey)
|
||||
|
||||
val out = MarmotGroupImageEncryption.decryptAny(v1Blob, rawKey, nonce)
|
||||
assertNotNull(out)
|
||||
assertContentEquals(plaintext, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptAny_garbage_returnsNull() {
|
||||
val out =
|
||||
MarmotGroupImageEncryption.decryptAny(
|
||||
RandomInstance.bytes(64),
|
||||
RandomInstance.bytes(32),
|
||||
RandomInstance.bytes(12),
|
||||
)
|
||||
assertNull(out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadKeypairSecret_isDeterministicAndDistinctFromSeed() {
|
||||
val seed = RandomInstance.bytes(32)
|
||||
val s1 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed)
|
||||
val s2 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed)
|
||||
assertEquals(32, s1.size)
|
||||
assertContentEquals(s1, s2)
|
||||
assertTrue(!s1.contentEquals(seed), "upload secret must be derived, not the raw seed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cipher_encryptDecrypt_roundTrips_asUsedByUploadAndDisplay() {
|
||||
val uploadCipher = MarmotGroupImageCipher.forNewImage()
|
||||
val blob = uploadCipher.encrypt(plaintext)
|
||||
|
||||
val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce)
|
||||
assertContentEquals(plaintext, displayCipher.decrypt(blob))
|
||||
assertContentEquals(plaintext, displayCipher.decryptOrNull(blob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cipher_decryptOrNull_wrongSeed_returnsNull() {
|
||||
val uploadCipher = MarmotGroupImageCipher.forNewImage()
|
||||
val blob = uploadCipher.encrypt(plaintext)
|
||||
val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce)
|
||||
assertNull(wrong.decryptOrNull(blob))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ wire format
|
||||
|
||||
@Test
|
||||
fun wire_roundTrips_withImage() {
|
||||
val original =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Otters",
|
||||
description = "river friends",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
relays = listOf("wss://relay.example/"),
|
||||
imageHash = "cc".repeat(32),
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
)
|
||||
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
|
||||
assertEquals("Otters", decoded.name)
|
||||
assertEquals("cc".repeat(32), decoded.imageHash)
|
||||
assertContentEquals("dd".repeat(32).hexToByteArray(), decoded.imageKey)
|
||||
assertContentEquals("ee".repeat(12).hexToByteArray(), decoded.imageNonce)
|
||||
assertContentEquals("ff".repeat(32).hexToByteArray(), decoded.imageUploadKey)
|
||||
assertNull(decoded.disappearingMessageSecs)
|
||||
assertTrue(decoded.hasImage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wire_roundTrips_withoutImage() {
|
||||
val original =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Plain",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
relays = listOf("wss://relay.example/"),
|
||||
)
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
|
||||
assertEquals("Plain", decoded.name)
|
||||
assertNull(decoded.imageHash)
|
||||
assertTrue(!decoded.hasImage())
|
||||
}
|
||||
|
||||
/**
|
||||
* INTEROP GUARD: at version 2, a group WITH an image must serialize with the image
|
||||
* fields as the LAST fields and ZERO trailing bytes — exactly what mdk-core's
|
||||
* `TlsNostrGroupDataExtensionV1V2` parser consumes. mdk rejects any trailing bytes at a
|
||||
* known version, so a stray byte here silently breaks the group for whitenoise/mdk
|
||||
* members. This test reproduces mdk's v1/v2 field consumption and asserts nothing is
|
||||
* left over.
|
||||
*/
|
||||
@Test
|
||||
fun wire_v2WithImage_hasNoTrailingBytesForMdk() {
|
||||
val withImage =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Compat",
|
||||
description = "d",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
relays = listOf("wss://relay.example/"),
|
||||
imageHash = "cc".repeat(32),
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
)
|
||||
|
||||
val reader = TlsReader(withImage.encodeTls())
|
||||
reader.readUint16() // version
|
||||
reader.readBytes(32) // nostr_group_id
|
||||
reader.readOpaqueVarInt() // name
|
||||
reader.readOpaqueVarInt() // description
|
||||
reader.readOpaqueVarInt() // admin_pubkeys
|
||||
reader.readOpaqueVarInt() // relays
|
||||
reader.readOpaqueVarInt() // image_hash
|
||||
reader.readOpaqueVarInt() // image_key
|
||||
reader.readOpaqueVarInt() // image_nonce
|
||||
reader.readOpaqueVarInt() // image_upload_key
|
||||
assertTrue(
|
||||
!reader.hasRemaining,
|
||||
"v2 image extension has trailing bytes past image_upload_key → mdk would reject it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wire_roundTrips_v3Disappearing_withImage() {
|
||||
val original =
|
||||
MarmotGroupData(
|
||||
version = 3,
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Ephemeral",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
relays = emptyList(),
|
||||
imageHash = "cc".repeat(32),
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
disappearingMessageSecs = 3600UL,
|
||||
)
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
|
||||
assertEquals(3600UL, decoded.disappearingMessageSecs)
|
||||
assertTrue(decoded.hasImage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withImage_andWithoutImage_helpers() {
|
||||
val base =
|
||||
MarmotGroupData(
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Base",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
)
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val withImg = base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32))
|
||||
assertTrue(withImg.hasImage())
|
||||
|
||||
val cleared = withImg.withoutImage()
|
||||
assertTrue(!cleared.hasImage())
|
||||
assertNull(cleared.imageUploadKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user