Skip to main content

mostro_core/chat/
unwrap.rs

1//! Decrypt and validate a Mostro P2P chat envelope.
2//!
3//! [`unwrap_chat_message`] implements the mandatory cheapest-first checks from
4//! <https://mostro.network/protocol/chat.html#client-security-requirements>,
5//! except the caller-owned steps: rate-limit budget, outer-id LRU, and durable
6//! inner-id deduplication.
7
8use nostr::nips::nip44;
9use nostr_sdk::prelude::*;
10
11use crate::error::{MostroError, ServiceError};
12
13/// Tolerance for clock skew between inner/outer `created_at` and against the
14/// recipient's local clock (absolute future bound). Spec default: 60 seconds.
15pub const CHAT_MAX_CLOCK_SKEW_SECS: u64 = 60;
16
17/// Upper bound on the encrypted outer `content`, enforced before decrypting.
18/// Spec default: 64 KiB.
19pub const CHAT_MAX_CONTENT_BYTES: usize = 64 * 1024;
20
21/// A decrypted P2P chat message.
22#[derive(Debug, Clone)]
23pub struct ChatMessage {
24    /// Plain-text body of the inner kind 1 event.
25    pub content: String,
26    /// Trade (or admin) public key of the sender — from the verified inner event.
27    pub sender: PublicKey,
28    /// `created_at` of the inner kind 1 event.
29    pub created_at: Timestamp,
30    /// Verified inner event id — retain durably for replay protection.
31    pub inner_event_id: EventId,
32    /// Outer event id — suitable for a bounded LRU against duplicate deliveries.
33    pub outer_event_id: EventId,
34}
35
36/// Unwrap a kind 14 chat event signed by `K_sign`.
37///
38/// * `conv` — `K_conv` (decrypt).
39/// * `sign_pubkey` — `pub(K_sign)` expected as outer author.
40/// * `allowed_signers` — accepted inner pubkeys (buyer+seller trade keys, or
41///   party trade key + admin pubkey for dispute chat).
42/// * `outer` — received kind 14 event.
43/// * `now` — recipient's clock for the absolute future bound.
44///
45/// Caller must still enforce rate limiting, outer-id LRU, and durable inner-id
46/// dedup using [`ChatMessage::inner_event_id`] / [`ChatMessage::outer_event_id`].
47pub fn unwrap_chat_message(
48    conv: &Keys,
49    sign_pubkey: &PublicKey,
50    allowed_signers: &[PublicKey],
51    outer: &Event,
52    now: Timestamp,
53) -> Result<ChatMessage, MostroError> {
54    // 1. Author
55    if outer.pubkey != *sign_pubkey {
56        return Err(MostroError::MostroInternalErr(
57            ServiceError::UnexpectedError(
58                "outer event is not authored by the conversation signing key".to_string(),
59            ),
60        ));
61    }
62    if outer.kind != Kind::PrivateDirectMessage {
63        return Err(MostroError::MostroInternalErr(
64            ServiceError::UnexpectedError("outer event is not kind 14".to_string()),
65        ));
66    }
67
68    // 2. Exactly one `p` tag equal to pub(K_conv)
69    let mut p_tags = outer.tags.iter().filter(|t| t.kind() == "p");
70    match (p_tags.next().and_then(|t| t.content()), p_tags.next()) {
71        (Some(pk), None) if pk == conv.public_key().to_hex() => {}
72        _ => {
73            return Err(MostroError::MostroInternalErr(
74                ServiceError::UnexpectedError(
75                    "outer event must carry exactly one p tag for this conversation".to_string(),
76                ),
77            ));
78        }
79    }
80
81    // 3. Absolute timestamp bound against local clock
82    if outer.created_at.as_secs() > now.as_secs().saturating_add(CHAT_MAX_CLOCK_SKEW_SECS) {
83        return Err(MostroError::MostroInternalErr(
84            ServiceError::UnexpectedError("outer event is dated too far in the future".to_string()),
85        ));
86    }
87
88    // 4. Size before crypto
89    if outer.content.len() > CHAT_MAX_CONTENT_BYTES {
90        return Err(MostroError::MostroInternalErr(
91            ServiceError::UnexpectedError(
92                "encrypted payload exceeds the accepted size".to_string(),
93            ),
94        ));
95    }
96
97    // 7. Outer signature (steps 5–6 are caller-owned)
98    outer.verify().map_err(|e| {
99        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
100            "invalid outer chat signature: {e}"
101        )))
102    })?;
103
104    // 8. Decrypt
105    let decrypted =
106        nip44::decrypt(conv.secret_key(), &conv.public_key(), &outer.content).map_err(|e| {
107            MostroError::MostroInternalErr(ServiceError::DecryptionError(format!(
108                "K_conv decrypt failed: {e}"
109            )))
110        })?;
111
112    let inner = Event::from_json(&decrypted).map_err(|e| {
113        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
114            "malformed inner chat event: {e}"
115        )))
116    })?;
117
118    // 9–11. Inner auth
119    inner.verify().map_err(|e| {
120        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
121            "invalid inner chat signature: {e}"
122        )))
123    })?;
124    if !allowed_signers.contains(&inner.pubkey) {
125        return Err(MostroError::MostroInternalErr(
126            ServiceError::UnexpectedError(
127                "inner event is signed by a key that is not a party to this conversation"
128                    .to_string(),
129            ),
130        ));
131    }
132    if inner.kind != Kind::TextNote {
133        return Err(MostroError::MostroInternalErr(
134            ServiceError::UnexpectedError("inner chat event is not a TextNote".to_string()),
135        ));
136    }
137
138    // 13. Relative timestamp bound (step 12 = caller durable inner-id dedup)
139    let skew = inner
140        .created_at
141        .as_secs()
142        .abs_diff(outer.created_at.as_secs());
143    if skew > CHAT_MAX_CLOCK_SKEW_SECS {
144        return Err(MostroError::MostroInternalErr(
145            ServiceError::UnexpectedError(
146                "inner and outer timestamps disagree — stale re-wrap".to_string(),
147            ),
148        ));
149    }
150
151    Ok(ChatMessage {
152        content: inner.content.clone(),
153        sender: inner.pubkey,
154        created_at: inner.created_at,
155        inner_event_id: inner.id,
156        outer_event_id: outer.id,
157    })
158}
159
160/// Legacy gift-wrap unwrap (kind 1059). Prefer [`unwrap_chat_message`].
161///
162/// Kept for dual-read migration: clients MAY accept both envelopes during the
163/// transition window.
164pub async fn unwrap_giftwrap_chat_message(
165    shared_keys: &Keys,
166    event: &Event,
167) -> Result<ChatMessage, MostroError> {
168    if event.kind != Kind::GiftWrap {
169        return Err(MostroError::MostroInternalErr(
170            ServiceError::UnexpectedError("event is not a GiftWrap".to_string()),
171        ));
172    }
173
174    let decrypted = nip44::decrypt(shared_keys.secret_key(), &event.pubkey, &event.content)
175        .map_err(|e| {
176            MostroError::MostroInternalErr(ServiceError::DecryptionError(format!(
177                "shared-key decrypt failed: {e}"
178            )))
179        })?;
180
181    let inner = Event::from_json(&decrypted).map_err(|e| {
182        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
183            "malformed inner chat event: {e}"
184        )))
185    })?;
186
187    if inner.kind != Kind::TextNote {
188        return Err(MostroError::MostroInternalErr(
189            ServiceError::UnexpectedError("inner chat event is not a TextNote".to_string()),
190        ));
191    }
192
193    inner.verify().map_err(|e| {
194        MostroError::MostroInternalErr(ServiceError::NostrError(format!(
195            "invalid inner chat signature: {e}"
196        )))
197    })?;
198
199    Ok(ChatMessage {
200        content: inner.content.clone(),
201        sender: inner.pubkey,
202        created_at: inner.created_at,
203        inner_event_id: inner.id,
204        outer_event_id: event.id,
205    })
206}