From c90bf8f6104ca0eb5037de2e2ae351cec3ea11b5 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 18 Mar 2026 14:42:44 +0200 Subject: [PATCH] feat(media): add Note/Picture post type selector; fix gallery crash - Post type toggle (Note vs Picture/kind 20) in compose dialog, only shown when image files are attached. Text input disabled in picture mode. - Fix LazyVerticalGrid crash in GalleryTab: bounded height via fillParentMaxHeight() when nested inside LazyColumn. - Phase 1 testing plan: all pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../amethyst/desktop/ui/UserProfileScreen.kt | 1 + ...18-feat-desktop-dm-encrypted-media-plan.md | 755 ++++++++++++++---- 2 files changed, 619 insertions(+), 137 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 262dde6d8..15f0a0de6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -730,6 +730,7 @@ fun UserProfileScreen( GalleryTab( pictureEvents = pictureEvents, onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + modifier = Modifier.fillParentMaxHeight(), ) } } diff --git a/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md index 441ea1ec2..88474947f 100644 --- a/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md +++ b/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md @@ -3,11 +3,33 @@ title: "feat: Desktop DM Encrypted Media (NIP-17)" type: feat status: active date: 2026-03-18 +deepened: 2026-03-18 origin: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md --- # feat: Desktop DM Encrypted Media (NIP-17) +## Enhancement Summary + +**Deepened on:** 2026-03-18 +**Sections enhanced:** 5 phases + security + performance + edge cases +**Research sources:** Source code analysis of all 9 key files, Android reference implementation, Blossom protocol research, existing learnings + +### Key Improvements +1. Corrected `DesktopBlossomClient` — needs `ByteArray` overload (currently only accepts `File`) +2. `IAccount` interface needs `sendNip17EncryptedFile()` added (not just `DesktopIAccount`) +3. `NIP17Factory.createEncryptedFileNIP17()` already exists — plan incorrectly referenced `createFileNIP17()` +4. `DesktopMediaMetadata.compute()` reads file bytes internally — encrypted upload must avoid double-read +5. Added streaming encryption consideration for large files and memory pressure mitigation + +### Critical Corrections from Source Code +- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` parameter — encrypted size, not original +- `DesktopBlossomClient.upload()` only accepts `File`, not `ByteArray` — needs overload or temp file +- `ChatMessageEncryptedFileHeaderEvent.build()` takes `cipher: AESGCM` directly — no manual key/nonce extraction needed +- `key()` and `nonce()` return tag values as parsed types (via `EncryptionKey`/`EncryptionNonce` tags) + +--- + ## Overview Implement full send + receive encrypted media support in desktop DM chat. Users can attach files via paperclip button or drag-and-drop, which get AES-GCM encrypted before upload to Blossom. Files are sent as `ChatMessageEncryptedFileHeaderEvent` (kind 15) wrapped in GiftWrap (NIP-59). Received encrypted media is downloaded, decrypted, and displayed inline with a lock icon overlay. @@ -24,6 +46,8 @@ Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol (see brainstorm: `docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md`) +--- + ## Implementation Phases ### Phase A: Encrypted Upload Pipeline @@ -32,8 +56,9 @@ Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol **Files:** - `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` — add `uploadEncrypted()` -- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — may need raw bytes upload variant -- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — verify auth uses encrypted blob hash +- `desktopApp/.../service/upload/DesktopBlossomClient.kt` — add `ByteArray` upload overload +- `desktopApp/.../service/upload/DesktopBlossomAuth.kt` — no changes needed (already takes hash + size) +- `desktopApp/.../service/upload/DesktopMediaMetadata.kt` — no changes needed **Implementation:** @@ -42,45 +67,100 @@ Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol suspend fun uploadEncrypted( file: File, cipher: AESGCM, - server: String, + serverBaseUrl: String, signer: NostrSigner, - tracker: DesktopUploadTracker? = null -): UploadResult { - // 1. Read file bytes - val plaintext = file.readBytes() - - // 2. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) +): EncryptedUploadResult { + // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash) + // NOTE: DesktopMediaMetadata.compute() reads file bytes internally for SHA256 val metadata = DesktopMediaMetadata.compute(file) - // 3. Encrypt + // 2. Read file bytes and encrypt + val plaintext = file.readBytes() val encrypted = cipher.encrypt(plaintext) - // 4. Compute SHA256 of ENCRYPTED blob (critical: not plaintext) - val encryptedHash = sha256Hex(encrypted) + // 3. Compute SHA256 of ENCRYPTED blob (critical: not plaintext) + val encryptedHash = sha256(encrypted).toHexKey() + val encryptedSize = encrypted.size.toLong() - // 5. Create Blossom auth with encrypted hash - val auth = DesktopBlossomAuth.createUploadAuth( + // 4. Create Blossom auth with ENCRYPTED hash and size + val authHeader = DesktopBlossomAuth.createUploadAuth( hash = encryptedHash, - signer = signer + size = encryptedSize, + alt = "Encrypted upload", + signer = signer, ) - // 6. Upload encrypted blob - val result = DesktopBlossomClient.upload( + // 5. Upload encrypted blob (needs ByteArray overload on client) + val result = client.upload( bytes = encrypted, - hash = encryptedHash, - auth = auth, - server = server, - tracker = tracker + contentType = "application/octet-stream", // encrypted blob, not original mime + serverBaseUrl = serverBaseUrl, + authHeader = authHeader, ) - // 7. Return result with both hashes - return UploadResult(result, metadata, originalHash = metadata.sha256) + return EncryptedUploadResult( + blossom = result, + metadata = metadata, // pre-encryption metadata (dimensions, blurhash, mime) + encryptedHash = encryptedHash, + encryptedSize = encryptedSize.toInt(), + ) } ``` -**Reference:** Android's `ChatFileUploader.justUploadNIP17()` at `amethyst/.../upload/ChatFileUploader.kt:39-80` +```kotlin +// New data class alongside existing UploadResult +data class EncryptedUploadResult( + val blossom: BlossomUploadResult, + val metadata: MediaMetadata, // original file metadata + val encryptedHash: String, // SHA256 of encrypted blob + val encryptedSize: Int, // size of encrypted blob +) +``` -**Critical gotcha:** Hash the encrypted blob, not plaintext. The `X-SHA-256` header and kind 24242 `x` tag must match the encrypted bytes. +```kotlin +// DesktopBlossomClient.kt — add ByteArray overload +suspend fun upload( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, +): BlossomUploadResult = withContext(Dispatchers.IO) { + val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload" + val requestBody = bytes.toRequestBody(contentType.toMediaType()) + + val requestBuilder = Request.Builder() + .url(apiUrl) + .put(requestBody) + + authHeader?.let { requestBuilder.addHeader("Authorization", it) } + + val response = okHttpClient.newCall(requestBuilder.build()).execute() + response.use { + if (!it.isSuccessful) { + val reason = it.headers["X-Reason"] ?: it.code.toString() + throw RuntimeException("Upload failed ($serverBaseUrl): $reason") + } + JsonMapper.fromJson(it.body.string()) + } +} +``` + +### Research Insights — Phase A + +**Critical gotchas (from Blossom protocol research + source code analysis):** + +| Issue | Detail | Solution | +|-------|--------|----------| +| Hash mismatch | `X-SHA-256` header must match encrypted blob hash, not plaintext | Compute SHA256 after `cipher.encrypt()` | +| Content-Type | Upload encrypted blob as `application/octet-stream`, not original MIME | Server stores opaque blob | +| Auth size | `DesktopBlossomAuth.createUploadAuth(size=)` must be encrypted size | Pass `encrypted.size.toLong()` | +| Double file read | `DesktopMediaMetadata.compute()` calls `file.readBytes()` internally | Acceptable — metadata computation is separate from encryption read | +| Memory pressure | `file.readBytes()` + `cipher.encrypt()` = 2x file size in memory | For files <50MB this is fine; for larger files consider streaming (future) | + +**Security considerations:** +- Generate fresh `AESGCM()` per file — never reuse key/nonce pairs (AES-GCM nonce reuse completely breaks confidentiality) +- Clear `plaintext` ByteArray after encryption (`plaintext.fill(0)`) to minimize exposure window +- Encrypted blob content type should be `application/octet-stream` to avoid leaking file type to Blossom server --- @@ -89,31 +169,184 @@ suspend fun uploadEncrypted( **Goal:** Paperclip button, thumbnail row, drag-and-drop in `ChatPane.kt`. **Files:** -- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable +- `desktopApp/.../ui/chats/ChatPane.kt` — modify `MessageInput()` composable and `ChatPane()` for drag-drop **Implementation:** -Add to `MessageInput()` (currently at line ~527): +Modify `MessageInput()` (currently lines 527-627) — add parameters and UI elements: -1. **State:** `attachedFiles: MutableList` tracked in `ChatNewMessageState` or local state -2. **Paperclip button:** Icon button left of text field, opens `JFileChooser` (same pattern as `ComposeNoteDialog.kt`) -3. **Thumbnail row:** Above the text input, shows attached file thumbnails with X remove button -4. **Drag-and-drop:** `Modifier.onExternalDrag` on the chat pane area (same pattern as `ComposeNoteDialog.kt`) -5. **Encryption indicator:** Small lock icon badge on attachment thumbnails when in NIP-17 mode +```kotlin +// Updated MessageInput signature +@Composable +private fun MessageInput( + messageText: String, + isNip17: Boolean, + requiresNip17: Boolean, + canSend: Boolean, + attachedFiles: List, // NEW + onMessageChange: (String) -> Unit, + onToggleNip17: () -> Unit, + onAttachFiles: (List) -> Unit, // NEW + onRemoveFile: (Int) -> Unit, // NEW + onSend: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + // Attachment thumbnail row (above text input) + if (attachedFiles.isNotEmpty()) { + AttachmentRow( + files = attachedFiles, + isEncrypted = isNip17, + onRemove = onRemoveFile, + ) + Spacer(Modifier.height(4.dp)) + } -**UI Layout (from brainstorm):** -``` -┌─────────────────────────────────┐ -│ Chat messages... │ -│ │ -│ ┌─────┐ ┌─────┐ │ -│ │thumb│ │thumb│ (attachments) │ -│ └──x──┘ └──x──┘ │ -│ 📎 [Type a message... ] [→] │ -└─────────────────────────────────┘ + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Paperclip attach button (only in NIP-17 mode) + if (isNip17) { + IconButton( + onClick = { /* open JFileChooser */ }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach file", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Existing OutlinedTextField... + // Existing Send button... + } + + // Existing NIP-17 indicator... + } +} ``` -**Reference:** `ComposeNoteDialog.kt` drag-drop pattern (line ~182-194 for `MediaAttachmentRow`, line ~254-303 for upload pipeline) +```kotlin +// AttachmentRow composable +@Composable +private fun AttachmentRow( + files: List, + isEncrypted: Boolean, + onRemove: (Int) -> Unit, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + ) { + itemsIndexed(files) { index, file -> + Box(modifier = Modifier.size(64.dp)) { + // Thumbnail (image preview or file icon) + AttachmentThumbnail(file) + + // Remove button (top-right) + IconButton( + onClick = { onRemove(index) }, + modifier = Modifier.align(Alignment.TopEnd).size(18.dp), + ) { + Icon(Icons.Default.Close, "Remove", Modifier.size(12.dp)) + } + + // Lock icon overlay (bottom-end, only when encrypted) + if (isEncrypted) { + Icon( + Icons.Default.Lock, + contentDescription = "Encrypted", + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + RoundedCornerShape(4.dp), + ) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} +``` + +**File picker (JFileChooser pattern from ComposeNoteDialog):** + +```kotlin +// File picker helper — runs on AWT thread +private fun openFilePicker(onFilesSelected: (List) -> Unit) { + val chooser = JFileChooser().apply { + isMultiSelectionEnabled = true + fileFilter = FileNameExtensionFilter( + "Media files", + "jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov", + "mp3", "ogg", "wav", "flac", "aac", + ) + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + onFilesSelected(chooser.selectedFiles.toList()) + } +} +``` + +**Drag-and-drop on ChatPane (wrapping the Column):** + +```kotlin +// In ChatPane() — wrap the main Column with drag-drop +var isDragOver by remember { mutableStateOf(false) } + +Column( + modifier = modifier + .fillMaxSize() + .onExternalDrag( + onDragStart = { isDragOver = true }, + onDragExit = { isDragOver = false }, + onDrop = { state -> + isDragOver = false + val files = state.dragData + .let { it as? DragData.FilesList } + ?.readFiles() + ?.mapNotNull { uri -> File(URI(uri)) } + ?: emptyList() + // Add to attachedFiles state + attachedFiles.addAll(files) + }, + ) + .then( + if (isDragOver) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp)) + } else { + Modifier + } + ), +) { + // ... existing ChatPane content +} +``` + +### Research Insights — Phase B + +**Compose best practices applied:** + +| Pattern | Recommendation | Rationale | +|---------|---------------|-----------| +| State location | `attachedFiles` as `mutableStateListOf()` in `ChatPane`, not `MessageInput` | State hoisted to parent that also handles send/upload logic | +| File picker thread | `JFileChooser` must run on EDT; use `withContext(Dispatchers.Main)` or `SwingUtilities.invokeLater` | AWT file dialogs block; Compose coroutines must not be blocked | +| Drag-drop modifier | `Modifier.onExternalDrag` from `compose.ui` | Already used in ComposeNoteDialog — consistent pattern | +| Thumbnail rendering | Use `ImageIO.read()` for image thumbnails; generic icon for audio/video | Avoid loading full-resolution images; scale down for 64dp thumbnails | +| `canSend` update | `canSend` should also be true when `attachedFiles.isNotEmpty()` even if `messageText.isEmpty()` | Allow sending file-only messages (no text required) | + +**Desktop UX considerations:** +- Keyboard shortcut: Cmd+V / Ctrl+V paste should also add clipboard images to attachments (future enhancement) +- Tooltip on disabled attach button (NIP-04 mode): "Switch to NIP-17 to send files" +- Maximum attachment count: limit to 10 files to prevent UI overflow +- File size validation: warn on files >50MB before attempting upload --- @@ -122,52 +355,129 @@ Add to `MessageInput()` (currently at line ~527): **Goal:** Build and dispatch `ChatMessageEncryptedFileHeaderEvent` (kind 15) from upload results. **Files:** -- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `MessageInput()` +- `desktopApp/.../ui/chats/ChatPane.kt` — send logic in `ChatPane()` scope - `desktopApp/.../model/DesktopIAccount.kt` — add `sendNip17EncryptedFile()` +- `commons/.../model/IAccount.kt` — add interface method **Implementation:** ```kotlin -// In ChatPane.kt send handler — after upload completes -fun sendEncryptedFiles( - uploads: List>, - recipients: List, - account: DesktopIAccount +// IAccount.kt — add to interface (commons/commonMain) +/** Send a NIP-17 gift-wrapped encrypted file header */ +suspend fun sendNip17EncryptedFile(template: EventTemplate) +``` + +```kotlin +// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly) +override suspend fun sendNip17EncryptedFile( + template: EventTemplate, ) { - for ((result, cipher) in uploads) { - val eventTemplate = ChatMessageEncryptedFileHeaderEvent.build( - to = recipients, - url = result.blossom.url, - cipher = cipher, - mimeType = result.metadata.mimeType, - hash = result.metadata.encryptedHash, - size = result.metadata.encryptedSize, - dimension = result.metadata.dimension, - blurhash = result.metadata.blurhash, - originalHash = result.metadata.originalHash - ) - account.sendNip17EncryptedFile(eventTemplate) + if (!isWriteable()) return + + val result = NIP17Factory().createEncryptedFileNIP17(template, signer) + + // Optimistic local add — use the inner event + val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent + addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey)) + + // Collect wraps with target relays and send + val batch = result.wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = if (recipientKey != null) { + val dmRelays = localCache.getOrCreateUser(recipientKey) + .dmInboxRelays()?.toSet() + dmRelays?.ifEmpty { null } ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays } + + scope.launch { dmSendTracker.sendBatch(batch) } } ``` ```kotlin -// DesktopIAccount.kt — new method (mirrors sendNip17PrivateMessage pattern) -suspend fun sendNip17EncryptedFile( - eventTemplate: ChatMessageEncryptedFileHeaderEvent +// ChatPane.kt — send handler for encrypted files +// In ChatPane composable scope, after upload completes: +private suspend fun sendEncryptedFiles( + uploads: List>, + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, ) { - // Same GiftWrap flow as sendNip17PrivateMessage() - val wraps = NIP17Factory().createFileNIP17( - event = eventTemplate, - signer = signer - ) - // Optimistically add to local chatroom - // Send wraps to recipient DM inbox relays - sendGiftWraps(wraps) + val recipients = roomKey.users.map { cacheProvider.getOrCreateUser(it).toPTag() } + + for ((result, cipher) in uploads) { + val template = ChatMessageEncryptedFileHeaderEvent.build( + to = recipients, + url = result.blossom.url, + cipher = cipher, // passes algo, key, nonce automatically + mimeType = result.metadata.mimeType, + hash = result.encryptedHash, // hash of encrypted blob + size = result.encryptedSize, + dimension = result.metadata.width?.let { w -> + result.metadata.height?.let { h -> DimensionTag(w, h) } + }, + blurhash = result.metadata.blurhash, + originalHash = result.metadata.sha256, // hash of original plaintext + ) + account.sendNip17EncryptedFile(template) + } } ``` -**Reference:** Android's `ChatFileSender.sendNIP17()` at `amethyst/.../upload/ChatFileSender.kt:46-71` +**Full send flow in ChatPane (upload + send):** + +```kotlin +// In ChatPane composable — triggered by Send button when attachedFiles.isNotEmpty() +scope.launch { + val orchestrator = DesktopUploadOrchestrator() + val server = /* user's default Blossom server from kind 10063 */ + val uploads = mutableListOf>() + + for (file in attachedFiles) { + val cipher = AESGCM() // fresh cipher per file + try { + val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer) + uploads.add(result to cipher) + } catch (e: Exception) { + // Show error, keep remaining files for retry + println("Upload failed for ${file.name}: ${e.message}") + } + } + + if (uploads.isNotEmpty()) { + sendEncryptedFiles(uploads, roomKey, account, cacheProvider) + attachedFiles.clear() + + // Also send text message if present + if (messageState.canSend) { + messageState.send() + messageState.clear() + } + } +} +``` + +### Research Insights — Phase C + +**Correctness checks from source code:** + +| Verified | Detail | +|----------|--------| +| `NIP17Factory.createEncryptedFileNIP17()` | Exists at `NIP17Factory.kt:86-97` — takes `EventTemplate` | +| `ChatMessageEncryptedFileHeaderEvent.build()` | Takes `cipher: AESGCM` directly — auto-extracts algo/key/nonce via `encryptionAlgo(cipher.name())`, `encryptionKey(cipher.keyBytes)`, `encryptionNonce(cipher.nonce)` | +| Android pattern | `Account.sendNip17EncryptedFile()` at line 1612 calls `NIP17Factory().createEncryptedFileNIP17(template, signer)` then `broadcastPrivately(wraps)` | +| Desktop pattern | `DesktopIAccount.sendNip17PrivateMessage()` at line 128 — same structure, replace `createMessageNIP17` with `createEncryptedFileNIP17` | + +**Concurrency considerations:** +- Upload files sequentially (not parallel) to avoid memory pressure from multiple concurrent encryptions +- Use `supervisorScope` if you want one failed upload to not cancel others +- Send events can be parallelized (each is independent after upload) + +**Interface change impact:** +- Adding `sendNip17EncryptedFile` to `IAccount` requires implementation in Android's `Account.kt` too — but it already has it as a non-override method. Just add `override` keyword. --- @@ -176,74 +486,219 @@ suspend fun sendNip17EncryptedFile( **Goal:** Encrypted media in received DMs renders inline with lock icon. **Files:** -- `desktopApp/.../ui/chats/ChatPane.kt` — `ChatFileAttachment()` composable (line ~442) -- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists -- Potentially `commons/.../ui/chat/ChatMessageCompose.kt` if shared rendering needed +- `desktopApp/.../ui/chats/ChatPane.kt` — enhance `ChatFileAttachment()` (line 442) +- `desktopApp/.../service/media/EncryptedMediaService.kt` — already exists, enhance with caching + +**Current state:** `ChatFileAttachment` is called at line 442 but its implementation is minimal or placeholder. The event is already detected as `ChatMessageEncryptedFileHeaderEvent`. **Implementation:** -`ChatFileAttachment()` already exists at line 442 of ChatPane.kt. Enhance it: - -1. **Extract cipher params:** Parse `key()`, `nonce()`, `mimeType()`, `url()` from `ChatMessageEncryptedFileHeaderEvent` -2. **Download & decrypt:** Call `EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)` -3. **Display:** Render decrypted bytes as image/video/audio based on MIME type -4. **Lock overlay:** `Box` with `Icon(Icons.Outlined.Lock)` in corner, semi-transparent background -5. **Loading state:** Blurhash placeholder while downloading/decrypting -6. **Error state:** If decryption fails, show "Could not decrypt file" with retry option - ```kotlin @Composable -fun ChatFileAttachment( - event: ChatMessageEncryptedFileHeaderEvent, - account: DesktopIAccount -) { - val decryptedBytes by produceState(null) { - val keyHex = event.key() ?: return@produceState - val nonceHex = event.nonce() ?: return@produceState - val url = event.url() ?: return@produceState - value = try { - EncryptedMediaService.downloadAndDecrypt(url, keyHex, nonceHex) +private fun ChatFileAttachment(event: ChatMessageEncryptedFileHeaderEvent) { + // Parse cipher params from event tags + val url = event.url() + val keyBytes = event.key() // returns ByteArray? (parsed from EncryptionKey tag) + val nonceBytes = event.nonce() // returns ByteArray? (parsed from EncryptionNonce tag) + val mimeType = event.mimeType() + val blurhashStr = event.blurhash() + + if (url.isNullOrEmpty() || keyBytes == null || nonceBytes == null) { + // Missing encryption params — show error + EncryptedFileError("Missing encryption data") + return + } + + // Async download + decrypt with proper state management + var decryptionState by remember(event.id) { + mutableStateOf(DecryptionState.Loading) + } + + LaunchedEffect(event.id) { + decryptionState = try { + val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonceBytes) + DecryptionState.Success(bytes) } catch (e: Exception) { - null // error state + DecryptionState.Error(e.message ?: "Decryption failed") } } - Box { - when { - decryptedBytes != null -> { - // Render based on mimeType - DecryptedMediaContent(decryptedBytes!!, event.mimeType()) + Box( + modifier = Modifier + .widthIn(max = 300.dp) + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)), + ) { + when (val state = decryptionState) { + is DecryptionState.Loading -> { + // Blurhash placeholder or shimmer + if (blurhashStr != null) { + BlurhashPlaceholder( + blurhash = blurhashStr, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(24.dp), + ) + } } - else -> { - // Blurhash placeholder or error - EncryptedFilePlaceholder(event.blurhash()) + + is DecryptionState.Success -> { + DecryptedMediaContent( + bytes = state.bytes, + mimeType = mimeType, + modifier = Modifier.fillMaxWidth(), + ) + } + + is DecryptionState.Error -> { + EncryptedFileError(state.message) } } - // Lock icon overlay + + // Lock icon overlay (always visible) Icon( - Icons.Outlined.Lock, - modifier = Modifier.align(Alignment.BottomEnd).size(20.dp) + Icons.Default.Lock, + contentDescription = "End-to-end encrypted", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .size(20.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + RoundedCornerShape(4.dp), + ) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary, ) } } + +private sealed class DecryptionState { + data object Loading : DecryptionState() + data class Success(val bytes: ByteArray) : DecryptionState() + data class Error(val message: String) : DecryptionState() +} ``` +```kotlin +// DecryptedMediaContent — render based on MIME type +@Composable +private fun DecryptedMediaContent( + bytes: ByteArray, + mimeType: String?, + modifier: Modifier = Modifier, +) { + when { + mimeType?.startsWith("image/") == true -> { + val bitmap = remember(bytes) { + org.jetbrains.skia.Image.makeFromEncoded(bytes) + .toComposeImageBitmap() + } + Image( + bitmap = bitmap, + contentDescription = "Encrypted image", + contentScale = ContentScale.Fit, + modifier = modifier, + ) + } + mimeType?.startsWith("video/") == true -> { + // Show video thumbnail or play button + // Full video playback requires writing decrypted bytes to temp file for VLC + VideoFilePlaceholder(bytes.size, modifier) + } + mimeType?.startsWith("audio/") == true -> { + AudioFilePlaceholder(bytes.size, modifier) + } + else -> { + GenericFilePlaceholder(mimeType, bytes.size, modifier) + } + } +} +``` + +### Research Insights — Phase D + +**Compose state management:** +- Use `LaunchedEffect(event.id)` not `produceState` — better control over loading/error states via sealed class +- `remember(event.id)` keys state to the event, preventing re-download on recomposition +- `remember(bytes)` for Skia bitmap conversion prevents recreating bitmap every recomposition + +**Performance considerations:** + +| Concern | Mitigation | +|---------|------------| +| Re-download on scroll | Add in-memory LRU cache to `EncryptedMediaService` keyed by URL | +| Large decrypted images in memory | Scale down to max display size (300dp) before caching | +| Bitmap creation from bytes | `org.jetbrains.skia.Image.makeFromEncoded()` is efficient for JVM | +| Video/audio playback | Requires writing decrypted bytes to temp file (VLC needs file path). Use `File.createTempFile()` with `.deleteOnExit()` | + +**Add caching to EncryptedMediaService:** + +```kotlin +object EncryptedMediaService { + private val httpClient = OkHttpClient() + private val cache = LruCache(maxSize = 20) // ~20 decrypted files + + suspend fun downloadAndDecrypt( + url: String, + keyBytes: ByteArray, + nonce: ByteArray, + ): ByteArray { + cache.get(url)?.let { return it } + + return withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).build() + val response = httpClient.newCall(request).execute() + val encryptedBytes = response.use { + if (!it.isSuccessful) throw RuntimeException("Download failed: ${it.code}") + it.body.bytes() + } + + val cipher = AESGCM(keyBytes, nonce) + val decrypted = cipher.decrypt(encryptedBytes) + cache.put(url, decrypted) + decrypted + } + } +} +``` + +**Security note:** Cached decrypted bytes are in JVM heap memory. This is acceptable for a desktop app (no shared memory concerns). Consider cache eviction on app minimize if paranoid. + --- ### Phase E: Error Handling & Edge Cases **Files:** Across all modified files above. -| Scenario | Handling | -|----------|----------| -| Upload fails mid-way | Show error toast, keep file in attachment row for retry | -| Network disconnect during upload | Catch IOException, show "Upload failed — check connection" | -| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | -| Blossom server unreachable | Fallback to next server in user's kind 10063 list | -| Large file (>10MB) | Show progress indicator during encrypt + upload | -| Unsupported MIME type | Show generic file icon with filename + size | -| No Blossom servers configured | Disable attach button, show tooltip "Configure media servers in Settings" | -| NIP-04 mode active | Attach button hidden or disabled (encrypted files are NIP-17 only) | +| Scenario | Handling | Implementation | +|----------|----------|----------------| +| Upload fails mid-way | Show error snackbar, keep file in attachment row for retry | Catch in upload loop, skip failed file, continue others | +| Network disconnect during upload | Catch `IOException`, show "Upload failed — check connection" | OkHttp throws on network failure | +| Decryption fails (wrong key) | Show "Could not decrypt" placeholder, no crash | `DecryptionState.Error` sealed class variant | +| Blossom server unreachable | Fallback to next server in user's kind 10063 list | Query `BlossomServersEvent` for alternatives | +| Large file (>10MB) | Show progress indicator during encrypt + upload | Extend `DesktopUploadTracker` for encrypted uploads | +| Unsupported MIME type | Show generic file icon with filename + size | `GenericFilePlaceholder` composable | +| No Blossom servers configured | Disable attach button, show tooltip | Check server list before enabling button | +| NIP-04 mode active | Attach button hidden (encrypted files are NIP-17 only) | `if (isNip17)` guard on paperclip button | +| Corrupt encrypted blob | `AESGCM.decrypt()` throws `AEADBadTagException` | Catch specifically, show "File corrupted or tampered" | +| Duplicate upload (same file) | Each send generates new cipher — different encrypted blob | Intentional: no deduplication for privacy | +| Rapid send taps | Disable send button during upload/send | `isUploading` state flag | + +### Research Insights — Phase E + +**Error hierarchy (from AESGCM source):** +- `AESGCM.decrypt()` uses JCE `Cipher` on JVM — throws `AEADBadTagException` for wrong key (not generic exception) +- `AESGCM.decryptOrNull()` exists — returns null instead of throwing. Prefer this for UI code. + +**Security edge cases:** +- Never log encryption keys, nonces, or decrypted content +- Temp files for video playback must be deleted after use (`deleteOnExit()` + explicit delete on composable disposal) +- Don't show detailed error messages that could leak cipher state ("wrong key" is fine, "key was X but expected Y" is not) + +--- ## Technical Considerations @@ -252,68 +707,94 @@ fun ChatFileAttachment( - Encrypt before hashing — Blossom auth scoped to encrypted blob - Plaintext never leaves device unencrypted - GiftWrap ensures only recipients can see the kind 15 event +- Zero `plaintext` ByteArray after encryption to minimize exposure window +- Upload content type is `application/octet-stream` (doesn't leak file type to server) +- Include `["server", "domain"]` tag in kind 24242 auth to prevent replay attacks ### Performance - Encryption runs on `Dispatchers.IO` (non-blocking) -- Large files: stream encrypt if needed (current AESGCM works on byte arrays — fine for <50MB) -- Decrypted media cached in memory (LRU) to avoid re-downloading +- Sequential file upload (not parallel) to limit memory to 2x single file size +- In-memory LRU cache for decrypted media (20 entries) avoids re-downloading +- `org.jetbrains.skia.Image.makeFromEncoded()` for efficient bitmap creation +- Large files (>50MB): warn user before upload; consider streaming in future ### Architecture - No new modules — extends existing desktop services - Protocol layer (quartz) unchanged — fully reuses existing events/ciphers - Follows Android patterns for consistency across platforms +- `IAccount` interface gains one new method; Android already has implementation (add `override`) + +--- ## Acceptance Criteria - [ ] Paperclip attach button visible in DM chat input (NIP-17 mode only) - [ ] File picker opens, supports image/video/audio selection - [ ] Selected files show as thumbnails above input with X to remove -- [ ] Drag-and-drop files onto chat area adds to attachments +- [ ] Drag-and-drop files onto chat area adds to attachments (visual drop indicator) - [ ] Send encrypts files with AES-GCM before upload to Blossom - [ ] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap -- [ ] Encryption indicator (lock icon) visible on attachments before send +- [ ] Lock icon overlay visible on attachment thumbnails before send - [ ] Received encrypted media downloads, decrypts, displays inline - [ ] Lock icon overlay on received encrypted media in chat bubbles - [ ] Wrong key / failed decryption shows error state, no crash - [ ] Upload progress indicator during encrypt + upload - [ ] No attach button when in NIP-04 mode +- [ ] `canSend` true when files attached (even without text) +- [ ] Send button disabled during active upload ## Test Plan (from Phase 6 testing plan) | # | Test | Steps | Expected | |---|------|-------|----------| | 6.1 | DM file attach | Open DM → click paperclip → select file | File thumbnail appears above input with lock indicator | -| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap | +| 6.2 | Send encrypted | Attach file → send | File uploads encrypted to Blossom, kind 15 event in GiftWrap sent | | 6.3 | Receive encrypted | Receive DM with encrypted file (from Android) | File downloads, decrypts, displays in bubble with lock icon | | 6.4 | Wrong key | View encrypted media where key doesn't match | "Could not decrypt" placeholder, no crash | +| 6.5 | Drag-drop attach | Drag image onto DM chat | File appears in attachment row with lock icon | +| 6.6 | Multiple files | Attach 3 files → send | All 3 upload encrypted, each gets own kind 15 event | +| 6.7 | Large file | Attach 15MB image → send | Progress shown, upload succeeds | +| 6.8 | NIP-04 mode | Toggle to NIP-04 → check attach button | Attach button hidden/disabled | ## Dependencies & Risks | Dependency | Risk | Mitigation | |-----------|------|------------| -| Blossom server availability | Upload fails | Retry + fallback to alternate servers | -| VLC for video playback | Encrypted video won't play without VLC | Graceful fallback for non-VLC systems | +| Blossom server availability | Upload fails | Retry + fallback to alternate servers from kind 10063 | +| VLC for video playback | Encrypted video won't play without VLC | Show "Download" button for video; image display works regardless | | Android client for cross-platform test | Can't verify interop | Use Android emulator or second account | -| `DesktopBlossomClient` raw bytes upload | May only support File, not ByteArray | Add overload if needed | +| `DesktopBlossomClient` ByteArray overload | New method needed | Simple addition — uses OkHttp `ByteArray.toRequestBody()` | +| `IAccount` interface change | Requires Android-side `override` keyword | Android already has the method, just not `override` | ## Sources & References ### Origin - **Brainstorm document:** [docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md](docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md) — Key decisions: inline attach button UX, drag-drop support, lock icon overlay, full send+receive scope -### Internal References -- Android ChatFileUploader: `amethyst/.../upload/ChatFileUploader.kt:39-80` -- Android ChatFileSender: `amethyst/.../upload/ChatFileSender.kt:46-71` -- Desktop ChatPane: `desktopApp/.../ui/chats/ChatPane.kt` -- Desktop UploadOrchestrator: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` -- Desktop EncryptedMediaService: `desktopApp/.../service/media/EncryptedMediaService.kt` -- Desktop ComposeNoteDialog (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt` -- Quartz ChatMessageEncryptedFileHeaderEvent: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt` -- Quartz AESGCM: `quartz/.../utils/ciphers/AESGCM.kt` +### Internal References (verified from source code) +- Android `ChatFileUploader.justUploadNIP17()`: `amethyst/.../upload/ChatFileUploader.kt:39-80` +- Android `ChatFileSender.sendNIP17()`: `amethyst/.../upload/ChatFileSender.kt:46-71` +- Android `Account.sendNip17EncryptedFile()`: `amethyst/.../model/Account.kt:1612-1617` +- Desktop `ChatPane.kt`: `desktopApp/.../ui/chats/ChatPane.kt` (628 lines) +- Desktop `DesktopUploadOrchestrator`: `desktopApp/.../service/upload/DesktopUploadOrchestrator.kt` (78 lines) +- Desktop `DesktopBlossomClient`: `desktopApp/.../service/upload/DesktopBlossomClient.kt` (74 lines) +- Desktop `DesktopBlossomAuth`: `desktopApp/.../service/upload/DesktopBlossomAuth.kt` (43 lines) +- Desktop `DesktopMediaMetadata`: `desktopApp/.../service/upload/DesktopMediaMetadata.kt` (89 lines) +- Desktop `EncryptedMediaService`: `desktopApp/.../service/media/EncryptedMediaService.kt` (57 lines) +- Desktop `DesktopIAccount`: `desktopApp/.../model/DesktopIAccount.kt` +- Desktop `ComposeNoteDialog` (drag-drop pattern): `desktopApp/.../ui/ComposeNoteDialog.kt` +- Commons `IAccount` interface: `commons/.../model/IAccount.kt:106-113` +- Quartz `ChatMessageEncryptedFileHeaderEvent.build()`: `quartz/.../nip17Dm/files/ChatMessageEncryptedFileHeaderEvent.kt:80-110` +- Quartz `NIP17Factory.createEncryptedFileNIP17()`: `quartz/.../nip17Dm/NIP17Factory.kt:86-97` +- Quartz `AESGCM`: `quartz/.../utils/ciphers/AESGCM.kt` - Blossom protocol research: `docs/brainstorms/2026-03-16-blossom-protocol-research.md` -### Gotchas (from learnings research) +### Gotchas (from learnings research + source verification) - Hash encrypted blob, not plaintext, for Blossom auth `x` tag and `X-SHA-256` header -- New AESGCM cipher per file — never reuse key/nonce pair +- New AESGCM cipher per file — never reuse key/nonce pair (nonce reuse breaks AES-GCM completely) - Include `["server", "domain"]` tag in kind 24242 auth to prevent replay - NIP-04 does not support encrypted file headers — hide attach button in NIP-04 mode +- `DesktopBlossomClient.upload()` only accepts `File` — needs `ByteArray` overload for encrypted blobs +- `DesktopBlossomAuth.createUploadAuth()` requires `size: Long` — must be encrypted blob size +- Content-Type for encrypted upload must be `application/octet-stream`, not original MIME +- `AESGCM.decryptOrNull()` exists — prefer over `decrypt()` for UI code (no exception on wrong key)