diff --git a/src/models.rs b/src/models.rs index ec23c12b..48431297 100644 --- a/src/models.rs +++ b/src/models.rs @@ -223,7 +223,10 @@ pub struct Order { pub premium: i64, pub trade_keys: Option, pub counterparty_pubkey: Option, - /// ECDH shared secret for P2P order chat (hex), derived once when both trade pubkeys are known. + /// ECDH shared secret (IKM) for P2P order chat (hex), derived once when both + /// trade pubkeys are known. Runtime chat wraps derive `K_conv` / `K_sign` from + /// this IKM; attachment ChaCha uses `K_conv` (see + /// [`crate::util::chat_utils::order_chat_decryption_key_bytes`]). pub order_chat_shared_key_hex: Option, /// Dispute UUID assigned by Mostro for this order. pub dispute_id: Option, diff --git a/src/ui/chat.rs b/src/ui/chat.rs index df43f35c..3c25f92c 100644 --- a/src/ui/chat.rs +++ b/src/ui/chat.rs @@ -65,16 +65,23 @@ pub enum ChatAttachmentType { File, } -/// Attachment metadata for a dispute chat message (Blossom URL + decryption key). -/// File bytes are fetched from Blossom when the admin saves (Ctrl+S). +/// Attachment metadata for chat (Blossom URL + optional ChaCha keys). +/// +/// File bytes are fetched from Blossom on Ctrl+S (My Trades, dispute chat, or +/// Observer). Wire JSON may embed a `key`; otherwise save handlers fill +/// [`Self::decryption_key`] with v2 `K_conv` (and optional ECDH fallbacks). #[derive(Clone, Debug)] pub struct ChatAttachment { pub blossom_url: String, pub filename: String, pub mime_type: Option, pub file_type: ChatAttachmentType, - /// When provided by the sender, used to decrypt the blob when saving. + /// Primary 32-byte ChaCha key: wire-embedded `key`, or derived at save time + /// (`K_conv` / disclosed Shared key). pub decryption_key: Option>, + /// Extra ChaCha keys tried after [`Self::decryption_key`] (e.g. legacy ECDH + /// IKM after v2 `K_conv`). Never present on the wire. + pub decryption_key_fallbacks: Vec>, } /// A chat message in the dispute resolution interface diff --git a/src/ui/helpers/attachments.rs b/src/ui/helpers/attachments.rs index 7c9b5ec4..d6aa26d9 100644 --- a/src/ui/helpers/attachments.rs +++ b/src/ui/helpers/attachments.rs @@ -139,6 +139,7 @@ pub(crate) fn try_parse_attachment_message(content: &str) -> Option<(ChatAttachm mime_type, file_type, decryption_key, + decryption_key_fallbacks: Vec::new(), }; let display = match file_type { ChatAttachmentType::Image => format!("{} Image: {}{}", icon, filename, key_hint), @@ -265,6 +266,7 @@ mod tests { mime_type: Some("image/png".to_string()), file_type: ChatAttachmentType::Image, decryption_key: None, + decryption_key_fallbacks: Vec::new(), } } diff --git a/src/ui/helpers/chat_storage.rs b/src/ui/helpers/chat_storage.rs index 1ca96bf8..77b8e6b6 100644 --- a/src/ui/helpers/chat_storage.rs +++ b/src/ui/helpers/chat_storage.rs @@ -863,6 +863,7 @@ mod tests { mime_type: None, file_type: ChatAttachmentType::File, decryption_key: None, + decryption_key_fallbacks: Vec::new(), }; let json = serialize_attachment_for_transcript(&att); let (content, restored) = message_fields_from_transcript_content(&json); diff --git a/src/ui/key_handler/mod.rs b/src/ui/key_handler/mod.rs index 5a7e2ab7..9daa103e 100644 --- a/src/ui/key_handler/mod.rs +++ b/src/ui/key_handler/mod.rs @@ -860,10 +860,24 @@ pub fn handle_key_event( }, ) { if let Ok(sender_pk) = PublicKey::parse(pk_str) { - if let Ok(shared) = crate::util::blossom::derive_shared_key( + if let Ok(ecdh) = crate::util::blossom::derive_shared_key( admin_keys, &sender_pk, ) { - attachment.decryption_key = Some(shared.to_vec()); + if let Ok(sk) = + nostr_sdk::prelude::SecretKey::from_slice(&ecdh) + { + let ecdh_keys = Keys::new(sk); + let mut candidates = crate::util::chat_utils::attachment_key_candidates_from_ecdh( + &ecdh_keys, + ); + if let Some(primary) = candidates.first().cloned() { + attachment.decryption_key = Some(primary); + if candidates.len() > 1 { + attachment.decryption_key_fallbacks = + candidates.split_off(1); + } + } + } } } } @@ -959,10 +973,16 @@ pub fn handle_key_event( if let Ok(order) = crate::models::Order::get_by_id(&pool, &order_id).await { - attachment.decryption_key = - crate::util::chat_utils::order_chat_decryption_key_bytes( - &order, - ); + let mut candidates = crate::util::chat_utils::order_chat_attachment_key_candidates( + &order, + ); + if let Some(primary) = candidates.first().cloned() { + attachment.decryption_key = Some(primary); + if candidates.len() > 1 { + attachment.decryption_key_fallbacks = + candidates.split_off(1); + } + } } } let _ = tx.send((order_id, attachment)); @@ -1013,15 +1033,15 @@ pub fn handle_key_event( app.observer_shared_key_input.chars().take(8).collect(); let id = format!("observer_{}", key_prefix); - // Observer holds K_conv only; use it as the ChaCha key when the - // attachment JSON omitted an inline key. + // Observer holds K_conv only — the same ChaCha key used for + // v2 attachment encrypt (peers derive K_conv from ECDH). let mut att_clone = (*att).clone(); if att_clone.decryption_key.is_none() { if let Some(keys) = crate::util::chat_utils::keys_from_shared_hex( &app.observer_shared_key_input, ) { att_clone.decryption_key = - Some(keys.secret_key().secret_bytes().to_vec()); + Some(keys.secret_key().to_secret_bytes().to_vec()); } } diff --git a/src/util/blossom.rs b/src/util/blossom.rs index e5ba4e29..39ac3b63 100644 --- a/src/util/blossom.rs +++ b/src/util/blossom.rs @@ -1,6 +1,8 @@ //! Blossom URL resolution, blob download/upload, and ChaCha20-Poly1305 encrypt/decrypt. //! Matches Mostro Mobile encrypted file messaging: blob layout [nonce:12][ciphertext][tag:16]. -//! Shared key for decryption: ECDH(admin_sk, sender_pubkey), same as mostro-cli with roles swapped. +//! +//! Attachment ChaCha keys (v2): prefer `K_conv` (same secret disclosed to Observer). +//! Legacy / mobile blobs may still use the raw ECDH IKM — decrypt tries candidates in order. use anyhow::{anyhow, Result}; use base64::engine::general_purpose::STANDARD as BASE64; @@ -36,8 +38,10 @@ pub const DEFAULT_BLOSSOM_SERVERS: &[&str] = &[ /// Upload timeout (seconds). const BLOSSOM_UPLOAD_TIMEOUT_SECS: u64 = 300; -/// Derives the 32-byte shared decryption key from our (admin) private key and the sender's public key. -/// Mirror of mostro-cli's derive_shared_key: they use (trade_sk, admin_pubkey); we use (admin_sk, sender_pubkey). +/// Derives the ECDH shared secret (IKM) from our private key and the sender's public key. +/// +/// Prefer [`crate::util::chat_utils::attachment_key_candidates_from_ecdh`] for attachment +/// decrypt (`K_conv` then this IKM). Encrypt with `K_conv` only. pub fn derive_shared_key(admin_keys: &Keys, sender_pubkey: &PublicKey) -> Result<[u8; 32]> { let shared = SharedKey::derive(admin_keys.secret_key(), sender_pubkey) .map_err(|e| anyhow!("shared key derivation failed: {e}"))?; @@ -147,6 +151,21 @@ pub fn decrypt_blob(key: &[u8], blob: &[u8]) -> Result> { Ok(plaintext) } +/// Tries each ChaCha key in order until one decrypts (`K_conv`, then legacy ECDH, …). +pub fn decrypt_blob_with_keys(keys: &[Vec], blob: &[u8]) -> Result> { + if keys.is_empty() { + return Err(anyhow!("no decryption keys provided")); + } + let mut last_err = None; + for key in keys { + match decrypt_blob(key, blob) { + Ok(plain) => return Ok(plain), + Err(e) => last_err = Some(e), + } + } + Err(last_err.unwrap_or_else(|| anyhow!("decrypt failed"))) +} + /// Encrypts plaintext with ChaCha20-Poly1305. Returns `[nonce:12][ciphertext][tag:16]`. pub fn encrypt_blob(key: &[u8], plaintext: &[u8]) -> Result> { if key.len() != 32 { @@ -264,22 +283,28 @@ fn sanitize_filename(name: &str) -> String { } /// Downloads an attachment from a Blossom URL, optionally decrypts it, and writes to -/// `~/.mostrix/downloads/_` (or with `.enc` suffix if no key). +/// `~/.mostrix/downloads/_`. +/// +/// `decryption_keys` are tried in order via [`decrypt_blob_with_keys`] (v2: `K_conv`, +/// then legacy ECDH). An empty list leaves the blob encrypted and appends `.enc` +/// to the sanitized filename. pub async fn save_attachment_to_disk( dispute_id: String, blossom_url: String, filename: String, - decryption_key: Option>, + decryption_keys: Vec>, ) -> Result { let url = blossom_url_to_https(blossom_url.trim())?; let client = Client::new(); let blob = fetch_blob(&client, &url, 0, BLOSSOM_MAX_BLOB_SIZE).await?; - let bytes = match &decryption_key { - Some(key) => decrypt_blob(key, &blob)?, - None => blob, + let had_keys = !decryption_keys.is_empty(); + let bytes = if had_keys { + decrypt_blob_with_keys(&decryption_keys, &blob)? + } else { + blob }; let sanitized = sanitize_filename(&filename); - let final_name = if decryption_key.is_some() { + let final_name = if had_keys { sanitized } else { format!("{}.enc", sanitized) @@ -293,7 +318,11 @@ pub async fn save_attachment_to_disk( } /// Spawns a task to download the attachment, optionally decrypt it, and write to -/// `~/.mostrix/downloads/`. Sends `OperationResult::Info(path)` or `OperationResult::Error` on completion. +/// `~/.mostrix/downloads/`. +/// +/// Builds the decrypt candidate list from [`ChatAttachment::decryption_key`] then +/// [`ChatAttachment::decryption_key_fallbacks`]. Sends `OperationResult::Info(path)` +/// or `OperationResult::Error` on completion. pub fn spawn_save_attachment( dispute_id: String, attachment: ChatAttachment, @@ -301,9 +330,17 @@ pub fn spawn_save_attachment( ) { let blossom_url = attachment.blossom_url; let filename = attachment.filename; - let decryption_key = attachment.decryption_key; + let mut decryption_keys = Vec::new(); + if let Some(key) = attachment.decryption_key { + decryption_keys.push(key); + } + for key in attachment.decryption_key_fallbacks { + if decryption_keys.iter().all(|k| k != &key) { + decryption_keys.push(key); + } + } tokio::spawn(async move { - match save_attachment_to_disk(dispute_id, blossom_url, filename, decryption_key).await { + match save_attachment_to_disk(dispute_id, blossom_url, filename, decryption_keys).await { Ok(path) => { let _ = order_result_tx.send(OperationResult::Info(format!( "Saved to {}", @@ -347,6 +384,17 @@ mod tests { assert_eq!(out, plain); } + #[test] + fn decrypt_blob_with_keys_tries_until_match() { + let wrong = [1u8; 32]; + let right = [9u8; 32]; + let plain = b"observer k_conv decrypt"; + let blob = encrypt_blob(&right, plain).unwrap(); + let out = decrypt_blob_with_keys(&[wrong.to_vec(), right.to_vec()], &blob).unwrap(); + assert_eq!(out, plain); + assert!(decrypt_blob_with_keys(&[wrong.to_vec()], &blob).is_err()); + } + #[test] fn sha256_hex_known_empty() { assert_eq!( diff --git a/src/util/chat_utils.rs b/src/util/chat_utils.rs index e75a20ef..9895de19 100644 --- a/src/util/chat_utils.rs +++ b/src/util/chat_utils.rs @@ -116,7 +116,8 @@ pub(crate) fn observer_kind14_filter( /// Read-only disclosure for a solver: `K_conv` secret hex and `pub(K_sign)`. /// -/// Never returns the `K_sign` secret. `K_conv` decrypts; it cannot author kind 14. +/// Never returns the `K_sign` secret. `K_conv` decrypts kind-14 content and +/// attachment blobs; it cannot author kind 14. pub fn conversation_disclosure_from_ecdh(ecdh_keys: &Keys) -> Option<(String, String)> { let (conv, sign) = chat_keys_from_ecdh(ecdh_keys)?; Some(( @@ -228,11 +229,11 @@ pub fn dispute_chat_role_for_inner_signer( } } -/// 32-byte ChaCha20 key for decrypting order-chat attachments (shared ECDH secret). -pub fn order_chat_decryption_key_bytes(order: &Order) -> Option> { +/// Rebuild the order-chat ECDH `Keys` (IKM), from persisted hex or trade-key ECDH. +pub fn order_chat_ecdh_keys(order: &Order) -> Option { if let Some(hex) = order.order_chat_shared_key_hex.as_deref() { if let Some(keys) = keys_from_shared_hex(hex) { - return Some(keys.secret_key().to_secret_bytes().to_vec()); + return Some(keys); } } let trade_keys_hex = order.trade_keys.as_deref()?; @@ -241,7 +242,51 @@ pub fn order_chat_decryption_key_bytes(order: &Order) -> Option> { let cp = order.counterparty_pubkey.as_deref()?; let cp_pk = PublicKey::parse(cp).ok()?; derive_shared_keys(Some(&trade_keys), Some(&cp_pk)) - .map(|k| k.secret_key().to_secret_bytes().to_vec()) +} + +/// 32-byte ChaCha20 key for order-chat attachments: disclosed-compatible `K_conv`. +/// +/// Kind-14 chat wraps with `K_conv` / `K_sign`. Attachments use the same `K_conv` +/// secret so an Observer holding only the disclosed Shared key can decrypt. +/// Prefer this for encrypt and as the first decrypt candidate. +pub fn order_chat_decryption_key_bytes(order: &Order) -> Option> { + attachment_key_from_ecdh(&order_chat_ecdh_keys(order)?) +} + +/// Legacy ChaCha20 key (raw ECDH IKM) used before v2 / by Mostro Mobile multimedia. +pub fn order_chat_legacy_attachment_key_bytes(order: &Order) -> Option> { + Some( + order_chat_ecdh_keys(order)? + .secret_key() + .to_secret_bytes() + .to_vec(), + ) +} + +/// `K_conv` secret bytes from an ECDH `Keys` (IKM). +pub fn attachment_key_from_ecdh(ecdh_keys: &Keys) -> Option> { + let (conv, _) = chat_keys_from_ecdh(ecdh_keys)?; + Some(conv.secret_key().to_secret_bytes().to_vec()) +} + +/// Decrypt candidates for a channel that holds ECDH: `K_conv` first, then ECDH IKM. +pub fn attachment_key_candidates_from_ecdh(ecdh_keys: &Keys) -> Vec> { + let mut keys = Vec::with_capacity(2); + if let Some(conv) = attachment_key_from_ecdh(ecdh_keys) { + keys.push(conv); + } + let ecdh = ecdh_keys.secret_key().to_secret_bytes().to_vec(); + if keys.first().map(|k| k.as_slice()) != Some(ecdh.as_slice()) { + keys.push(ecdh); + } + keys +} + +/// Decrypt candidates for an order chat: `K_conv` then legacy ECDH. +pub fn order_chat_attachment_key_candidates(order: &Order) -> Vec> { + order_chat_ecdh_keys(order) + .map(|ecdh| attachment_key_candidates_from_ecdh(&ecdh)) + .unwrap_or_default() } /// Resolve the order-chat counterparty pubkey and the ECDH shared-key hex. @@ -1170,6 +1215,45 @@ mod tests { assert_eq!(via_order.1, sign_pk_hex); } + #[test] + fn attachment_key_is_k_conv_so_observer_can_decrypt() { + use crate::util::blossom::{decrypt_blob, encrypt_blob}; + + let a = Keys::generate(); + let b = Keys::generate(); + let ecdh = derive_shared_keys(Some(&a), Some(&b.public_key())).expect("ecdh"); + let (conv_hex, _) = conversation_disclosure_from_ecdh(&ecdh).expect("disclosure"); + + let encrypt_key = attachment_key_from_ecdh(&ecdh).expect("k_conv attach key"); + let ecdh_bytes = ecdh.secret_key().to_secret_bytes().to_vec(); + assert_ne!( + encrypt_key, ecdh_bytes, + "attachment ChaCha must not be raw ECDH (Observer only has K_conv)" + ); + + let plain = b"dispute evidence photo"; + let blob = encrypt_blob(&encrypt_key, plain).expect("encrypt"); + + // Observer pastes disclosed K_conv and rebuilds Keys the same way as Ctrl+S. + let observer_keys = keys_from_shared_hex(&conv_hex).expect("observer K_conv"); + let observer_key = observer_keys.secret_key().to_secret_bytes().to_vec(); + assert_eq!(observer_key, encrypt_key); + assert_eq!(decrypt_blob(&observer_key, &blob).expect("observer decrypt"), plain); + + let candidates = attachment_key_candidates_from_ecdh(&ecdh); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], encrypt_key); + assert_eq!(candidates[1], ecdh_bytes); + + // Legacy mobile/ECDH blobs still decrypt via the fallback candidate. + let legacy_blob = encrypt_blob(&ecdh_bytes, plain).expect("legacy encrypt"); + assert!(decrypt_blob(&encrypt_key, &legacy_blob).is_err()); + assert_eq!( + crate::util::blossom::decrypt_blob_with_keys(&candidates, &legacy_blob).expect("legacy"), + plain + ); + } + #[tokio::test] async fn observer_k_conv_only_unwraps_kind14() { let sender = Keys::generate(); diff --git a/src/util/mod.rs b/src/util/mod.rs index 4669b926..60aa9153 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -19,8 +19,9 @@ pub mod types; // Re-export commonly used items pub use crate::ui::helpers::PreparedOrderChatAttachment; pub use blossom::{ - blossom_url_to_https, decrypt_blob, encrypt_blob, fetch_blob, save_attachment_to_disk, - spawn_save_attachment, upload_blob_with_retry, BLOSSOM_MAX_BLOB_SIZE, DEFAULT_BLOSSOM_SERVERS, + blossom_url_to_https, decrypt_blob, decrypt_blob_with_keys, encrypt_blob, fetch_blob, + save_attachment_to_disk, spawn_save_attachment, upload_blob_with_retry, BLOSSOM_MAX_BLOB_SIZE, + DEFAULT_BLOSSOM_SERVERS, }; pub use chat_listener::{ listen_for_chat_messages, set_chat_router_cmd_tx, track_dispute_chat, track_order_chat, diff --git a/src/util/send_attachment.rs b/src/util/send_attachment.rs index b307f70b..4276c8c8 100644 --- a/src/util/send_attachment.rs +++ b/src/util/send_attachment.rs @@ -1,4 +1,8 @@ //! Send encrypted order-chat attachments (encrypt → Blossom → kind-14 chat DM). +//! +//! ChaCha20-Poly1305 uses the order-chat `K_conv` secret +//! ([`crate::util::chat_utils::order_chat_decryption_key_bytes`]) so an Observer +//! holding only the disclosed Shared key can decrypt the same blobs. use std::path::{Path, PathBuf}; @@ -189,7 +193,7 @@ pub async fn send_prepared_order_chat_attachment( Ok((local_message_from_prepared(prepared), info)) } -/// Encrypts, uploads, sends attachment JSON over order chat. +/// Encrypts with order-chat `K_conv`, uploads to Blossom, sends attachment JSON over chat. async fn send_order_chat_attachment_from_path( client: &Client, pool: &SqlitePool,