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