Files
amethyst/docs/plans/2026-03-18-feat-desktop-dm-encrypted-media-plan.md
T
nrobi144 24d4073b9b feat(media): encrypted file sharing in desktop DMs (NIP-17 Phase 6)
Add full send + receive encrypted media support in desktop DM chat:
- Paperclip attach button and drag-and-drop in ChatPane (NIP-17 mode only)
- AES-GCM encryption before upload to Blossom server
- ChatMessageEncryptedFileHeaderEvent (kind 15) wrapped in GiftWrap
- sendNip17EncryptedFile() added to IAccount interface and implementations
- DesktopUploadOrchestrator.uploadEncrypted() with proper encrypted hash
- DesktopBlossomClient ByteArray upload overload for encrypted blobs
- LRU cache in EncryptedMediaService to avoid re-downloading
- Error handling: retry on failure, disable send during upload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:53:36 +02:00

33 KiB

title, type, status, date, deepened, origin
title type status date deepened origin
feat: Desktop DM Encrypted Media (NIP-17) feat completed 2026-03-18 2026-03-18 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.

This is Phase 6 of the desktop media parity plan.

Problem Statement / Motivation

Desktop DM chat (ChatPane.kt) supports text messages but has no file attachment capability. Android has a complete implementation (ChatFileUploader + ChatFileSender). Most protocol and service pieces exist on desktop already — this work wires them together with a desktop-native UX.

Proposed Solution

Port Android's NIP-17 encrypted file flow to desktop, reusing existing protocol layer (quartz) and extending desktop services. Three workstreams: upload pipeline, send logic, and receive/display.

(see brainstorm: docs/brainstorms/2026-03-18-desktop-dm-encrypted-media-brainstorm.md)


Implementation Phases

Phase A: Encrypted Upload Pipeline

Goal: DesktopUploadOrchestrator gains uploadEncrypted() method.

Files:

  • desktopApp/.../service/upload/DesktopUploadOrchestrator.kt — add uploadEncrypted()
  • 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:

// DesktopUploadOrchestrator.kt — new method
suspend fun uploadEncrypted(
    file: File,
    cipher: AESGCM,
    serverBaseUrl: String,
    signer: NostrSigner,
): EncryptedUploadResult {
    // 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
    //    NOTE: DesktopMediaMetadata.compute() reads file bytes internally for SHA256
    val metadata = DesktopMediaMetadata.compute(file)

    // 2. Read file bytes and encrypt
    val plaintext = file.readBytes()
    val encrypted = cipher.encrypt(plaintext)

    // 3. Compute SHA256 of ENCRYPTED blob (critical: not plaintext)
    val encryptedHash = sha256(encrypted).toHexKey()
    val encryptedSize = encrypted.size.toLong()

    // 4. Create Blossom auth with ENCRYPTED hash and size
    val authHeader = DesktopBlossomAuth.createUploadAuth(
        hash = encryptedHash,
        size = encryptedSize,
        alt = "Encrypted upload",
        signer = signer,
    )

    // 5. Upload encrypted blob (needs ByteArray overload on client)
    val result = client.upload(
        bytes = encrypted,
        contentType = "application/octet-stream", // encrypted blob, not original mime
        serverBaseUrl = serverBaseUrl,
        authHeader = authHeader,
    )

    return EncryptedUploadResult(
        blossom = result,
        metadata = metadata,           // pre-encryption metadata (dimensions, blurhash, mime)
        encryptedHash = encryptedHash,
        encryptedSize = encryptedSize.toInt(),
    )
}
// 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
)
// 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<BlossomUploadResult>(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

Phase B: DM File Attach UI in ChatPane

Goal: Paperclip button, thumbnail row, drag-and-drop in ChatPane.kt.

Files:

  • desktopApp/.../ui/chats/ChatPane.kt — modify MessageInput() composable and ChatPane() for drag-drop

Implementation:

Modify MessageInput() (currently lines 527-627) — add parameters and UI elements:

// Updated MessageInput signature
@Composable
private fun MessageInput(
    messageText: String,
    isNip17: Boolean,
    requiresNip17: Boolean,
    canSend: Boolean,
    attachedFiles: List<File>,          // NEW
    onMessageChange: (String) -> Unit,
    onToggleNip17: () -> Unit,
    onAttachFiles: (List<File>) -> 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))
        }

        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...
    }
}
// AttachmentRow composable
@Composable
private fun AttachmentRow(
    files: List<File>,
    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):

// File picker helper — runs on AWT thread
private fun openFilePicker(onFilesSelected: (List<File>) -> 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):

// 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<File>() 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

Phase C: Send Encrypted File Event

Goal: Build and dispatch ChatMessageEncryptedFileHeaderEvent (kind 15) from upload results.

Files:

  • desktopApp/.../ui/chats/ChatPane.kt — send logic in ChatPane() scope
  • desktopApp/.../model/DesktopIAccount.kt — add sendNip17EncryptedFile()
  • commons/.../model/IAccount.kt — add interface method

Implementation:

// IAccount.kt — add to interface (commons/commonMain)
/** Send a NIP-17 gift-wrapped encrypted file header */
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
// DesktopIAccount.kt — implement (mirrors sendNip17PrivateMessage exactly)
override suspend fun sendNip17EncryptedFile(
    template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>,
) {
    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) }
}
// ChatPane.kt — send handler for encrypted files
// In ChatPane composable scope, after upload completes:
private suspend fun sendEncryptedFiles(
    uploads: List<Pair<EncryptedUploadResult, AESGCM>>,
    roomKey: ChatroomKey,
    account: IAccount,
    cacheProvider: ICacheProvider,
) {
    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)
    }
}

Full send flow in ChatPane (upload + send):

// 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<Pair<EncryptedUploadResult, AESGCM>>()

    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>
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.

Phase D: Receive & Display Encrypted Media

Goal: Encrypted media in received DMs renders inline with lock icon.

Files:

  • 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:

@Composable
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>(DecryptionState.Loading)
    }

    LaunchedEffect(event.id) {
        decryptionState = try {
            val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonceBytes)
            DecryptionState.Success(bytes)
        } catch (e: Exception) {
            DecryptionState.Error(e.message ?: "Decryption failed")
        }
    }

    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),
                    )
                }
            }

            is DecryptionState.Success -> {
                DecryptedMediaContent(
                    bytes = state.bytes,
                    mimeType = mimeType,
                    modifier = Modifier.fillMaxWidth(),
                )
            }

            is DecryptionState.Error -> {
                EncryptedFileError(state.message)
            }
        }

        // Lock icon overlay (always visible)
        Icon(
            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()
}
// 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:

object EncryptedMediaService {
    private val httpClient = OkHttpClient()
    private val cache = LruCache<String, ByteArray>(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 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

Security

  • New AESGCM() cipher per file (random key + nonce) — never reuse
  • 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)
  • 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 (visual drop indicator)
  • Send encrypts files with AES-GCM before upload to Blossom
  • Kind 15 ChatMessageEncryptedFileHeaderEvent sent wrapped in GiftWrap
  • 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 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 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 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

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 + 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 (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)