Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,10 @@ pub struct Order {
pub premium: i64,
pub trade_keys: Option<String>,
pub counterparty_pubkey: Option<String>,
/// 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<String>,
/// Dispute UUID assigned by Mostro for this order.
pub dispute_id: Option<String>,
Expand Down
13 changes: 10 additions & 3 deletions src/ui/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
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<Vec<u8>>,
/// 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<Vec<u8>>,
}

/// A chat message in the dispute resolution interface
Expand Down
2 changes: 2 additions & 0 deletions src/ui/helpers/attachments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
}
}

Expand Down
1 change: 1 addition & 0 deletions src/ui/helpers/chat_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
38 changes: 29 additions & 9 deletions src/ui/key_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
}
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 onlythe 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());
}
}

Expand Down
72 changes: 60 additions & 12 deletions src/util/blossom.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}"))?;
Expand Down Expand Up @@ -147,6 +151,21 @@ pub fn decrypt_blob(key: &[u8], blob: &[u8]) -> Result<Vec<u8>> {
Ok(plaintext)
}

/// Tries each ChaCha key in order until one decrypts (`K_conv`, then legacy ECDH, …).
pub fn decrypt_blob_with_keys(keys: &[Vec<u8>], blob: &[u8]) -> Result<Vec<u8>> {
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<Vec<u8>> {
if key.len() != 32 {
Expand Down Expand Up @@ -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/<dispute_id>_<sanitized_filename>` (or with `.enc` suffix if no key).
/// `~/.mostrix/downloads/<dispute_id>_<sanitized_filename>`.
///
/// `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<Vec<u8>>,
decryption_keys: Vec<Vec<u8>>,
) -> Result<PathBuf> {
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)
Expand All @@ -293,17 +318,29 @@ 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,
order_result_tx: UnboundedSender<OperationResult>,
) {
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 {}",
Expand Down Expand Up @@ -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!(
Expand Down
94 changes: 89 additions & 5 deletions src/util/chat_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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((
Expand Down Expand Up @@ -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<Vec<u8>> {
/// Rebuild the order-chat ECDH `Keys` (IKM), from persisted hex or trade-key ECDH.
pub fn order_chat_ecdh_keys(order: &Order) -> Option<Keys> {
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()?;
Expand All @@ -241,7 +242,51 @@ pub fn order_chat_decryption_key_bytes(order: &Order) -> Option<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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.
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions src/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading