5 Commits
Author SHA1 Message Date
reya 2d7881e574 update api 2026-09-15 19:33:30 +07:00
reya 37d3380c55 update room 2026-09-15 19:21:01 +07:00
reya 9fcfb9f0bf update message 2026-09-15 19:16:10 +07:00
reya 1877e0082c add encrypted file construction 2026-09-15 19:11:54 +07:00
reya 68d3d77ae3 prepare 2026-09-15 19:03:20 +07:00
9 changed files with 922 additions and 61 deletions
Generated
+49
View File
@@ -139,6 +139,20 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -1686,6 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -1708,6 +1723,15 @@ dependencies = [
"linktime-proc-macro", "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]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@@ -2542,6 +2566,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "gif" name = "gif"
version = "0.14.2" version = "0.14.2"
@@ -5191,6 +5225,18 @@ dependencies = [
"arrayvec", "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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.15.0"
@@ -6631,7 +6677,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "state" name = "state"
version = "1.0.1" version = "1.0.1"
dependencies = [ dependencies = [
"aes-gcm",
"anyhow", "anyhow",
"base64 0.22.1",
"browser-signer-proxy", "browser-signer-proxy",
"common", "common",
"flume 0.11.1", "flume 0.11.1",
@@ -6650,6 +6698,7 @@ dependencies = [
"nostr-sdk", "nostr-sdk",
"rustls", "rustls",
"serde_json", "serde_json",
"sha2 0.10.9",
"smol", "smol",
"webbrowser", "webbrowser",
] ]
+5
View File
@@ -27,6 +27,11 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { 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" ] } 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"
base64 = "0.22"
# Others # Others
anyhow = "1.0.44" anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] } chrono = { version = "0.4.38", features = ["wasmbind"] }
+452
View File
@@ -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 | `<width>x<height>` 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<dyn HttpClient>` 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<u8>,
/// 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<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
/// Non-standard display name (see "Open decisions" #2).
pub name: Option<String>,
}
impl FileAttachment {
/// Build the NIP-17 tags for a kind 15 rumor.
pub fn tags(&self) -> Vec<Tag>;
/// 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<Self>;
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<EncryptedFile>;
/// AES-256-GCM decrypt using the values from a kind 15 rumor.
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>>;
/// 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<FileAttachment>;
/// 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<Vec<u8>>;
```
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<FileAttachment>,
}
```
- 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] <name>` 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<Tag>;
```
`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<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
where
I: IntoIterator<Item = EventId>,
{
// 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", <file name>]` (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`.
+8 -7
View File
@@ -21,6 +21,7 @@ mod room;
pub use message::*; pub use message::*;
pub use room::*; pub use room::*;
pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events. /// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -629,7 +630,7 @@ impl ChatRegistry {
/// Load all rooms from the database. /// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) { pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
let task = self.get_rooms_task(cx); let task = self.query_chat_rooms(cx);
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match task.await {
@@ -650,8 +651,8 @@ impl ChatRegistry {
})); }));
} }
/// Create a task to load rooms from the database /// Query the chat rooms from the database
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> { fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
@@ -677,7 +678,7 @@ impl ChatRegistry {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .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 events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new(); let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
@@ -719,8 +720,8 @@ impl ChatRegistry {
/// Parse a nostr event into a message and push it to the belonging room /// Parse a nostr event into a message and push it to the belonging room
/// ///
/// If the room doesn't exist, it will be created. /// - If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages. /// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) { pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
Tag::identifier(id), Tag::identifier(id),
Tag::public_key(rumor.pubkey), Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]), 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()) let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
+79 -36
View File
@@ -4,6 +4,9 @@ use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls}; use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri}; use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::FileAttachment;
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
/// Rendered message. /// Rendered message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -21,61 +24,90 @@ pub struct Message {
pub mentions: Vec<Mention>, pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to /// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>, pub replies_to: Vec<EventId>,
/// Encrypted file attachment
pub file: Option<FileAttachment>,
} }
impl From<&Event> for Message { impl From<&Event> for Message {
fn from(val: &Event) -> Self { fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content); from_parts(
let replies_to = extract_reply_ids(&val.tags); val.id,
let (media, string) = extract_and_remove_media_urls(&val.content); val.pubkey,
val.created_at,
Self { val.kind,
id: val.id, &val.content,
author: val.pubkey, &val.tags,
content: string, )
media,
created_at: val.created_at,
mentions,
replies_to,
}
} }
} }
impl From<&UnsignedEvent> for Message { impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self { fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content); from_parts(
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known // Event ID must be known
id: val.id.unwrap(), val.id.unwrap(),
author: val.pubkey, val.pubkey,
content: string, val.created_at,
media, val.kind,
created_at: val.created_at, &val.content,
mentions, &val.tags,
replies_to, )
}
} }
} }
impl From<&NewMessage> for Message { impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self { fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content); from_parts(
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known // Event ID must be known
id: val.rumor.id.unwrap(), val.rumor.id.unwrap(),
author: val.rumor.pubkey, val.rumor.pubkey,
content: string, 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, media,
created_at: val.rumor.created_at, created_at,
mentions, mentions,
replies_to, 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. /// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage { pub struct NewMessage {
+46 -15
View File
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind}; use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner}; 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 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."; 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 content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect(); let replies: Vec<EventId> = replies.into_iter().collect();
let persons = PersonRegistry::global(cx); // Get current user's public key
let nostr = NostrRegistry::global(cx); 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<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
where
I: IntoIterator<Item = EventId>,
{
let replies: Vec<EventId> = replies.into_iter().collect();
// Get current user's public key // Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?; 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<Tag> {
let persons = PersonRegistry::global(cx);
// Construct event's tags // Construct event's tags
let mut tags = vec![]; let mut tags = vec![];
@@ -454,8 +494,8 @@ impl Room {
} }
// Add all reply tags // Add all reply tags
for id in replies.into_iter() { for id in replies {
tags.push(Tag::event(id)) tags.push(Tag::event(*id))
} }
// Add all receiver tags (no intermediate allocation) // Add all receiver tags (no intermediate allocation)
@@ -467,16 +507,7 @@ impl Room {
})); }));
} }
// Construct a direct message rumor event tags
// 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)
} }
/// Select the appropriate signer based on signer kind and available keys. /// Select the appropriate signer based on signer kind and available keys.
@@ -609,7 +640,7 @@ async fn send_gift_wrap(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
config: &SignerKind, config: &SignerKind,
) -> Result<SendReport, Error> { ) -> Result<SendReport, Error> {
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]; let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config // Determine the receiver public key based on the config
+4
View File
@@ -24,6 +24,10 @@ serde_json.workspace = true
mime_guess = "2.0.4" mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
base64.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true nostr-memory.workspace = true
+274
View File
@@ -0,0 +1,274 @@
use std::path::PathBuf;
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use anyhow::{Error, anyhow, bail};
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use futures::AsyncReadExt;
use gpui::http_client::AsyncBody;
use gpui::{AsyncApp, SharedString};
use nostr::nips::nip94::Sha256Hash;
use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
#[cfg(not(target_arch = "wasm32"))]
use nostr_blossom::prelude::*;
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<u8>,
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<String>,
pub original_sha256: Option<String>,
pub size: Option<u64>,
pub dim: Option<(u32, u32)>,
pub name: Option<String>,
}
impl FileAttachment {
pub fn tags(&self) -> Vec<Tag> {
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<Self> {
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<EncryptedFile, Error> {
let key = Aes256Gcm::generate_key(OsRng);
let nonce = Aes256Gcm::generate_nonce(OsRng);
let cipher = Aes256Gcm::new(&key);
let data = cipher
.encrypt(&nonce, data)
.map_err(|_| anyhow!("Failed to encrypt file"))?;
Ok(EncryptedFile {
data,
key: STANDARD.encode(key.as_slice()),
nonce: STANDARD.encode(nonce.as_slice()),
})
}
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
let key = decode(key, 32, "decryption key")?;
let nonce = decode(nonce, 12, "decryption nonce")?;
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| anyhow!("Invalid decryption key"))?;
cipher
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file"))
}
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<FileAttachment, Error> {
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 = Some(sha256_hex(&encrypted.data));
let original_sha256 = Some(sha256_hex(&data));
let size = Some(encrypted.data.len() as u64);
let keys = Keys::generate();
let client = BlossomClient::new(server);
let url = Tokio::spawn(cx, async move {
let blob = client
.upload_blob(
encrypted.data,
Some("application/octet-stream".to_string()),
None,
Some(&keys),
)
.await?;
Ok::<Url, Error>(blob.url)
})
.await
.map_err(|e| anyhow!("Upload error: {e}"))??;
Ok(FileAttachment {
url,
mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256,
original_sha256,
size,
dim: None,
name,
})
}
#[cfg(target_arch = "wasm32")]
pub async fn upload_encrypted(
_server: Url,
_path: PathBuf,
_cx: &AsyncApp,
) -> Result<FileAttachment, Error> {
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<Vec<u8>, 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)
}
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, expected_len: usize, label: &str) -> Result<Vec<u8>, Error> {
let decoded = STANDARD
.decode(value)
.or_else(|_| STANDARD_NO_PAD.decode(value))
.or_else(|_| URL_SAFE.decode(value))
.or_else(|_| URL_SAFE_NO_PAD.decode(value))
.map_err(|_| anyhow!("Invalid {label} encoding"))?;
if decoded.len() != expected_len {
bail!(
"Invalid {label} length: expected {expected_len} bytes, got {}",
decoded.len()
);
}
Ok(decoded)
}
+2
View File
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
mod blossom; mod blossom;
mod constants; mod constants;
mod file;
mod nip05; mod nip05;
mod nip4e; mod nip4e;
mod signer; mod signer;
pub use blossom::*; pub use blossom::*;
pub use constants::*; pub use constants::*;
pub use file::*;
pub use nip4e::*; pub use nip4e::*;
pub use nip05::*; pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner}; pub use signer::{CoopAuthUrlHandler, UniversalSigner};