diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs new file mode 100644 index 00000000..4649f56c --- /dev/null +++ b/crates/state/src/file.rs @@ -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, + 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 = 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, 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::::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 { + 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::(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 { + 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) +} + +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, 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) +} 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};