Skip to main content

bsv_messagebox_client/
encryption.rs

1use base64::{engine::general_purpose::STANDARD, Engine};
2use bsv::primitives::public_key::PublicKey;
3use bsv::wallet::interfaces::{
4    CreateHmacArgs, DecryptArgs, EncryptArgs, WalletInterface,
5};
6use bsv::wallet::types::{Counterparty, CounterpartyType, Protocol};
7
8use crate::error::MessageBoxError;
9
10/// BRC-78 encrypt a message body for a recipient.
11///
12/// Returns a JSON string: `{"encryptedMessage":"<STANDARD_base64_ciphertext>"}`.
13/// Must use STANDARD base64 (with padding) for TS interop — URL-safe or unpadded variants
14/// produce different output that the TS client cannot decrypt.
15pub async fn encrypt_body<W: WalletInterface>(
16    wallet: &W,
17    body: &str,
18    recipient_pubkey_hex: &str,
19    originator: Option<&str>,
20) -> Result<String, MessageBoxError> {
21    let pk = PublicKey::from_string(recipient_pubkey_hex)
22        .map_err(|e| MessageBoxError::Encryption(e.to_string()))?;
23
24    let result = wallet
25        .encrypt(
26            EncryptArgs {
27                protocol_id: Protocol {
28                    security_level: 1,
29                    protocol: "messagebox".to_string(),
30                },
31                key_id: "1".to_string(),
32                counterparty: Counterparty {
33                    counterparty_type: CounterpartyType::Other,
34                    public_key: Some(pk),
35                },
36                plaintext: body.as_bytes().to_vec(),
37                privileged: false,
38                privileged_reason: None,
39                seek_permission: None,
40            },
41            originator,
42        )
43        .await
44        .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
45
46    // STANDARD base64 with padding — required for TS interop
47    let b64 = STANDARD.encode(&result.ciphertext);
48    Ok(serde_json::json!({"encryptedMessage": b64}).to_string())
49}
50
51/// BRC-78 decrypt an encrypted message body from a sender.
52///
53/// Parses `encrypted_json` expecting `{"encryptedMessage":"<base64>"}`,
54/// base64-decodes the ciphertext, and decrypts using the sender's public key.
55pub async fn decrypt_body<W: WalletInterface>(
56    wallet: &W,
57    encrypted_json: &str,
58    sender_pubkey_hex: &str,
59    originator: Option<&str>,
60) -> Result<String, MessageBoxError> {
61    let v: serde_json::Value = serde_json::from_str(encrypted_json)?;
62    let b64 = v["encryptedMessage"]
63        .as_str()
64        .ok_or_else(|| MessageBoxError::Encryption("missing encryptedMessage field".to_string()))?;
65
66    let ciphertext = STANDARD
67        .decode(b64)
68        .map_err(|e| MessageBoxError::Encryption(format!("base64 decode: {e}")))?;
69
70    let pk = PublicKey::from_string(sender_pubkey_hex)
71        .map_err(|e| MessageBoxError::Encryption(e.to_string()))?;
72
73    let result = wallet
74        .decrypt(
75            DecryptArgs {
76                protocol_id: Protocol {
77                    security_level: 1,
78                    protocol: "messagebox".to_string(),
79                },
80                key_id: "1".to_string(),
81                counterparty: Counterparty {
82                    counterparty_type: CounterpartyType::Other,
83                    public_key: Some(pk),
84                },
85                ciphertext,
86                privileged: false,
87                privileged_reason: None,
88                seek_permission: None,
89            },
90            originator,
91        )
92        .await
93        .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
94
95    String::from_utf8(result.plaintext)
96        .map_err(|e| MessageBoxError::Encryption(format!("utf-8 decode: {e}")))
97}
98
99/// Try to decrypt a message body, with graceful fallback for plaintext.
100///
101/// Handles three cases (matching TS `listMessagesLite` lines 1442-1452):
102/// 1. JSON with a `"message"` wrapper key: unwrap first, then check inner content.
103/// 2. JSON with `"encryptedMessage"` key: decrypt.
104/// 3. Anything else (no encryptedMessage key, or not JSON): return original body unchanged.
105///
106/// Does NOT panic on parse failure — always returns a String.
107///
108/// NOTE: PARITY — passes `originator: None` inside when called from list_messages_lite.
109/// This matches a latent TS bug where listMessagesLite omits originator.
110pub async fn try_decrypt_message<W: WalletInterface>(
111    wallet: &W,
112    raw_body: &str,
113    sender_pubkey_hex: &str,
114    originator: Option<&str>,
115) -> String {
116    // Try to parse as JSON and inspect the content
117    if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw_body) {
118        // Case 1: payment envelope wrapper — unwrap and recurse into inner content
119        if let Some(inner) = v.get("message") {
120            let inner_str = if inner.is_string() {
121                inner.as_str().unwrap().to_string()
122            } else {
123                inner.to_string()
124            };
125            // Recursively handle the unwrapped inner body
126            return Box::pin(try_decrypt_message(wallet, &inner_str, sender_pubkey_hex, originator)).await;
127        }
128
129        // Case 2: encrypted body
130        if v.get("encryptedMessage").is_some() {
131            return decrypt_body(wallet, raw_body, sender_pubkey_hex, originator)
132                .await
133                .unwrap_or_else(|_| raw_body.to_string());
134        }
135    }
136
137    // Case 3: plaintext passthrough (ENC-04)
138    raw_body.to_string()
139}
140
141/// Outcome of [`try_decrypt_message_typed`] — distinguishes a body that genuinely
142/// AEAD-decrypted from one that was merely passed through (plaintext or a
143/// fail-open decrypt failure).
144///
145/// SECURITY (fail-closed boundary support): `try_decrypt_message` returns a bare
146/// `String` and is **fail-OPEN** — on AEAD-decrypt failure it returns the raw
147/// encrypted envelope, and for non-encrypted bodies it returns the raw plaintext.
148/// Callers that require sender provenance (e.g. the MPC transport, whose messages
149/// are ALWAYS sent encrypted) cannot tell those apart from a real decrypt. This
150/// typed sibling preserves that distinction so the caller can reject anything that
151/// did not authenticate-decrypt against the claimed sender's key.
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub enum DecryptOutcome {
154    /// The encrypted-envelope branch ran and `decrypt_body` returned `Ok` — the
155    /// body genuinely AEAD-decrypted against the claimed sender. The inner string
156    /// is the recovered plaintext.
157    Decrypted(String),
158    /// The body was NOT authenticated-decrypted: either a non-encrypted passthrough
159    /// (Case 3) or an `{"encryptedMessage":...}` body whose `decrypt_body` failed
160    /// (the Case-2 fail-open branch). The inner string is the raw body verbatim,
161    /// byte-for-byte identical to what `try_decrypt_message` would return.
162    Plaintext(String),
163}
164
165impl DecryptOutcome {
166    /// The recovered/passthrough body string, regardless of authenticity. Equals
167    /// exactly what [`try_decrypt_message`] returns for the same inputs.
168    pub fn into_body(self) -> String {
169        match self {
170            DecryptOutcome::Decrypted(s) | DecryptOutcome::Plaintext(s) => s,
171        }
172    }
173
174    /// `true` iff the body genuinely AEAD-decrypted (the `Decrypted` variant).
175    pub fn is_authenticated(&self) -> bool {
176        matches!(self, DecryptOutcome::Decrypted(_))
177    }
178}
179
180/// Typed sibling of [`try_decrypt_message`] that additionally surfaces whether the
181/// body genuinely AEAD-decrypted.
182///
183/// Returns [`DecryptOutcome::Decrypted`] ONLY when the encrypted-envelope branch
184/// (`{"encryptedMessage":...}`) ran AND `decrypt_body` returned `Ok`. Returns
185/// [`DecryptOutcome::Plaintext`] for the Case-3 non-encrypted passthrough AND for
186/// the Case-2 decrypt-failure fail-open branch.
187///
188/// The returned body string is byte-for-byte identical to [`try_decrypt_message`]
189/// for the same inputs, so this is a strict superset — callers that only need the
190/// string can call `.into_body()`.
191pub async fn try_decrypt_message_typed<W: WalletInterface>(
192    wallet: &W,
193    raw_body: &str,
194    sender_pubkey_hex: &str,
195    originator: Option<&str>,
196) -> DecryptOutcome {
197    if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw_body) {
198        // Case 1: payment envelope wrapper — unwrap and recurse. Authenticity of
199        // the OUTER wrapper is irrelevant; the inner recursion decides.
200        if let Some(inner) = v.get("message") {
201            let inner_str = if inner.is_string() {
202                inner.as_str().unwrap().to_string()
203            } else {
204                inner.to_string()
205            };
206            return Box::pin(try_decrypt_message_typed(
207                wallet,
208                &inner_str,
209                sender_pubkey_hex,
210                originator,
211            ))
212            .await;
213        }
214
215        // Case 2: encrypted body — Decrypted only on a genuine Ok, else fail-open Plaintext.
216        if v.get("encryptedMessage").is_some() {
217            return match decrypt_body(wallet, raw_body, sender_pubkey_hex, originator).await {
218                Ok(plaintext) => DecryptOutcome::Decrypted(plaintext),
219                Err(_) => DecryptOutcome::Plaintext(raw_body.to_string()),
220            };
221        }
222    }
223
224    // Case 3: plaintext passthrough — not authenticated.
225    DecryptOutcome::Plaintext(raw_body.to_string())
226}
227
228/// Generate a deterministic HMAC-based message ID for idempotency.
229///
230/// CRITICAL: The TS uses `JSON.stringify(message.body)` as the HMAC data (line 917).
231/// For a plain string body, this means the data is the JSON-encoded string
232/// (e.g., `"hello"` becomes `"\"hello\""` with surrounding quotes).
233/// Use `serde_json::to_string(body)` to replicate this behavior exactly.
234pub async fn generate_message_id<W: WalletInterface>(
235    wallet: &W,
236    body: &str,
237    recipient_pubkey_hex: &str,
238    originator: Option<&str>,
239) -> Result<String, MessageBoxError> {
240    // Replicate TS JSON.stringify(body) — wraps the string in JSON quotes
241    let json_body = serde_json::to_string(body)?;
242
243    let pk = PublicKey::from_string(recipient_pubkey_hex)
244        .map_err(|e| MessageBoxError::Encryption(e.to_string()))?;
245
246    let result = wallet
247        .create_hmac(
248            CreateHmacArgs {
249                protocol_id: Protocol {
250                    security_level: 1,
251                    protocol: "messagebox".to_string(),
252                },
253                key_id: "1".to_string(),
254                counterparty: Counterparty {
255                    counterparty_type: CounterpartyType::Other,
256                    public_key: Some(pk),
257                },
258                data: json_body.as_bytes().to_vec(),
259                privileged: false,
260                privileged_reason: None,
261                seek_permission: None,
262            },
263            originator,
264        )
265        .await
266        .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
267
268    // Hex-encode matching TS: Array.from(hmac).map(b => b.toString(16).padStart(2,'0')).join('')
269    Ok(result.hmac.iter().map(|b| format!("{b:02x}")).collect())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use bsv::primitives::private_key::PrivateKey;
276    use bsv::wallet::interfaces::GetPublicKeyArgs;
277    use bsv::wallet::proto_wallet::ProtoWallet;
278
279    fn make_wallet() -> ProtoWallet {
280        let key = PrivateKey::from_random().expect("random key");
281        ProtoWallet::new(key)
282    }
283
284    async fn identity_hex(wallet: &ProtoWallet) -> String {
285        wallet
286            .get_public_key(
287                GetPublicKeyArgs {
288                    identity_key: true,
289                    protocol_id: None,
290                    key_id: None,
291                    counterparty: None,
292                    privileged: false,
293                    privileged_reason: None,
294                    for_self: None,
295                    seek_permission: None,
296                },
297                None,
298            )
299            .await
300            .expect("get_public_key")
301            .public_key
302            .to_der_hex()
303    }
304
305    #[tokio::test]
306    async fn encrypt_body_produces_valid_json_with_base64() {
307        let sender = make_wallet();
308        let receiver = make_wallet();
309        let receiver_pk = identity_hex(&receiver).await;
310
311        let encrypted = encrypt_body(&sender, "hello world", &receiver_pk, None)
312            .await
313            .expect("encrypt_body");
314
315        let v: serde_json::Value = serde_json::from_str(&encrypted).expect("valid json");
316        let b64 = v["encryptedMessage"].as_str().expect("encryptedMessage field");
317        // Must be valid STANDARD base64 (with padding)
318        STANDARD.decode(b64).expect("valid STANDARD base64");
319    }
320
321    #[tokio::test]
322    async fn encrypt_decrypt_round_trip() {
323        let sender = make_wallet();
324        let receiver = make_wallet();
325        let sender_pk = identity_hex(&sender).await;
326        let receiver_pk = identity_hex(&receiver).await;
327
328        let message = "The quick brown fox jumps over the lazy dog — unicode: 日本語 🦊";
329        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
330            .await
331            .expect("encrypt");
332        let decrypted = decrypt_body(&receiver, &encrypted, &sender_pk, None)
333            .await
334            .expect("decrypt");
335
336        assert_eq!(decrypted, message);
337    }
338
339    #[tokio::test]
340    async fn try_decrypt_plaintext_passthrough() {
341        let wallet = make_wallet();
342        let other = make_wallet();
343        let other_pk = identity_hex(&other).await;
344
345        let result = try_decrypt_message(&wallet, "plain text body", &other_pk, None).await;
346        assert_eq!(result, "plain text body");
347    }
348
349    #[tokio::test]
350    async fn try_decrypt_json_without_encrypted_message_passthrough() {
351        let wallet = make_wallet();
352        let other = make_wallet();
353        let other_pk = identity_hex(&other).await;
354
355        let input = r#"{"foo":"bar","baz":42}"#;
356        let result = try_decrypt_message(&wallet, input, &other_pk, None).await;
357        assert_eq!(result, input);
358    }
359
360    #[tokio::test]
361    async fn try_decrypt_with_encrypted_body_decrypts() {
362        let sender = make_wallet();
363        let receiver = make_wallet();
364        let sender_pk = identity_hex(&sender).await;
365        let receiver_pk = identity_hex(&receiver).await;
366
367        let message = "secret message content";
368        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
369            .await
370            .expect("encrypt");
371
372        let result = try_decrypt_message(&receiver, &encrypted, &sender_pk, None).await;
373        assert_eq!(result, message);
374    }
375
376    #[tokio::test]
377    async fn try_decrypt_unwraps_payment_envelope_and_decrypts() {
378        let sender = make_wallet();
379        let receiver = make_wallet();
380        let sender_pk = identity_hex(&sender).await;
381        let receiver_pk = identity_hex(&receiver).await;
382
383        let message = "payment wrapped message";
384        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
385            .await
386            .expect("encrypt");
387
388        // Simulate payment envelope: {"message": "<encrypted_json>", "payment": {...}}
389        let wrapped = serde_json::json!({
390            "message": encrypted,
391            "payment": {"txid": "abc123"}
392        })
393        .to_string();
394
395        let result = try_decrypt_message(&receiver, &wrapped, &sender_pk, None).await;
396        assert_eq!(result, message);
397    }
398
399    #[tokio::test]
400    async fn typed_decrypt_plaintext_body_is_plaintext_outcome() {
401        // A valid-shaped plaintext WireMessage body must NOT be reported as
402        // authenticated — this is the bypass the fail-closed boundary defends.
403        let wallet = make_wallet();
404        let other = make_wallet();
405        let other_pk = identity_hex(&other).await;
406
407        let body = r#"{"sender":0,"is_broadcast":true,"msg":{"round":1}}"#;
408        let outcome = try_decrypt_message_typed(&wallet, body, &other_pk, None).await;
409        assert_eq!(outcome, DecryptOutcome::Plaintext(body.to_string()));
410        assert!(!outcome.is_authenticated());
411        assert_eq!(outcome.into_body(), body);
412    }
413
414    #[tokio::test]
415    async fn typed_decrypt_non_json_plaintext_is_plaintext_outcome() {
416        let wallet = make_wallet();
417        let other = make_wallet();
418        let other_pk = identity_hex(&other).await;
419
420        let outcome = try_decrypt_message_typed(&wallet, "plain text", &other_pk, None).await;
421        assert!(!outcome.is_authenticated());
422    }
423
424    #[tokio::test]
425    async fn typed_decrypt_failed_aead_fails_open_to_plaintext_outcome() {
426        // An {"encryptedMessage":...} body that does NOT decrypt under our key must
427        // be reported Plaintext (fail-open string parity) but NOT authenticated.
428        let wallet = make_wallet();
429        let other = make_wallet();
430        let other_pk = identity_hex(&other).await;
431
432        let body = r#"{"encryptedMessage":"AAAAbase64ciphertext=="}"#;
433        let outcome = try_decrypt_message_typed(&wallet, body, &other_pk, None).await;
434        assert!(!outcome.is_authenticated(), "garbage ciphertext must not authenticate");
435        // String parity with try_decrypt_message (fail-open returns raw body).
436        let legacy = try_decrypt_message(&wallet, body, &other_pk, None).await;
437        assert_eq!(outcome.into_body(), legacy);
438    }
439
440    #[tokio::test]
441    async fn typed_decrypt_round_trip_is_decrypted_outcome() {
442        let sender = make_wallet();
443        let receiver = make_wallet();
444        let sender_pk = identity_hex(&sender).await;
445        let receiver_pk = identity_hex(&receiver).await;
446
447        let message = "secret authenticated content";
448        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
449            .await
450            .expect("encrypt");
451
452        let outcome = try_decrypt_message_typed(&receiver, &encrypted, &sender_pk, None).await;
453        assert_eq!(outcome, DecryptOutcome::Decrypted(message.to_string()));
454        assert!(outcome.is_authenticated());
455    }
456
457    #[tokio::test]
458    async fn typed_decrypt_payment_envelope_round_trip_is_decrypted() {
459        let sender = make_wallet();
460        let receiver = make_wallet();
461        let sender_pk = identity_hex(&sender).await;
462        let receiver_pk = identity_hex(&receiver).await;
463
464        let message = "wrapped authenticated content";
465        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
466            .await
467            .expect("encrypt");
468        let wrapped = serde_json::json!({"message": encrypted, "payment": {"txid": "abc"}})
469            .to_string();
470
471        let outcome = try_decrypt_message_typed(&receiver, &wrapped, &sender_pk, None).await;
472        assert_eq!(outcome, DecryptOutcome::Decrypted(message.to_string()));
473    }
474
475    #[tokio::test]
476    async fn generate_message_id_returns_64_char_hex() {
477        let wallet = make_wallet();
478        let other = make_wallet();
479        let other_pk = identity_hex(&other).await;
480
481        let id = generate_message_id(&wallet, "test body", &other_pk, None)
482            .await
483            .expect("generate_message_id");
484
485        assert_eq!(id.len(), 64, "HMAC hex should be 64 chars (32 bytes)");
486        assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "all hex chars");
487        assert!(id.chars().all(|c| !c.is_uppercase()), "lowercase hex");
488    }
489
490    #[tokio::test]
491    async fn generate_message_id_is_deterministic() {
492        let wallet = make_wallet();
493        let other = make_wallet();
494        let other_pk = identity_hex(&other).await;
495
496        let id1 = generate_message_id(&wallet, "same body", &other_pk, None)
497            .await
498            .expect("first call");
499        let id2 = generate_message_id(&wallet, "same body", &other_pk, None)
500            .await
501            .expect("second call");
502
503        assert_eq!(id1, id2, "same inputs must produce same HMAC");
504    }
505}