diff --git a/Cargo.lock b/Cargo.lock index 51239ae2..bed8c985 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -1686,6 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1708,6 +1723,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2542,6 +2566,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -5191,6 +5225,18 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -6631,9 +6677,11 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "state" version = "1.0.1" dependencies = [ + "aes-gcm", "anyhow", "browser-signer-proxy", "common", + "data-encoding", "flume 0.11.1", "futures", "gpui", @@ -6650,6 +6698,7 @@ dependencies = [ "nostr-sdk", "rustls", "serde_json", + "sha2 0.10.9", "smol", "webbrowser", ] diff --git a/Cargo.toml b/Cargo.toml index 0d4f481b..1cee8338 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,11 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] } +# Crypto (NIP-17 encrypted file messages) +aes-gcm = "0.10" +sha2 = "0.10" +data-encoding = "2" + # Others anyhow = "1.0.44" chrono = { version = "0.4.38", features = ["wasmbind"] } diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..53b1cbd2 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,452 @@ +# NIP-17 Encrypted File Messages (kind 15) — Backend Plan + +Implementation plan for sending and receiving NIP-17 **file messages** in coop. + +**Scope: backend only.** This document covers crypto, blob upload/download, rumor +construction, rumor parsing and local caching. No UI work (composer button, file +rendering, image cache, decryption-on-render state) — that is a separate follow-up +once these APIs exist. + +--- + +## 1. What the protocol requires + +NIP-17 file messages are **not** a new transport. They reuse everything coop already +has (NIP-44, NIP-59 seal + gift wrap, kind 10050 inbox relays) and only add: + +1. A new inner **rumor kind: `15`** (`Kind::Custom(15)`), whose `.content` is the URL + of an **encrypted** blob and whose tags carry the MIME type and the decryption + material. +2. **AES-256-GCM** encryption of the file bytes before upload. + +Kind 15 tags (per NIP-17): + +| Tag | Required | Meaning | +|---|---|---| +| `p` | yes | receivers (as for kind 14) | +| `e` | if reply | parent message id | +| `subject` | optional | conversation title | +| `file-type` | yes | MIME type of the **plaintext** file | +| `encryption-algorithm` | yes | `aes-gcm` (only supported value) | +| `decryption-key` | yes | key for the recipient | +| `decryption-nonce` | yes | nonce for the recipient | +| `x` | yes | SHA-256 hex of the **encrypted** file | +| `ox` | expected | SHA-256 hex of the file **before** encryption | +| `size` | optional | size of the **encrypted** file in bytes | +| `dim` | optional | `x` in pixels | +| `thumbhash` / `blurhash` | optional | placeholder previews | +| `thumb` | optional | thumbnail URL (same key/nonce) | +| `fallback` | optional | extra file sources (same key/nonce) | + +Key material travels **inside** the gift wrap, so the public blob URL is useless +without it. That property falls out of the existing seal/gift-wrap code for free. + +`thumbhash`, `blurhash`, `thumb`, `fallback` are out of scope for v1 (all optional). + +--- + +## 2. What already exists in coop (verified against the pinned deps) + +| Requirement | Location | Status | +|---|---|---| +| NIP-59 seal + gift wrap | `crates/chat/src/room.rs::send_gift_wrap` (`nip59::GiftWrapBuilder`) | exists | +| Per-recipient publish + self backup | `crates/chat/src/room.rs::send` | exists | +| Inbox relays (kind 10050) | `crates/chat/src/lib.rs::handle_notifications` | exists | +| Rumor unwrap + local cache | `crates/chat/src/lib.rs::{extract_rumor,try_unwrap_with,set_rumor,get_rumor}` | exists | +| Kind 14 rumor construction | `crates/chat/src/room.rs::rumor` | exists | +| Blossom upload | `crates/state/src/blossom.rs::upload` (plaintext, random signing key) | partial | +| **Kind 15 rumor + parse** | — | **to add** | +| **AES-256-GCM encrypt/decrypt** | — | **to add** | +| **Encrypted blob upload/download** | — | **to add** | + +Verified facts about the pinned SDK (`rust-nostr@b230cec`, `nostr 0.45.4`) and +`gpui@69af529`: + +- rust-nostr has **no** kind-15 helper and **no** AES-GCM/AES-GCM-tag support anywhere + (`FileMetadata` in NIP-94 is kind 1063, unrelated). `nip17.rs` only covers kind 14 + and 10050. +- `nip59::GiftWrapBuilder::new(receiver, rumor: UnsignedEvent)` accepts **any** + `UnsignedEvent`, so kind 15 flows through the existing wrap/send path unchanged. +- `Kind` has no named variant for 15; `Kind::Custom(15)` is required, and + `Kind: Display` prints `as_u16()`, so `rumor.kind.to_string()` yields `"15"`. +- `nostr::nips::nip94::Sha256Hash` is public (`from_byte_array`, `to_hex`, `Display`, + `from_hex`) even though it lives in the nip94 module — usable for `x`/`ox` hex + formatting without a new hashing crate. +- `gpui::App::http_client()` returns `Arc` and + `gpui_web/src/http_client.rs` implements it, so HTTP download is cross-platform. + `AsyncApp` exposes `update(|app| ...)`, which is how backend async code reaches it. +- `nostr-blossom` exposes `upload_blob`, `get_blob`, `has_blob`, `list_blobs`, + `delete_blob`. +- The `k` tag is currently hardcoded to `"14"` in two places + (`room.rs::send_gift_wrap`, `lib.rs::set_rumor`) and used as a room-list filter + (`lib.rs::get_rooms_task`, `custom_tag(LOWERCASE_K, "14")`). + +--- + +## 3. Dependencies + +Add to `[workspace.dependencies]` in `Cargo.toml`, then reference from the crates below. + +```toml +aes-gcm = "0.10" # NEW - RustCrypto: Aes256Gcm, aead::{Aead, KeyInit, OsRng} +sha2 = "0.10" # NEW (already in Cargo.lock, cached) - SHA-256 for x / ox +base64 = "0.22" # NEW as a direct dep (already in the tree transitively) +``` + +- No new RNG dependency: `aes_gcm::aead::OsRng` (the wasm getrandom backends are + already configured in `web/Cargo.toml`). +- No new hashing/hex dependency: `sha2` output → `nostr::nips::nip94::Sha256Hash::from_byte_array(...).to_hex()`. +- `aes-gcm` is the only crate that needs a crates.io fetch (`aes 0.8` / `aead 0.5` are + already in the lock file), so it is a small addition to the build graph. +- Hand-rolling AES-GCM is explicitly **not** an option. + +--- + +## 4. Layering and type ownership + +`chat` depends on `state` (see `crates/chat/Cargo.toml`), never the reverse, so the +shared types and tag names must live in `state`. + +- `crates/state/src/file.rs` (new) owns: + - `EncryptedFile`, `FileAttachment`, the tag-name constants, `ALGORITHM = "aes-gcm"` + - `encrypt` / `decrypt` / `sha256_hex` + - `FileAttachment::from_tags` / `FileAttachment::tags` (single source of truth for + tag names, so build and parse can't drift) + - `upload_encrypted`, `download_and_decrypt` + - re-exported from `crates/state/src/lib.rs`: `mod file; pub use file::*;` +- `crates/chat` consumes it: `message.rs` (parse into `Message`), `room.rs` + (build kind-15 rumor), `lib.rs` (cache tag + room list query). +- `crates/chat` should re-export the type for the future UI layer: + `pub use state::FileAttachment;` in `crates/chat/src/lib.rs`. + +--- + +## 5. Data flow (backend) + +```mermaid +flowchart TD + A[caller: path + blossom server] --> B[read bytes + guess mime] + B --> C[encrypt: random 32B key, 12B nonce, AES-256-GCM] + C --> D[sha256 ciphertext = x, sha256 plaintext = ox] + D --> E[Blossom upload ciphertext] + E --> F[FileAttachment] + F --> G[Room::file_rumor -> kind 15 rumor] + G --> H[Room::send -> existing NIP-59 wrap per member + self backup] + H --> I[existing extract_rumor on receiver] + I --> J[Message.file] + J --> K[download_and_decrypt: GET url, verify x, decrypt] +``` + +--- + +## 6. Implementation steps + +### Step 1 — Dependencies + +Add the three lines from section 3 and wire them into `crates/state/Cargo.toml` +(`aes-gcm`, `sha2`, `base64`). Run `cargo check -p state` to confirm the fetch works. + +### Step 2 — `crates/state/src/file.rs` (new, ~180 LOC) + +```rust +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce}; +use nostr::nips::nip94::Sha256Hash; +use sha2::{Digest, Sha256}; + +pub const ALGORITHM: &str = "aes-gcm"; +/// Ciphertext hash tag (NIP-17). +const TAG_SHA256: &str = "x"; +const TAG_ORIGINAL_SHA256: &str = "ox"; +const TAG_FILE_TYPE: &str = "file-type"; +const TAG_ALGORITHM: &str = "encryption-algorithm"; +const TAG_KEY: &str = "decryption-key"; +const TAG_NONCE: &str = "decryption-nonce"; +const TAG_SIZE: &str = "size"; +const TAG_DIM: &str = "dim"; +const TAG_ALT: &str = "alt"; + +/// Maximum blob size accepted when downloading (bytes). See edge cases. +pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024; + +/// Result of encrypting a file: ciphertext to upload plus NIP-17 key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedFile { + pub data: Vec, + /// base64-encoded 32-byte key + pub key: String, + /// base64-encoded 12-byte nonce + pub nonce: String, +} + +/// NIP-17 kind 15 attachment metadata (tags + `.content` URL). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct FileAttachment { + pub url: Url, + pub mime: String, + pub key: String, + pub nonce: String, + pub sha256: Option, + pub original_sha256: Option, + pub size: Option, + pub dim: Option<(u32, u32)>, + /// Non-standard display name (see "Open decisions" #2). + pub name: Option, +} + +impl FileAttachment { + /// Build the NIP-17 tags for a kind 15 rumor. + pub fn tags(&self) -> Vec; + /// Parse a kind 15 rumor's tags. Returns `None` if key material is missing + /// or `encryption-algorithm` is not `aes-gcm`. + pub fn from_tags(tags: &Tags) -> Option; + pub fn is_image(&self) -> bool; + pub fn display_name(&self) -> SharedString; // falls back to mime/size +} + +/// AES-256-GCM encrypt with a fresh random key and nonce. +pub fn encrypt(data: &[u8]) -> Result; +/// AES-256-GCM decrypt using the values from a kind 15 rumor. +pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result>; + +/// Lowercase hex SHA-256, matching the NIP-94 `x`/`ox` convention. +pub fn sha256_hex(data: &[u8]) -> String; + +/// Read a file, encrypt it, upload the ciphertext to Blossom, return the attachment. +#[cfg(not(target_arch = "wasm32"))] +pub async fn upload_encrypted(server: Url, path: PathBuf, cx: &AsyncApp) -> Result; + +/// Fetch the blob, verify its SHA-256 against `expected_sha256`, then decrypt. +pub async fn download_and_decrypt( + url: &Url, + key: &str, + nonce: &str, + expected_sha256: Option<&str>, + cx: &AsyncApp, +) -> Result>; +``` + +Implementation notes, in priority order: + +1. **Compute `x` and `ox` locally with `sha256_hex`.** Do **not** derive `x` from + `BlobDescriptor::sha256` — that field is a `bitcoin_hashes::sha256::Hash` whose + `Display` byte order could not be confirmed from the vendored sources, and a + reversed digest would silently break integrity checks in other clients. Use the + Blossom response only for `blob.url`. (`sha2` → `Sha256Hash::from_byte_array(...).to_hex()` + gives the conventional digest order and is unambiguous.) +2. **Key/nonce decoding must be tolerant.** NIP-17 does not specify an encoding: + accept base64 standard (padded and unpadded), base64url, and hex on read; emit + base64 standard on write. See "Open decisions" #1. +3. Validate sizes after decoding: key must be exactly 32 bytes, nonce exactly 12. + Return a descriptive error otherwise. +4. `decrypt` must fail closed on a bad tag (GCM authentication failure) — never + return partial plaintext. +5. `upload_encrypted` mirrors the existing `state::blossom::upload` shape: + `smol::fs::read` + `mime_guess::from_path`, `BlossomClient::new(server)`, + `upload_blob(ciphertext, Some("application/octet-stream"), None, Some(&keys))` + wrapped in `Tokio::spawn(cx, ...)`. Note the content type describes the + **ciphertext**; the plaintext MIME goes in the `file-type` tag. + On `wasm32` return `Err(anyhow!("File upload not supported on web"))`, matching + the existing stub in `blossom.rs`. +6. `download_and_decrypt` uses gpui's HTTP client + (`cx.update(|app| app.http_client())`, then + `client.get(url.as_str(), AsyncBody::default(), true)`), reads the body with + `futures::AsyncReadExt`, caps the read at `MAX_FILE_SIZE + 1` and rejects anything + larger, verifies `sha256_hex(&ciphertext)` against `expected_sha256` + (case-insensitive) when present, then decrypts. This path works on desktop and web. +7. `size` in `FileAttachment` is the **ciphertext** size (per the NIP wording), i.e. + `encrypted.data.len()`. `dim` is optional; if wanted, decode dimensions with the + `image` crate (`image::ImageReader`) — mark as a nice-to-have, not a blocker. +8. `encrypt` should take `&[u8]` (not a path) so it stays pure and testable without I/O. + +### Step 3 — `crates/chat/src/message.rs` (~80 LOC) + +- Add the kind constant next to `Message`: + + ```rust + /// NIP-17 file message (rust-nostr has no named `Kind` variant for 15). + pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15); + ``` + +- Add the field: + + ```rust + pub struct Message { + // ... existing fields ... + /// Encrypted file attachment (NIP-17 kind 15) + pub file: Option, + } + ``` + +- In all three `From` impls (`&Event`, `&UnsignedEvent`, `&NewMessage`), parse from + tags when `kind == KIND_FILE_MESSAGE`, and when a file is present **do not** run + `extract_and_remove_media_urls` or `extract_mentions` on `.content` — for kind 15 + `.content` is a ciphertext URL that must never be rendered as text or as media: + + ```rust + let file = (val.kind == KIND_FILE_MESSAGE) + .then(|| FileAttachment::from_tags(&val.tags)) + .flatten(); + + let (media, content) = if file.is_some() { + (vec![], String::new()) + } else { + extract_and_remove_media_urls(&val.content) + }; + ``` + +- Add a helper for non-UI consumers that display a message one-line + (reply previews, notifications, copy): + + ```rust + /// Single-line representation: text for kind 14, `[File] ` for kind 15. + pub fn preview(&self) -> SharedString; + ``` + +- `Ord`/`Eq`/`Hash` are keyed on `id`/`created_at`, so adding the field does not + change `Message` ordering semantics. + +### Step 4 — `crates/chat/src/room.rs` (~70 LOC) + +- Extract the shared tag logic from `rumor()` into a private helper so kind 14 and + kind 15 cannot drift: + + ```rust + /// `subject` + reply `e` tags + receiver `p` tags (excluding `sender`). + fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec; + ``` + + `rumor()` then becomes `EventBuilder::new(kind, content).tags(self.conversation_tags(...))`, + and gains no behavior change. + +- Add: + + ```rust + /// Build a NIP-17 kind 15 (file message) rumor. + /// + /// WARNING: never sign and send this event to relays; it is only ever wrapped. + pub fn file_rumor(&self, file: FileAttachment, replies: I, cx: &App) -> Option + where + I: IntoIterator, + { + // tags = conversation_tags(...) ++ file.tags() + // content = file.url.to_string() + // EventBuilder::new(KIND_FILE_MESSAGE, content).tags(tags).finalize_unsigned(sender) + // then event.ensure_id() + } + ``` + +- Fix the gift wrap's `k` tag so relays see the real inner kind + (`send_gift_wrap`, currently hardcoded): + + ```rust + let k_tag = Tag::custom("k", [rumor.kind.to_string()]); + ``` + +- `Room::send` needs **no change**: it already accepts any `UnsignedEvent`, and the + self-backup branch publishes the same rumor wrapped for the sender, so the sender's + other devices receive the file message with the same key material. + +### Step 5 — `crates/chat/src/lib.rs` (~5 LOC) + +- `set_rumor`: store the real kind in the local cache tag: + + ```rust + Tag::custom("k", [rumor.kind.to_string()]), + ``` + +- `get_rooms_task` currently filters `custom_tag(SingleLetterTag::LOWERCASE_K, "14")`, + which would hide rooms whose only messages are file messages. Simplest fix: drop + the `k` filter and keep `.kind(Kind::ApplicationSpecificData)`; the existing + in-loop JSON parse plus `tags.public_keys().next().is_some()` check already + discards junk, and kind 30078 rows in the local DB exist only for this rumor cache. + Fallback if the extra rows are a concern: run two queries (`k = "14"` and + `k = "15"`) and merge the results. + +- No changes to `handle_notifications`, `extract_rumor`, `try_unwrap_with`, `new_message`, + `Room::get_messages` — all are already kind-agnostic. (`Room::get_messages` filters + only on the `r` room tag, so file messages load with the rest of the history.) + +### Step 6 — (out of scope) UI wiring + +Composer entry point, decryption cache, image/file rendering live in +`crates/chat_ui`. Not part of this plan; the APIs above are what that layer needs: +`state::upload_encrypted`, `FileAttachment::tags/from_tags`, `Room::file_rumor`, +`Room::send`, `state::download_and_decrypt`. + +--- + +## 7. Open decisions + +1. **Key/nonce encoding (interop risk — decide/verify first).** NIP-17 does not specify + how `decryption-key` / `decryption-nonce` are encoded, and no reference + implementation could be reached from this environment to confirm. Plan: emit + **base64 standard**, accept base64 std/unpadded/url-safe/hex on read. Before + release, verify against another NIP-17 client (0xchat, Amethyst) and tighten if + needed. Everything else in the plan is encoding-agnostic. +2. **Display name.** The NIP defines no filename tag and Blossom URLs end in a content + hash, so a name has to come from a non-standard tag. Proposal: emit + `["alt", ]` (NIP-94 tag; NIP-17 allows "rest of tags", and `alt` is a + benign a11y field), tolerate its absence, fall back to mime + size. Alternative: + ship strictly spec-shaped and show mime + size only. +3. **`thumbhash`/`blurhash`/`thumb`/`fallback`/`dim`**: all optional; recommend skipping + in v1 (and letting the UI show a placeholder until the blob is decrypted). +4. **Web (wasm)**: encrypted upload stays unsupported on web, matching today's + `state::blossom::upload` stub. Download/decrypt is cross-platform via gpui's client. +5. **Ciphertext caching**: recommend *not* persisting decrypted plaintext in the local + database in v1; decryption happens on demand from the blob URL. + +--- + +## 8. Edge cases and failure handling + +| Case | Behavior | +|---|---| +| `encryption-algorithm` != `aes-gcm` | do not decrypt; report "unsupported encryption" (forward compatibility) | +| missing `decryption-key` / `decryption-nonce` | `FileAttachment::from_tags` returns `None` → surfaced as a failed message, not a panic | +| key != 32 bytes or nonce != 12 bytes | descriptive error | +| GCM authentication failure | error; never emit partial plaintext | +| `sha256_hex(ciphertext) != x` | error (tampered or corrupted blob), checked **before** decrypting | +| blob larger than `MAX_FILE_SIZE` | rejected while reading the body | +| HTTP error / non-200 | error message including status | +| `content` not a valid URL | error; message still cached so it renders as a failed attachment | +| sender's own copy | works automatically via the existing self-backup gift wrap | +| multiple receivers | one blob, one key, key re-sent inside each recipient's gift wrap | + +--- + +## 9. Verification + +The feature cannot be exercised end-to-end without the UI step (out of scope), so +backend verification is: + +1. `cargo check -p state -p chat` after each step. +2. `cargo check -p coop_web --target wasm32-unknown-unknown` to confirm the + `cfg(target_arch = "wasm32")` stubs and the new module compile for web. +3. Cheap unit checks (no network, ~15 LOC in `state/src/file.rs`), despite "no tests + required" for the feature, because these are pure functions and catch the two + riskiest bugs — encoding and tag ordering: + - `encrypt` → `decrypt` round trip returns the original bytes. + - flipping one ciphertext bit makes `decrypt` fail. + - `sha256_hex(b"abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"` + (confirms the digest order used for `x`/`ox`). + - `FileAttachment` tags → `from_tags` round trip preserves key/nonce/mime/url. +4. End-to-end (requires the UI step or a temporary call site): send to self with + `RoomConfig::backup` enabled and confirm the rumor unwraps, downloads, verifies and + decrypts; then a second account; then a cross-client check for decision #1. + +--- + +## 10. Estimated size + +| File | Change | ~LOC | +|---|---|---| +| `Cargo.toml`, `crates/state/Cargo.toml` | deps | 6 | +| `crates/state/src/file.rs` | new | 180 | +| `crates/state/src/lib.rs` | module + re-export | 2 | +| `crates/chat/src/message.rs` | parse + preview | 80 | +| `crates/chat/src/room.rs` | `file_rumor` + tag refactor + `k` tag | 70 | +| `crates/chat/src/lib.rs` | cache tag + room list query | 5 | + +Order of work: 1 → 2 → 3 → 4 → 5, each step compiling on its own. Steps 2–4 are +independent of any UI change, so the branch stays green without touching `chat_ui`. diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 00000000..ea03c36d --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index ae5134b2..0563c87c 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -21,6 +21,7 @@ mod room; pub use message::*; pub use room::*; +pub use state::FileAttachment; /// A static keypair used only for signing locally-cached rumor events. static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); @@ -629,7 +630,7 @@ impl ChatRegistry { /// Load all rooms from the database. pub fn get_rooms(&mut self, cx: &mut Context) { - let task = self.get_rooms_task(cx); + let task = self.query_chat_rooms(cx); self.tasks.push(cx.spawn(async move |this, cx| { match task.await { @@ -650,8 +651,8 @@ impl ChatRegistry { })); } - /// Create a task to load rooms from the database - fn get_rooms_task(&self, cx: &App) -> Task, Error>> { + /// Query the chat rooms from the database + fn query_chat_rooms(&self, cx: &App) -> Task, Error>> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); @@ -677,7 +678,7 @@ impl ChatRegistry { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) - .custom_tag(SingleLetterTag::LOWERCASE_K, "14"); + .custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]); let events = client.database().query(filter).await?; let mut grouped: HashMap> = HashMap::new(); @@ -719,8 +720,8 @@ impl ChatRegistry { /// Parse a nostr event into a message and push it to the belonging room /// - /// If the room doesn't exist, it will be created. - /// Updates room ordering based on the most recent messages. + /// - If the room doesn't exist, it will be created. + /// - Updates room ordering based on the most recent messages. pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul Tag::identifier(id), Tag::public_key(rumor.pubkey), Tag::custom("r", [room_id]), - Tag::custom("k", ["14"]), + Tag::custom("k", [rumor.kind.to_string()]), ]; let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json()) diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index 1d9217c8..afe0ea22 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -4,6 +4,9 @@ use std::ops::Range; use common::{EventExt, NostrParser, extract_and_remove_media_urls}; use gpui::{SharedString, SharedUri}; use nostr_sdk::prelude::*; +use state::FileAttachment; + +pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15); /// Rendered message. #[derive(Debug, Clone)] @@ -21,61 +24,90 @@ pub struct Message { pub mentions: Vec, /// List of event of the message this message is a reply to pub replies_to: Vec, + /// Encrypted file attachment + pub file: Option, } impl From<&Event> for Message { fn from(val: &Event) -> Self { - let mentions = extract_mentions(&val.content); - let replies_to = extract_reply_ids(&val.tags); - let (media, string) = extract_and_remove_media_urls(&val.content); - - Self { - id: val.id, - author: val.pubkey, - content: string, - media, - created_at: val.created_at, - mentions, - replies_to, - } + from_parts( + val.id, + val.pubkey, + val.created_at, + val.kind, + &val.content, + &val.tags, + ) } } impl From<&UnsignedEvent> for Message { fn from(val: &UnsignedEvent) -> Self { - let mentions = extract_mentions(&val.content); - let replies_to = extract_reply_ids(&val.tags); - let (media, string) = extract_and_remove_media_urls(&val.content); - - Self { + from_parts( // Event ID must be known - id: val.id.unwrap(), - author: val.pubkey, - content: string, - media, - created_at: val.created_at, - mentions, - replies_to, - } + val.id.unwrap(), + val.pubkey, + val.created_at, + val.kind, + &val.content, + &val.tags, + ) } } impl From<&NewMessage> for Message { fn from(val: &NewMessage) -> Self { - let mentions = extract_mentions(&val.rumor.content); - let replies_to = extract_reply_ids(&val.rumor.tags); - let (media, string) = extract_and_remove_media_urls(&val.rumor.content); - - Self { + from_parts( // Event ID must be known - id: val.rumor.id.unwrap(), - author: val.rumor.pubkey, - content: string, - media, - created_at: val.rumor.created_at, - mentions, - replies_to, - } + val.rumor.id.unwrap(), + val.rumor.pubkey, + val.rumor.created_at, + val.rumor.kind, + &val.rumor.content, + &val.rumor.tags, + ) + } +} + +fn from_parts( + id: EventId, + author: PublicKey, + created_at: Timestamp, + kind: Kind, + content: &str, + tags: &Tags, +) -> Message { + let file = if kind == KIND_FILE_MESSAGE { + FileAttachment::from_tags(content, tags) + } else { + None + }; + let has_file = file.is_some(); + + let replies_to = extract_reply_ids(tags); + + // For file messages `.content` is the encrypted blob URL, not text or media + let mentions = if has_file { + Vec::new() + } else { + extract_mentions(content) + }; + + let (media, content) = if has_file { + (Vec::new(), String::new()) + } else { + extract_and_remove_media_urls(content) + }; + + Message { + id, + author, + content, + media, + created_at, + mentions, + replies_to, + file, } } @@ -105,6 +137,17 @@ impl Hash for Message { } } +impl Message { + /// Single-line representation for reply previews, notifications and copy. + pub fn preview(&self) -> SharedString { + if let Some(file) = &self.file { + return format!("[File] {}", file.display_name()).into(); + } + + self.content.clone().into() + } +} + /// New message. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct NewMessage { diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 6edd0cab..094209b0 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -12,7 +12,7 @@ use person::{Person, PersonRegistry}; use settings::{RoomConfig, SignerKind}; use state::{NostrRegistry, TIMEOUT, UniversalSigner}; -use crate::NewMessage; +use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available."; @@ -439,12 +439,52 @@ impl Room { let content: String = content.into(); let replies: Vec = replies.into_iter().collect(); - let persons = PersonRegistry::global(cx); + // Get current user's public key let nostr = NostrRegistry::global(cx); + let sender = nostr.read(cx).current_user()?; + + // Construct a direct message rumor event + // WARNING: never sign and send this event to relays + let mut event = EventBuilder::new(kind, content) + .tags(self.conversation_tags(&replies, sender, cx)) + .finalize_unsigned(sender); + + // Ensure that the ID is set + event.ensure_id(); + + Some(event) + } + + // Construct a rumor event for an encrypted file message (NIP-17 kind 15) + pub fn file_rumor(&self, file: FileAttachment, replies: I, cx: &App) -> Option + where + I: IntoIterator, + { + let replies: Vec = replies.into_iter().collect(); // Get current user's public key + let nostr = NostrRegistry::global(cx); let sender = nostr.read(cx).current_user()?; + let mut tags = self.conversation_tags(&replies, sender, cx); + tags.extend(file.tags()); + + // Construct a file message rumor event + // WARNING: never sign and send this event to relays + let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string()) + .tags(tags) + .finalize_unsigned(sender); + + // Ensure that the ID is set + event.ensure_id(); + + Some(event) + } + + // Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`) + fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec { + let persons = PersonRegistry::global(cx); + // Construct event's tags let mut tags = vec![]; @@ -454,8 +494,8 @@ impl Room { } // Add all reply tags - for id in replies.into_iter() { - tags.push(Tag::event(id)) + for id in replies { + tags.push(Tag::event(*id)) } // Add all receiver tags (no intermediate allocation) @@ -467,16 +507,7 @@ impl Room { })); } - // Construct a direct message rumor event - // WARNING: never sign and send this event to relays - let mut event = EventBuilder::new(kind, content) - .tags(tags) - .finalize_unsigned(sender); - - // Ensure that the ID is set - event.ensure_id(); - - Some(event) + tags } /// Select the appropriate signer based on signer kind and available keys. @@ -609,7 +640,7 @@ async fn send_gift_wrap( rumor: &UnsignedEvent, config: &SignerKind, ) -> Result { - let k_tag = Tag::custom("k", vec!["14"]); + let k_tag = Tag::custom("k", [rumor.kind.to_string()]); let mut extra_tags = vec![k_tag]; // Determine the receiver public key based on the config diff --git a/crates/chat_ui/src/file.rs b/crates/chat_ui/src/file.rs new file mode 100644 index 00000000..1d8cc168 --- /dev/null +++ b/crates/chat_ui/src/file.rs @@ -0,0 +1,32 @@ +use std::path::{Path, PathBuf}; + +use chat::FileAttachment; +use gpui::SharedString; +use nostr_sdk::prelude::*; + +/// A file attachment that has been uploaded, but not sent yet. +/// +/// The local `path` is kept around so the composer can preview +/// the file without downloading and decrypting it again. +pub(crate) struct PendingFile { + pub file: FileAttachment, + pub path: PathBuf, +} + +/// State of the encrypted file attachment of a message +pub(crate) enum DecryptedFile { + Loading, + Ready(PathBuf), + Failed(SharedString), +} + +/// Result of an upload, either plain or encrypted +pub(crate) enum Uploaded { + Url(Url), + File(FileAttachment, PathBuf), +} + +/// A `file://` url for a decrypted file, so it can be opened by the OS +pub(crate) fn file_url(path: &Path) -> String { + format!("file://{}", path.display()) +} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 16c81352..ae37f311 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::PathBuf; use std::sync::{Arc, LazyLock, RwLock}; pub use actions::*; -use anyhow::{Context as AnyhowContext, Error}; +use anyhow::Error; use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus}; use common::{TimestampExt, coop_cache}; use futures::lock::Mutex; @@ -21,7 +22,9 @@ use person::{Person, PersonRegistry}; use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; -use state::{NostrRegistry, upload}; +use state::{ + FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted, +}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -30,11 +33,13 @@ use ui::input::{Input, InputEvent, InputState}; use ui::menu::DropdownMenu; use ui::notification::Notification; use ui::scroll::Scrollbar; +use ui::tooltip::Tooltip; use ui::{ Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension, h_flex, v_flex, }; +use crate::file::*; use crate::text::RenderedText; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; @@ -46,6 +51,7 @@ static EMOJI_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); mod actions; +mod file; mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -96,6 +102,12 @@ pub struct ChatPanel { /// Media Attachment attachments: Entity>, + /// Uploaded, encrypted file attachments which are not sent yet + encrypted_attachments: Entity>, + + /// Decrypted attachments of file messages, by message id + decrypted_files: HashMap, + /// Upload state uploading: bool, @@ -110,6 +122,7 @@ impl ChatPanel { pub fn new(room: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { // Define attachments and replies_to entities let attachments = cx.new(|_| vec![]); + let encrypted_attachments = cx.new(|_| vec![]); let replies_to = cx.new(|_| HashSet::new()); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new())); @@ -185,6 +198,8 @@ impl ChatPanel { subject_bar, replies_to, attachments, + encrypted_attachments, + decrypted_files: HashMap::new(), rendered_texts_by_id: BTreeMap::new(), reports_by_id, sent_ids: Arc::new(Mutex::new(Vec::new())), @@ -370,21 +385,32 @@ impl ChatPanel { } fn send_text_message(&mut self, window: &mut Window, cx: &mut Context) { - // Get the message which includes all attachments + // Get the message which includes all plain attachments let content = self.get_input_value(cx); // Get the replies to this message let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); - // Return if message is empty - if content.trim().is_empty() { + // Uploaded files are sent as encrypted file messages + let files: Vec = self + .encrypted_attachments + .read(cx) + .iter() + .map(|pending| pending.file.clone()) + .collect(); + + // Return if there is nothing to send + if content.trim().is_empty() && files.is_empty() { window.push_notification("Cannot send an empty message", cx); return; } // If replying to exactly one message with only a valid emoji, // send as a reaction instead of a text message - if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty() + if replies.len() == 1 + && EMOJI_RE.is_match(&content) + && self.attachments.read(cx).is_empty() + && files.is_empty() { for reply in &replies { self.send_reaction(&content, reply, window, cx); @@ -393,7 +419,15 @@ impl ChatPanel { return; } - self.send_message(&content, replies, false, window, cx); + // Send the text part, including the plain attachment urls + if !content.trim().is_empty() { + self.send_message(&content, replies.clone(), false, window, cx); + } + + // Send every file as its own encrypted file message + for file in files { + self.send_file(file, replies.clone(), window, cx); + } } fn send_reaction( @@ -426,29 +460,59 @@ impl ChatPanel { return; } - let room = self.room.clone(); - let content = value.to_string(); - let sent_ids = self.sent_ids.clone(); - // Upgrade room and create rumor + send task in a single read lock - let Some(room_entity) = room.upgrade() else { + let Some(room) = self.room.upgrade() else { return; }; - // Create rumor and send task - let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { - let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?; + let outcome = room.read_with(cx, |room, cx| { + let rumor = room.rumor(value, replies, reaction, cx)?; let send_task = room.send(rumor.clone(), cx)?; + Some((rumor, send_task)) - }) { - Some(pair) => pair, - None => { - window.push_notification("Failed to create message", cx); - return; - } + }); + + match outcome { + Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx), + None => window.push_notification("Failed to create message", cx), + } + } + + /// Send an encrypted file message (NIP-17 kind 15) to all members of the chat + fn send_file( + &mut self, + file: FileAttachment, + replies: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let Some(room) = self.room.upgrade() else { + return; }; + let outcome = room.read_with(cx, |room, cx| { + let rumor = room.file_rumor(file, replies, cx)?; + let send_task = room.send(rumor.clone(), cx)?; + + Some((rumor, send_task)) + }); + + match outcome { + Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx), + None => window.push_notification("Failed to create message", cx), + } + } + + /// Insert a rumor optimistically and track the send reports of its gift wraps + fn dispatch( + &mut self, + rumor: UnsignedEvent, + send_task: Task>, + window: &mut Window, + cx: &mut Context, + ) { let id = rumor.id.expect("rumor must have an id"); + let sent_ids = self.sent_ids.clone(); // Insert optimistic message and clear input if rumor.kind != Kind::Reaction { @@ -487,6 +551,10 @@ impl ChatPanel { this.clear(); cx.notify(); }); + self.encrypted_attachments.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); self.replies_to.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -604,7 +672,7 @@ impl ChatPanel { let Some(message) = self.message(id) else { return; }; - let content = message.content.to_string(); + let content = message.preview().to_string(); let item = ClipboardItem::new_string(content); cx.write_to_clipboard(item); @@ -630,6 +698,9 @@ impl ChatPanel { // Get the user's configured blossom server let server = AppSettings::get_file_server(cx); + // Encrypt attachments which are not part of a message being written + let encrypted = self.input.read(cx).value().trim().is_empty(); + // Ask user for file upload let path = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -639,36 +710,95 @@ impl ChatPanel { }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { - this.update(cx, |this, cx| { - this.set_uploading(true, cx); + // Selecting no file means the prompt was cancelled + let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + return Ok(()); + }; + + this.update_in(cx, |this, window, cx| { + this.upload_file(server, path, encrypted, window, cx); })?; - let mut paths = path.await??.context("Not found")?; - let path = paths.pop().context("No path")?; + Ok(()) + })); + } - // Upload via blossom client - match upload(server, path, cx).await { - Ok(url) => { - this.update_in(cx, |this, _window, cx| { - this.add_attachment(url, cx); - this.set_uploading(false, cx); - })?; - } - Err(e) => { - this.update_in(cx, |this, window, cx| { - this.set_uploading(false, cx); + /// Upload a file, encrypted when the attachment is the whole message + fn upload_file( + &mut self, + server: Url, + path: PathBuf, + encrypted: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.set_uploading(true, cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = if encrypted { + upload_encrypted(server.clone(), path.clone(), cx) + .await + .map(|file| Uploaded::File(file, path.clone())) + } else { + upload(server.clone(), path.clone(), cx) + .await + .map(Uploaded::Url) + }; + + this.update_in(cx, |this, window, cx| { + this.set_uploading(false, cx); + + match result { + Ok(Uploaded::Url(url)) => this.add_attachment(url, cx), + Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx), + Err(e) if encrypted => { + this.report_encrypted_upload_error(server, path, e, window, cx) + } + Err(e) => { window.push_notification( Notification::error(e.to_string()).autohide(false), cx, ); - })?; + } } - } + })?; Ok(()) })); } + /// Report a failed encrypted upload, offering to retry it without encryption + fn report_encrypted_upload_error( + &mut self, + server: Url, + path: PathBuf, + error: Error, + window: &mut Window, + cx: &mut Context, + ) { + let view = cx.entity().downgrade(); + + window.push_notification( + Notification::error(error.to_string()) + .title("Encrypted upload failed") + .action(move |_this, _window, _cx| { + let view = view.clone(); + let server = server.clone(); + let path = path.clone(); + + Button::new("retry-without-encryption") + .label("Upload without encryption") + .on_click(move |_ev, window, cx| { + view.update(cx, |this, cx| { + this.upload_file(server.clone(), path.clone(), false, window, cx); + }) + .ok(); + }) + }), + cx, + ); + } + fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { self.uploading = uploading; cx.notify(); @@ -690,6 +820,88 @@ impl ChatPanel { }); } + fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context) { + self.encrypted_attachments.update(cx, |this, cx| { + this.push(PendingFile { file, path }); + cx.notify(); + }); + } + + fn remove_pending_file(&mut self, url: &Url, cx: &mut Context) { + self.encrypted_attachments.update(cx, |this, cx| { + if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) { + this.remove(ix); + cx.notify(); + } + }); + } + + /// Download and decrypt the attachment of a file message for preview + fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context) { + self.decrypted_files.insert(id, DecryptedFile::Loading); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = download_and_decrypt_to_file(&file, cx).await; + + this.update(cx, |this, cx| { + match result { + Ok(path) => { + this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + } + Err(e) => { + this.decrypted_files + .insert(id, DecryptedFile::Failed(e.to_string().into())); + } + } + + cx.notify(); + })?; + + Ok(()) + })); + } + + /// Decrypt the attachment of a file message and open it with the OS + fn open_file( + &mut self, + id: EventId, + file: FileAttachment, + window: &mut Window, + cx: &mut Context, + ) { + match self.decrypted_files.get(&id) { + Some(DecryptedFile::Ready(path)) => { + cx.open_url(&file_url(path)); + return; + } + Some(DecryptedFile::Loading) => return, + _ => {} + }; + + self.decrypted_files.insert(id, DecryptedFile::Loading); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = download_and_decrypt_to_file(&file, cx).await; + + this.update_in(cx, |this, _window, cx| { + match result { + Ok(path) => { + cx.open_url(&file_url(&path)); + this.decrypted_files.insert(id, DecryptedFile::Ready(path)); + } + Err(e) => { + this.decrypted_files + .insert(id, DecryptedFile::Failed(e.to_string().into())); + } + } + + cx.notify(); + })?; + + Ok(()) + })); + } + fn profile(&self, public_key: &PublicKey, cx: &App) -> Person { let persons = PersonRegistry::global(cx); persons.read(cx).get(public_key, cx) @@ -929,6 +1141,16 @@ impl ChatPanel { window: &mut Window, cx: &mut Context, ) -> AnyElement { + let file = self.messages.get(ix).and_then(|message| { + let file = message.file.clone()?; + (!self.decrypted_files.contains_key(&message.id) && file.is_image()) + .then_some((message.id, file)) + }); + + if let Some((id, file)) = file { + self.load_file(id, file, cx); + } + if let Some(message) = self.messages.get(ix) { let persons = PersonRegistry::global(cx); let show_author = self.is_group_start(ix); @@ -1016,8 +1238,11 @@ impl ChatPanel { .when(has_replies, |this| { this.children(self.render_message_replies(replies, cx)) }) - .child(rendered_text) + .when(message.file.is_none(), |this| this.child(rendered_text)) .child(self.render_media(&message.media, cx)) + .when_some(message.file.as_ref(), |this, file| { + this.child(self.render_message_file(&id, file, cx)) + }) .when(has_reactions, |this| { this.child(self.render_reactions(&id, cx)) }), @@ -1123,7 +1348,7 @@ impl ChatPanel { .w_full() .text_ellipsis() .line_clamp(1) - .child(SharedString::from(&message.content)), + .child(message.preview()), ) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .on_click({ @@ -1427,7 +1652,7 @@ impl ChatPanel { .size_16() .when(cx.theme().shadow, |this| this.shadow_lg()) .rounded(cx.theme().radius) - .object_fit(ObjectFit::ScaleDown), + .object_fit(ObjectFit::Cover), ) .child( div() @@ -1464,6 +1689,159 @@ impl ChatPanel { items } + /// Render the encrypted file attachment of a message + fn render_message_file( + &self, + id: &EventId, + file: &FileAttachment, + cx: &Context, + ) -> AnyElement { + let state = self.decrypted_files.get(id); + + if let Some(path) = state + .and_then(|state| match state { + DecryptedFile::Ready(path) => Some(path), + _ => None, + }) + .filter(|_| file.is_image()) + { + return div() + .child( + img(path.clone()) + .border_1() + .border_color(cx.theme().border_variant) + .h(px(250.)) + .object_fit(ObjectFit::Cover) + .rounded(cx.theme().radius), + ) + .into_any_element(); + } + + let label = match state { + Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."), + Some(DecryptedFile::Failed(error)) => error.clone(), + Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"), + None => SharedString::from("Click to decrypt"), + }; + + self.render_file_chip(id, file, label, cx) + } + + /// Render an encrypted file as a chip which decrypts and opens it on click + fn render_file_chip( + &self, + id: &EventId, + file: &FileAttachment, + label: SharedString, + cx: &Context, + ) -> AnyElement { + h_flex() + .id(SharedString::from(format!("file-{id}"))) + .self_start() + .items_start() + .min_w_0() + .gap_2() + .p_2() + .border_1() + .border_color(cx.theme().border_variant) + .rounded(cx.theme().radius) + .child(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent)) + .child( + v_flex() + .min_w_0() + .overflow_hidden() + .text_sm() + .child(div().line_height(relative(1.2)).child(file.display_name())) + .child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(label), + ), + ) + .on_click({ + let file = file.clone(); + let id = *id; + + cx.listener(move |this, _, window, cx| { + this.open_file(id, file.clone(), window, cx); + }) + }) + .into_any_element() + } + + /// Render an uploaded, encrypted file which is not sent yet + fn render_pending_file(&self, pending: &PendingFile, cx: &Context) -> impl IntoElement { + let file = &pending.file; + let label = file.display_name(); + + div() + .id(SharedString::from(file.url.to_string())) + .relative() + .w_16() + .tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into()) + .map(|this| { + if file.is_image() { + this.child( + img(pending.path.clone()) + .size_16() + .when(cx.theme().shadow, |this| this.shadow_sm()) + .rounded(cx.theme().radius) + .object_fit(ObjectFit::Cover), + ) + } else { + this.child( + div() + .size_16() + .flex() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border_variant) + .bg(cx.theme().surface_background) + .text_xs() + .text_center() + .child("Preview not available"), + ) + } + }) + .child( + v_flex() + .absolute() + .top_neg_1() + .right_neg_1() + .size_4() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(cx.theme().border_variant) + .bg(gpui::green()) + .child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())), + ) + .on_click({ + let url = file.url.clone(); + cx.listener(move |this, _, _, cx| { + this.remove_pending_file(&url, cx); + }) + }) + } + + fn render_pending_file_list( + &self, + _window: &Window, + cx: &Context, + ) -> impl IntoIterator { + let mut items = vec![]; + + for pending in self.encrypted_attachments.read(cx).iter() { + items.push(self.render_pending_file(pending, cx)); + } + + items + } + fn render_reply(&self, id: &EventId, cx: &Context) -> impl IntoElement { if let Some(text) = self.message(id) { let persons = PersonRegistry::global(cx); @@ -1512,7 +1890,7 @@ impl ChatPanel { .text_sm() .text_ellipsis() .line_clamp(1) - .child(SharedString::from(&text.content)), + .child(text.preview()), ) } else { div() @@ -1640,6 +2018,10 @@ impl Focusable for ChatPanel { impl Render for ChatPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + const WARNING: &str = "Attachments added while typing are uploaded without encryption"; + + let is_typing = !self.input.read(cx).value().trim().is_empty(); + v_flex() .image_cache(coop_cache(self.id.clone(), 100)) .on_action(cx.listener(Self::on_command)) @@ -1673,10 +2055,8 @@ impl Render for ChatPanel { .map(|this| { if self.messages.is_empty() { this.child( - div() + h_flex() .size_full() - .flex() - .items_center() .justify_end() .child(self.render_announcement(cx)), ) @@ -1701,7 +2081,17 @@ impl Render for ChatPanel { .w_full() .gap_1p5() .children(self.render_attachment_list(window, cx)) + .children(self.render_pending_file_list(window, cx)) .children(self.render_reply_list(window, cx)) + .when(is_typing, |this| { + this.child( + div() + .px_1() + .text_xs() + .text_color(cx.theme().text_warning) + .child(WARNING), + ) + }) .child( h_flex() .items_end() diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 150ae199..df86e28a 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -12,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) { AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx) } +const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; +const LEGACY_FILE_SERVER: &str = "blossom.band"; + macro_rules! setting_accessors { ($(pub $field:ident: $type:ty),* $(,)?) => { impl AppSettings { @@ -138,7 +141,7 @@ impl Default for Settings { screening: true, nip4e: false, trusted_relays: vec![], - file_server: Url::parse("https://blossom.band/").unwrap(), + file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), } } } @@ -217,7 +220,12 @@ impl AppSettings { }); cx.spawn_in(window, async move |this, cx| { - let settings = task.await.unwrap_or(Settings::default()); + let mut settings = task.await.unwrap_or(Settings::default()); + + // Move settings still pointed at the old default file server over to the new one + if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) { + settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap(); + } // Update settings this.update_in(cx, |this, window, cx| { diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index fee518b5..daebc7a9 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -24,6 +24,10 @@ serde_json.workspace = true mime_guess = "2.0.4" +aes-gcm.workspace = true +sha2.workspace = true +data-encoding.workspace = true + [target.'cfg(target_arch = "wasm32")'.dependencies] nostr-memory.workspace = true diff --git a/crates/state/src/blossom.rs b/crates/state/src/blossom.rs index b1628545..eed8c6b6 100644 --- a/crates/state/src/blossom.rs +++ b/crates/state/src/blossom.rs @@ -4,30 +4,52 @@ use anyhow::{Error, anyhow}; use gpui::AsyncApp; #[cfg(not(target_arch = "wasm32"))] use gpui_tokio::Tokio; +#[cfg(not(target_arch = "wasm32"))] use mime_guess::from_path; use nostr_blossom::prelude::*; use nostr_sdk::prelude::*; #[cfg(not(target_arch = "wasm32"))] -pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result { - let content_type = from_path(&path).first_or_octet_stream().to_string(); - let data = smol::fs::read(path).await?; - let keys = Keys::generate(); +use crate::file::sha256_hex; - // Construct the blossom client - let client = BlossomClient::new(server); +/// Upload a blob to a blossom server and return its URL +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn upload_blob( + server: &Url, + data: Vec, + content_type: &str, + sha256: &str, + cx: &AsyncApp, +) -> Result { + let client = BlossomClient::new(server.clone()); + let keys = Keys::generate(); + let content_type = content_type.to_string(); + let base = server.clone(); + let hash = sha256.to_string(); Tokio::spawn(cx, async move { - let blob = client + match client .upload_blob(data, Some(content_type), None, Some(&keys)) - .await?; - - Ok(blob.url) + .await + { + Ok(blob) => Ok(blob.url), + Err(e) if e.to_string().contains("201 Created") => Ok::(base.join(&hash)?), + Err(e) => Err(anyhow!(e.to_string())), + } }) .await .map_err(|e| anyhow!("Upload error: {e}"))? } +#[cfg(not(target_arch = "wasm32"))] +pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result { + let content_type = from_path(&path).first_or_octet_stream().to_string(); + let data = smol::fs::read(&path).await?; + let sha256 = sha256_hex(&data); + + upload_blob(&server, data, &content_type, &sha256, cx).await +} + #[cfg(target_arch = "wasm32")] pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result { Err(anyhow!("File upload not supported on web")) diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs new file mode 100644 index 00000000..924d8621 --- /dev/null +++ b/crates/state/src/file.rs @@ -0,0 +1,337 @@ +use std::path::PathBuf; + +use aes_gcm::aead::consts::{U12, U16, U32}; +use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng}; +use aes_gcm::aes::Aes256; +use aes_gcm::{Aes256Gcm, AesGcm, Nonce}; +use anyhow::{Error, anyhow, bail}; +use data_encoding::HEXLOWER; +use futures::AsyncReadExt; +use gpui::http_client::AsyncBody; +use gpui::{AsyncApp, SharedString}; +#[cfg(not(target_arch = "wasm32"))] +use mime_guess::from_path; +use nostr::nips::nip94::Sha256Hash; +use nostr_sdk::prelude::*; +use sha2::{Digest, Sha256}; + +pub const ALGORITHM: &str = "aes-gcm"; + +pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024; + +const TAG_SHA256: &str = "x"; +const TAG_ORIGINAL_SHA256: &str = "ox"; +const TAG_FILE_TYPE: &str = "file-type"; +const TAG_ALGORITHM: &str = "encryption-algorithm"; +const TAG_KEY: &str = "decryption-key"; +const TAG_NONCE: &str = "decryption-nonce"; +const TAG_SIZE: &str = "size"; +const TAG_DIM: &str = "dim"; +const TAG_ALT: &str = "alt"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncryptedFile { + pub data: Vec, + pub key: String, + pub nonce: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileAttachment { + pub url: Url, + pub mime: String, + pub key: String, + pub nonce: String, + pub sha256: Option, + pub original_sha256: Option, + pub size: Option, + pub dim: Option<(u32, u32)>, + pub name: Option, +} + +impl FileAttachment { + pub fn tags(&self) -> Vec { + let mut tags = vec![ + Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]), + Tag::custom(TAG_ALGORITHM, [ALGORITHM]), + Tag::custom(TAG_KEY, [self.key.clone()]), + Tag::custom(TAG_NONCE, [self.nonce.clone()]), + ]; + + if let Some(sha256) = &self.sha256 { + tags.push(Tag::custom(TAG_SHA256, [sha256.clone()])); + } + + if let Some(original_sha256) = &self.original_sha256 { + tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()])); + } + + if let Some(size) = self.size { + tags.push(Tag::custom(TAG_SIZE, [size.to_string()])); + } + + if let Some((width, height)) = self.dim { + tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")])); + } + + if let Some(name) = &self.name { + tags.push(Tag::custom(TAG_ALT, [name.clone()])); + } + + tags + } + + pub fn from_tags(content: &str, tags: &Tags) -> Option { + if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM { + return None; + } + + Some(Self { + url: Url::parse(content).ok()?, + mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(), + key: tag_value(tags, TAG_KEY)?.to_string(), + nonce: tag_value(tags, TAG_NONCE)?.to_string(), + sha256: tag_value(tags, TAG_SHA256).map(str::to_string), + original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string), + size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()), + dim: tag_value(tags, TAG_DIM).and_then(parse_dim), + name: tag_value(tags, TAG_ALT).map(str::to_string), + }) + } + + pub fn is_image(&self) -> bool { + self.mime.starts_with("image/") + } + + pub fn display_name(&self) -> SharedString { + if let Some(name) = &self.name { + return name.clone().into(); + } + + match self.size { + Some(size) => format!("{} ({size} bytes)", self.mime).into(), + None => self.mime.clone().into(), + } + } +} + +pub fn encrypt(data: &[u8]) -> Result { + let key = Aes256Gcm::generate_key(OsRng); + let nonce = AesGcm::::generate_nonce(OsRng); + let cipher = AesGcm::::new(&key); + + let data = cipher + .encrypt(&nonce, data) + .map_err(|_| anyhow!("Failed to encrypt file"))?; + + Ok(EncryptedFile { + data, + key: HEXLOWER.encode(key.as_slice()), + nonce: HEXLOWER.encode(nonce.as_slice()), + }) +} + +pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result, Error> { + let key = decode(key, "decryption key")?; + let nonce = decode(nonce, "decryption nonce")?; + + if key.len() != 32 { + bail!( + "Invalid decryption key length: expected 32 bytes, got {}", + key.len() + ); + } + + match nonce.len() { + 12 => Aes256Gcm::new_from_slice(&key) + .map_err(|_| anyhow!("Invalid decryption key"))? + .decrypt(Nonce::::from_slice(&nonce), data) + .map_err(|_| anyhow!("Failed to decrypt file")), + 16 => AesGcm::::new_from_slice(&key) + .map_err(|_| anyhow!("Invalid decryption key"))? + .decrypt(Nonce::::from_slice(&nonce), data) + .map_err(|_| anyhow!("Failed to decrypt file")), + 32 => AesGcm::::new_from_slice(&key) + .map_err(|_| anyhow!("Invalid decryption key"))? + .decrypt(Nonce::::from_slice(&nonce), data) + .map_err(|_| anyhow!("Failed to decrypt file")), + len => bail!("Unsupported decryption nonce length: {len} bytes"), + } +} + +pub fn sha256_hex(data: &[u8]) -> String { + let hash: [u8; 32] = Sha256::digest(data).into(); + + Sha256Hash::from_byte_array(hash).to_hex() +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn upload_encrypted( + server: Url, + path: PathBuf, + cx: &AsyncApp, +) -> Result { + let mime = from_path(&path).first_or_octet_stream().to_string(); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()); + let data = smol::fs::read(&path).await?; + + let encrypted = encrypt(&data)?; + let sha256 = sha256_hex(&encrypted.data); + let original_sha256 = sha256_hex(&data); + let size = encrypted.data.len() as u64; + let base_url = server.to_string(); + + let url = crate::blossom::upload_blob( + &server, + encrypted.data, + "application/octet-stream", + &sha256, + cx, + ) + .await + .map_err(|e| { + let message = e.to_string(); + + if !message.contains("415") { + return anyhow!(message); + } + + anyhow!( + "{base_url} rejected the encrypted file. Encrypted attachments are uploaded as + opaque data, which this file server does not accept. Choose a different file + server in the settings." + ) + })?; + + Ok(FileAttachment { + url, + mime, + key: encrypted.key, + nonce: encrypted.nonce, + sha256: Some(sha256), + original_sha256: Some(original_sha256), + size: Some(size), + dim: None, + name, + }) +} + +#[cfg(target_arch = "wasm32")] +pub async fn upload_encrypted( + _server: Url, + _path: PathBuf, + _cx: &AsyncApp, +) -> Result { + Err(anyhow!("File upload not supported on web")) +} + +pub async fn download_and_decrypt( + url: &Url, + key: &str, + nonce: &str, + expected_sha256: Option<&str>, + cx: &AsyncApp, +) -> Result, Error> { + let client = cx.update(|app| app.http_client()); + let response = client.get(url.as_str(), AsyncBody::default(), true).await?; + + if !response.status().is_success() { + bail!("Failed to download file: HTTP {}", response.status()); + } + + let mut data = Vec::new(); + response + .into_body() + .take(MAX_FILE_SIZE as u64 + 1) + .read_to_end(&mut data) + .await?; + + if data.len() > MAX_FILE_SIZE { + bail!("File is too large (max {MAX_FILE_SIZE} bytes)"); + } + + if let Some(expected) = expected_sha256 + && !sha256_hex(&data).eq_ignore_ascii_case(expected) + { + bail!("File hash mismatch"); + } + + decrypt(&data, key, nonce) +} + +/// Download and decrypt a file attachment into a temporary file. +/// +/// The same attachment always maps to the same path, so callers can render the +/// result directly (e.g. with `img`) without downloading it more than once. +#[cfg(not(target_arch = "wasm32"))] +pub async fn download_and_decrypt_to_file( + file: &FileAttachment, + cx: &AsyncApp, +) -> Result { + let name = file + .sha256 + .clone() + .unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes())); + + let extension = mime_guess::get_mime_extensions_str(&file.mime) + .and_then(|extensions| extensions.first()) + .copied() + .unwrap_or("bin"); + + let path = std::env::temp_dir() + .join("coop-files") + .join(format!("{name}.{extension}")); + + if smol::fs::metadata(&path).await.is_ok() { + return Ok(path); + } + + let data = download_and_decrypt( + &file.url, + &file.key, + &file.nonce, + file.sha256.as_deref(), + cx, + ) + .await?; + + let Some(parent) = path.parent() else { + bail!("Invalid file path"); + }; + smol::fs::create_dir_all(parent).await?; + + // Write under a temporary name first, so an interrupted download is never reused + let partial = path.with_extension("download"); + smol::fs::write(&partial, data).await?; + smol::fs::rename(&partial, &path).await?; + + Ok(path) +} + +#[cfg(target_arch = "wasm32")] +pub async fn download_and_decrypt_to_file( + _file: &FileAttachment, + _cx: &AsyncApp, +) -> Result { + Err(anyhow!("File download not supported on web")) +} + +fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { + tags.iter() + .find(|tag| tag.kind() == name) + .and_then(|tag| tag.content()) +} + +fn parse_dim(value: &str) -> Option<(u32, u32)> { + let (width, height) = value.split_once('x')?; + + Some((width.parse().ok()?, height.parse().ok()?)) +} + +fn decode(value: &str, label: &str) -> Result, Error> { + HEXLOWER + .decode(value.to_ascii_lowercase().as_bytes()) + .map_err(|_| anyhow!("Invalid {label} encoding")) +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 23fa04ae..4313d38d 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -17,12 +17,14 @@ use nostr_sdk::prelude::*; mod blossom; mod constants; +mod file; mod nip05; mod nip4e; mod signer; pub use blossom::*; pub use constants::*; +pub use file::*; pub use nip4e::*; pub use nip05::*; pub use signer::{CoopAuthUrlHandler, UniversalSigner}; diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index a59cb32e..3ab51480 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -46,6 +46,7 @@ pub enum IconName { InboxFill, Link, Loader, + Lock, Moon, Plus, PlusCircle, @@ -118,6 +119,7 @@ impl IconNamed for IconName { Self::InboxFill => "icons/inbox-fill.svg", Self::Link => "icons/link.svg", Self::Loader => "icons/loader.svg", + Self::Lock => "icons/lock.svg", Self::Moon => "icons/moon.svg", Self::Plus => "icons/plus.svg", Self::PlusCircle => "icons/plus-circle.svg", diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs index a944c9cf..c9e46d93 100644 --- a/crates/workspace/src/panels/profile.rs +++ b/crates/workspace/src/panels/profile.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use anyhow::{Context as AnyhowContext, Error}; +use anyhow::Error; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, @@ -167,13 +167,15 @@ impl ProfilePanel { }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { + // Selecting no file means the prompt was cancelled + let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + return Ok(()); + }; + this.update(cx, |this, cx| { this.set_uploading(true, cx); })?; - let mut paths = path.await??.context("Not found")?; - let path = paths.pop().context("No path")?; - // Upload via blossom client match upload(server, path, cx).await { Ok(url) => {