Chat attachments: send, receive and preview images and PDFs, interoperable with v1 (Blossom)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 30/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Domain
- mobile-dev, security
Research direction
Start with crypto/file_enc.rs, api/messages.rs, and nostr/blossom.rs to understand the existing attachment path; the issue divides the work into separate Rust, Dart UI, dispute-chat, web, and image-UX phases. For phase 1, add the v1 cross-client fixtures and run cargo test and cargo clippy. Done means v1 and v2 exchange attachments successfully, downloaded content is verified and cached encrypted, and uploads are not signed by the identity key.
Written by the indexing model from the issue text.
Description
Problem
v2 cannot send, receive or preview images or PDFs in the trade chat. In the UI:
- The paperclip is a stub (
chat_room_screen.dart_onAttach: "UI hook deferred"). encrypted_image_message.dartandencrypted_file_message.dartare placeholders.- An attachment sent from v1 shows up as JSON text (see below).
In a P2P trade the chat is where the payment proof travels (a transfer screenshot, a bank PDF), so today a v2 user cannot send the evidence the counterpart — or a solver — asks for.
v1 (MostroP2P/mobile v1.4.2) supports this with client-side encryption and Blossom storage, and the idea is sound. This issue describes how v1 does it, what v2 already has, why that code does not work with v1 yet, and the step-by-step plan, before implementing.
Related: #140 (UI wiring), #130 (image UX), #150 (web Blossom), #143 (dispute chat). #140's premise — "the Rust side is in place" — does not hold: see What v2 already has. This issue is meant as the umbrella for all four.
How v1 does it
Checked in MostroP2P/mobile @ v1.4.2 (637fd43):
- Pick.
ChatFileUploadHelper(lib/shared/utils/chat_file_upload_helper.dart) opensFilePickerlimited to JPG/PNG, PDF/DOC/DOCX and MP4/MOV/AVI, checks the 25 MB limit before reading the file, and asks for confirmation. - Sanitize images.
MediaValidationService.validateAndSanitizeImageLightdecodes and re-encodes JPEG/PNG in an isolate, which drops EXIF/GPS metadata, and returns width/height. Documents get structural checks (the%PDF-header; DOC/DOCX without macros). - Encrypt. ChaCha20-Poly1305, output
[nonce 12][ciphertext][tag 16]. Key: the raw ECDH secret of the two trade keys (Nip44.computeSharedSecret→session.sharedKey→sharedKeyToBytes), the same secret the chat envelope derivesK_conv/K_signfrom. It is notK_conv, and not SHA-256 of the secret. In the dispute chat the key is the raw ECDH secret between the trade key and the solver (adminSharedKey). - Upload to Blossom.
PUT {server}/upload(BUD-02),Content-Type: application/octet-stream.- Auth is a kind 24242 event signed by a fresh random key per upload: not the identity key, not the trade key.
- The URL is built as
{server}/{sha256}. - Servers are tried in order (
BlossomConfig.defaultServers):cdn.hzrd149.com,nostr.download,blossom-01.uid.ovh,files.sovbit.host,blssm.us. The config's own comment explains the choice: they accept opaque blobs (media-only servers reject encrypted data by sniffing it), and they keep files indefinitely, because dispute evidence must not expire.
- Send. The chat message is a normal chat-envelope message (kind 14 / inner kind 1) whose text is a JSON string:
{"type":"image_encrypted","blossom_url":"https://…/<sha256>","nonce":"<hex>","mime_type":"image/jpeg",
"original_size":524288,"width":1920,"height":1080,"filename":"…","encrypted_size":524320}
{"type":"file_encrypted","file_type":"document","blossom_url":"https://…/<sha256>","nonce":"<hex>",
"mime_type":"application/pdf","original_size":1048576,"filename":"…","encrypted_size":1048608}
The nonce field repeats the one at the head of the blob. v1's parser casts every field with as int / as String, so an image_encrypted without width/height breaks on the v1 side.
6. Receive. A message whose text parses as JSON with one of those types is rendered as an attachment.
- Images are downloaded and decrypted automatically for preview.
- Other files show a card and download only on demand, then "Open" writes a temp file and calls
OpenFile. - The cache is memory-only; v1's own doc (
.specify/v1-reference/ENCRYPTED_IMAGE_MESSAGING_IMPLEMENTATION.md, Future Improvements) says a disk cache must keep the encrypted blob and decrypt only at display time.
What v2 already has, and why it does not work with v1
Rust has nostr/blossom.rs, crypto/file_enc.rs (same blob layout as v1), api/messages.rs::send_file / download_attachment / parse_chat_payload, and the on_attachment_progress stream. None of it is reachable from the UI, and as written it would not work with v1:
| v1 | v2 today | Effect | |
|---|---|---|---|
| File key | raw ECDH x-coordinate | derive_nip04_shared_key = SHA-256 of it (orders.rs peer-reveal, send_file) |
Neither side can decrypt the other's files |
| Message JSON | image_encrypted / file_encrypted, fields above |
{"type":"file","url","name","mime_type","size"} |
v1 files arrive in v2 as raw JSON text; v2 files never render in v1 |
| Upload endpoint | PUT /upload |
PUT /{sha256} |
Works only on servers that still take the old form |
| Upload auth | fresh key per upload | the identity key (get_active_keys) |
Privacy: every upload is signed by the user's long-lived identity, linking their trades on every Blossom server |
| Servers | the 5 above (opaque blobs, long retention) | older list incl. blossom.primal.net, nostr.media… |
v1 dropped those: media-only (they reject encrypted blobs) or ephemeral (evidence expires) |
| Downloaded file | memory | decrypted file written to a temp path | Plaintext payment proof left on disk |
| Integrity | — | — | Neither checks that the downloaded blob's SHA-256 matches the URL |
| Web | — | NotImplemented (#150) |
No attachments on the web build |
(Checked by reading both codebases; the ECDH form is also stated in crypto/chat_keys.rs. Step 1 below pins it with a cross-client test vector before anything ships.)
Open decision — can a solver open P2P chat files?
v1 discloses K_conv to a solver during a dispute, as the chat spec says (read-only access), and deliberately never the raw ECDH secret (it also derives K_sign). But v1 encrypts P2P chat files with the raw secret. So a solver can read the whole conversation, see that a receipt was sent, and cannot open it. The payment proof, the main reason for attachments, is invisible to whoever decides the dispute. Files sent in the dispute chat itself are fine: the solver holds its side of that ECDH.
Options:
- A — v1-compatible (raw ECDH). Works with v1 today; keeps the gap.
- B — a file key derived from
K_conv(e.g.HKDF-SHA256(K_conv, info = "mostro:chat:file:v1")). The solver can open the files; needs a protocol addition and the same change in v1, or v1 and v2 stop reading each other's files.
Recommendation: ship A for interoperability, and open a protocol proposal for B, to be adopted by both clients together (with a version bump of the type field so old and new files coexist).
Step-by-step plan
Each step is its own PR, feat(chat-attachments): phase N.
Phase 1 — Rust core, wire-compatible with v1 (no UI)
- Key: the file key is the raw ECDH secret for the peer chat and the raw ECDH with the solver for the dispute chat. Add a cross-client test vector: a blob encrypted by v1's
EncryptionService, decrypted byfile_encwith keys derived from fixed trade keys, and the reverse direction. - Wire format: a
chat_attachmentmodule with serde types forimage_encryptedandfile_encrypted, byte-for-byte with v1 (field names, requiredwidth/height, the redundantnonce,file_type ∈ {image, video, document}).parse_chat_payloadrecognizes both;send_fileemits them. The v2type:"file"shape was never sent (the UI was never wired), so it is dropped, not migrated. Tests use v1 JSON fixtures. - Blossom client:
PUT /upload(BUD-02) withapplication/octet-stream, auth signed by a fresh key per upload.- v1's server list.
- Take the URL from the server's blob descriptor, falling back to
{server}/{sha256}. - On download: verify SHA-256 against the URL before decrypting, reject over 25 MB by
Content-Lengthand by bytes, accepthttps://(andblossom://if seen).
- Validation and sanitization in Rust (the core decides what leaves the device):
- Detect the type by magic bytes, never by extension or peer-supplied MIME.
- Allowed to send: JPEG, PNG, PDF.
- Images are decoded and re-encoded (drops EXIF/GPS) with a pixel cap against decompression bombs, returning width/height.
- PDFs must start with
%PDF-. - Received files of every v1 type are accepted, including DOC/DOCX and video.
- Encrypted at rest: the cache stores the encrypted blob keyed by SHA-256.
download_attachmentreturns the decrypted bytes (FRBVec<u8>) instead of a plaintext temp path. The cache is identity-scoped: it is added toclear_identity_data/resetIdentityScopedState(CLAUDE.md, A new identity starts from zero). - Real progress: feed
on_attachment_progressfrom bytes sent and received, and report an indeterminate state withoutContent-Length. - Spec: FR-036 in detail (types, limits, auto-download rules, encrypted cache),
data-model.mdandcontracts/messages.mdwith the two JSON shapes. The design doc links to v1's.
Phase 2 — Dart UI (the #140 scope)
- Paperclip. A sheet with Photo / Camera / File (PDF), the size check before reading, a confirmation dialog, and an upload bubble with progress plus cancel/retry.
- Image bubble. Auto-download and decrypt on arrival (as v1 does), a thumbnail sized by
width/height, loading and error states with retry. - Full-screen viewer. Pinch-zoom, save to gallery, share.
- PDF bubble. Name, size and icon; tap to download and decrypt, then an in-app preview rendered from memory (first page as a thumbnail, full viewer), plus "open with…" and save.
- Other v1 types (DOC/DOCX/video). Received ones show a file card with open/save; v2 does not send them in this issue.
- Untrusted names. The peer-supplied filename is sanitized (no path separators, no traversal) before any save or temp file. Temp files exist only for "open with…" and are deleted afterwards.
- Rest of the UI. l10n in all 6 languages; semantics labels; golden images in the PR (light and dark, image and PDF bubbles, the upload state).
- Tests. Widget tests for each state.
Phase 3 — Dispute chat (with #143)
The same attach path in the dispute chat, keyed to the solver. Blocked on #143's dispute wiring.
Phase 4 — Web (#150)
A fetch-based Blossom client for wasm (the auth construction does not change). Check the servers' CORS, cache the encrypted blob in IndexedDB, and extend the web smoke test with an encrypted upload and read-back.
Phase 5 — Image UX (#130)
Compression and max-edge resize before upload, and a thumbnail before full decrypt.
Acceptance
- An image and a PDF sent from v2 open in v1 v1.4.2, and v1's open in v2, in both directions (manual cross-client test; notes in the PR).
- Nothing on disk holds a decrypted attachment, except a temp file created by an explicit "open with…" and deleted afterwards.
- Blossom uploads are not signed by the identity key.
cargo test,cargo clippy,flutter analyzeandflutter testpass; goldens are in the PR.
Out of scope
Video playback in-app, sending DOC/DOCX/video, and the protocol change of option B (tracked separately once decided).
- Dominant language
- Dart
- Stars
- 11
- Forks
- 9
- Avg merge
- 13h 4m
- Merged PRs (30d)
- 259
Getting set up
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from MostroP2P/app
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
Add-invoice screen stays on "Sent, waiting for the node" after a late acceptance on a sell orderOpenbug priority: medium
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
area: ui
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
MostroP2P/app#341 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
intel/rohd-wave-viewer#11 ·
-
DOCS UPDATE: README.md and BACKEND.md reference a search-meetings edge function that does not existOpendocumentation
Difficulty 1/5 1-3 hours Newbie friendliness 93/100
AOSSIE-Org/Ell-ena#337 ·
-
Difficulty 1/5 Under an hour Newbie friendliness 66/100
Maintainers usually reply within 1 day
-
cat: puzzle
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
lichess-org/mobile#3826 ·
Maintainers usually reply within 2 days
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
saber-notes/saber#1844 ·