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>
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
- Corrected
DesktopBlossomClient— needsByteArrayoverload (currently only acceptsFile) IAccountinterface needssendNip17EncryptedFile()added (not justDesktopIAccount)NIP17Factory.createEncryptedFileNIP17()already exists — plan incorrectly referencedcreateFileNIP17()DesktopMediaMetadata.compute()reads file bytes internally — encrypted upload must avoid double-read- Added streaming encryption consideration for large files and memory pressure mitigation
Critical Corrections from Source Code
DesktopBlossomAuth.createUploadAuth()requiressize: Longparameter — encrypted size, not originalDesktopBlossomClient.upload()only acceptsFile, notByteArray— needs overload or temp fileChatMessageEncryptedFileHeaderEvent.build()takescipher: AESGCMdirectly — no manual key/nonce extraction neededkey()andnonce()return tag values as parsed types (viaEncryptionKey/EncryptionNoncetags)
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— adduploadEncrypted()desktopApp/.../service/upload/DesktopBlossomClient.kt— addByteArrayupload overloaddesktopApp/.../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
plaintextByteArray after encryption (plaintext.fill(0)) to minimize exposure window - Encrypted blob content type should be
application/octet-streamto 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— modifyMessageInput()composable andChatPane()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 inChatPane()scopedesktopApp/.../model/DesktopIAccount.kt— addsendNip17EncryptedFile()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
supervisorScopeif you want one failed upload to not cancel others - Send events can be parallelized (each is independent after upload)
Interface change impact:
- Adding
sendNip17EncryptedFiletoIAccountrequires implementation in Android'sAccount.kttoo — but it already has it as a non-override method. Just addoverridekeyword.
Phase D: Receive & Display Encrypted Media
Goal: Encrypted media in received DMs renders inline with lock icon.
Files:
desktopApp/.../ui/chats/ChatPane.kt— enhanceChatFileAttachment()(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)notproduceState— better control over loading/error states via sealed class remember(event.id)keys state to the event, preventing re-download on recompositionremember(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 JCECipheron JVM — throwsAEADBadTagExceptionfor 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
plaintextByteArray 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
IAccountinterface gains one new method; Android already has implementation (addoverride)
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
ChatMessageEncryptedFileHeaderEventsent 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
canSendtrue 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
- Brainstorm document: 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 (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
IAccountinterface: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
xtag andX-SHA-256header - 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 acceptsFile— needsByteArrayoverload for encrypted blobsDesktopBlossomAuth.createUploadAuth()requiressize: Long— must be encrypted blob size- Content-Type for encrypted upload must be
application/octet-stream, not original MIME AESGCM.decryptOrNull()exists — prefer overdecrypt()for UI code (no exception on wrong key)