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