24d4073b9b
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>
801 lines
33 KiB
Markdown
801 lines
33 KiB
Markdown
---
|
|
title: "feat: Desktop DM Encrypted Media (NIP-17)"
|
|
type: feat
|
|
status: completed
|
|
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.
|
|
|
|
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:**
|
|
|
|
```kotlin
|
|
// 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(),
|
|
)
|
|
}
|
|
```
|
|
|
|
```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
|
|
)
|
|
```
|
|
|
|
```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<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:
|
|
|
|
```kotlin
|
|
// 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...
|
|
}
|
|
}
|
|
```
|
|
|
|
```kotlin
|
|
// 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):**
|
|
|
|
```kotlin
|
|
// 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):**
|
|
|
|
```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<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:**
|
|
|
|
```kotlin
|
|
// IAccount.kt — add to interface (commons/commonMain)
|
|
/** Send a NIP-17 gift-wrapped encrypted file header */
|
|
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>)
|
|
```
|
|
|
|
```kotlin
|
|
// 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) }
|
|
}
|
|
```
|
|
|
|
```kotlin
|
|
// 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):**
|
|
|
|
```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<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:**
|
|
|
|
```kotlin
|
|
@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()
|
|
}
|
|
```
|
|
|
|
```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<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
|
|
|
|
- [x] Paperclip attach button visible in DM chat input (NIP-17 mode only)
|
|
- [x] File picker opens, supports image/video/audio selection
|
|
- [x] Selected files show as thumbnails above input with X to remove
|
|
- [x] Drag-and-drop files onto chat area adds to attachments (visual drop indicator)
|
|
- [x] Send encrypts files with AES-GCM before upload to Blossom
|
|
- [x] Kind 15 `ChatMessageEncryptedFileHeaderEvent` sent wrapped in GiftWrap
|
|
- [x] Lock icon overlay visible on attachment thumbnails before send
|
|
- [x] Received encrypted media downloads, decrypts, displays inline
|
|
- [x] Lock icon overlay on received encrypted media in chat bubbles
|
|
- [x] Wrong key / failed decryption shows error state, no crash
|
|
- [x] Upload progress indicator during encrypt + upload
|
|
- [x] No attach button when in NIP-04 mode
|
|
- [x] `canSend` true when files attached (even without text)
|
|
- [x] 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](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 `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)
|