feat: implement MIP-04 encrypted media upload for Marmot groups

Adds full MIP-04 (Encrypted Media) support for Marmot group chats:

Protocol layer (quartz):
- Mip04MediaEncryption: ChaCha20-Poly1305 AEAD with MLS exporter key
  derivation (HKDF-Expand), random nonces, and AAD binding
- Mip04Cipher/Mip04NostrCipher: NostrCipher adapters for the existing
  EncryptedBlobInterceptor download-and-decrypt pipeline
- Mip04IMetaTag: NIP-92 imeta tag builder/parser with MIP-04 fields
  (url, m, filename, x, n, v=mip04-v2)
- MlsGroupManager.mediaExporterSecret(): MLS-Exporter("marmot",
  "encrypted-media", 32)

Upload flow (amethyst):
- MarmotFileUploader: per-file Mip04NostrCipher → existing
  UploadOrchestrator.uploadEncrypted pipeline (compression, stripping,
  Blossom upload)
- MarmotFileSender: builds imeta tags and sends kind:9 inner events
  through the MLS group message pipeline
- MarmotGroupMessageComposer: adds SelectFromGallery leading icon and
  ChatFileUploadDialog (mirrors NIP-17 DM pattern)

Display flow:
- RenderMarmotEncryptedMedia: parses imeta v=mip04-v2 tags, derives
  Mip04Cipher, registers in EncryptionKeyCache, renders via
  ZoomableContentView (same path as NIP-17 encrypted files)
- MarmotGroupList.noteToGroupIndex: reverse index for mapping notes
  back to their group ID (needed for exporter secret lookup)
- ChatMessageCompose NoteRow: routes MIP-04 events to the new renderer

https://claude.ai/code/session_01TckzZLpJdmE6p198DHBQ5i
This commit is contained in:
Claude
2026-04-16 02:08:45 +00:00
parent 3356059d50
commit 5f5d576f1e
13 changed files with 1054 additions and 5 deletions
@@ -403,6 +403,12 @@ class MarmotManager(
*/
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
/**
* Get the MIP-04 media exporter secret for a group.
* MLS-Exporter("marmot", "encrypted-media", 32)
*/
fun mediaExporterSecret(nostrGroupId: HexKey): ByteArray = groupManager.mediaExporterSecret(nostrGroupId)
/**
* Get the MIP-01 group metadata from the MLS GroupContext extensions.
* Returns null if the group doesn't exist or has no MarmotGroupData extension.
@@ -36,6 +36,8 @@ class MarmotGroupList(
var rooms = LargeCache<HexKey, MarmotGroupChatroom>()
private set
private val noteToGroupIndex = LargeCache<HexKey, HexKey>()
private val _groupListChanges = MutableSharedFlow<HexKey>(0, 20, BufferOverflow.DROP_OLDEST)
val groupListChanges = _groupListChanges
@@ -56,6 +58,7 @@ class MarmotGroupList(
chatroom.addMessageSync(msg)
}
if (added) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
if (isSelfAuthored) {
chatroom.ownerSentMessage = true
}
@@ -73,6 +76,7 @@ class MarmotGroupList(
) {
val chatroom = getOrCreateGroup(nostrGroupId)
if (chatroom.restoreMessageSync(msg)) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
if (msg.author?.pubkeyHex == ownerPubKey) {
chatroom.ownerSentMessage = true
}
@@ -80,6 +84,8 @@ class MarmotGroupList(
}
}
fun groupIdForNote(noteId: HexKey): HexKey? = noteToGroupIndex.get(noteId)
/**
* Mark a group as "known" by the local user — used right after the user
* creates a group, so the creator doesn't appear under "New Requests"