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/// Generate a deterministic HMAC-based message ID for idempotency.
142///
143/// CRITICAL: The TS uses `JSON.stringify(message.body)` as the HMAC data (line 917).
144/// For a plain string body, this means the data is the JSON-encoded string
145/// (e.g., `"hello"` becomes `"\"hello\""` with surrounding quotes).
146/// Use `serde_json::to_string(body)` to replicate this behavior exactly.
147pub async fn generate_message_id<W: WalletInterface>(
148    wallet: &W,
149    body: &str,
150    recipient_pubkey_hex: &str,
151    originator: Option<&str>,
152) -> Result<String, MessageBoxError> {
153    // Replicate TS JSON.stringify(body) — wraps the string in JSON quotes
154    let json_body = serde_json::to_string(body)?;
155
156    let pk = PublicKey::from_string(recipient_pubkey_hex)
157        .map_err(|e| MessageBoxError::Encryption(e.to_string()))?;
158
159    let result = wallet
160        .create_hmac(
161            CreateHmacArgs {
162                protocol_id: Protocol {
163                    security_level: 1,
164                    protocol: "messagebox".to_string(),
165                },
166                key_id: "1".to_string(),
167                counterparty: Counterparty {
168                    counterparty_type: CounterpartyType::Other,
169                    public_key: Some(pk),
170                },
171                data: json_body.as_bytes().to_vec(),
172                privileged: false,
173                privileged_reason: None,
174                seek_permission: None,
175            },
176            originator,
177        )
178        .await
179        .map_err(|e| MessageBoxError::Wallet(e.to_string()))?;
180
181    // Hex-encode matching TS: Array.from(hmac).map(b => b.toString(16).padStart(2,'0')).join('')
182    Ok(result.hmac.iter().map(|b| format!("{b:02x}")).collect())
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use bsv::primitives::private_key::PrivateKey;
189    use bsv::wallet::interfaces::GetPublicKeyArgs;
190    use bsv::wallet::proto_wallet::ProtoWallet;
191
192    fn make_wallet() -> ProtoWallet {
193        let key = PrivateKey::from_random().expect("random key");
194        ProtoWallet::new(key)
195    }
196
197    async fn identity_hex(wallet: &ProtoWallet) -> String {
198        wallet
199            .get_public_key(
200                GetPublicKeyArgs {
201                    identity_key: true,
202                    protocol_id: None,
203                    key_id: None,
204                    counterparty: None,
205                    privileged: false,
206                    privileged_reason: None,
207                    for_self: None,
208                    seek_permission: None,
209                },
210                None,
211            )
212            .await
213            .expect("get_public_key")
214            .public_key
215            .to_der_hex()
216    }
217
218    #[tokio::test]
219    async fn encrypt_body_produces_valid_json_with_base64() {
220        let sender = make_wallet();
221        let receiver = make_wallet();
222        let receiver_pk = identity_hex(&receiver).await;
223
224        let encrypted = encrypt_body(&sender, "hello world", &receiver_pk, None)
225            .await
226            .expect("encrypt_body");
227
228        let v: serde_json::Value = serde_json::from_str(&encrypted).expect("valid json");
229        let b64 = v["encryptedMessage"].as_str().expect("encryptedMessage field");
230        // Must be valid STANDARD base64 (with padding)
231        STANDARD.decode(b64).expect("valid STANDARD base64");
232    }
233
234    #[tokio::test]
235    async fn encrypt_decrypt_round_trip() {
236        let sender = make_wallet();
237        let receiver = make_wallet();
238        let sender_pk = identity_hex(&sender).await;
239        let receiver_pk = identity_hex(&receiver).await;
240
241        let message = "The quick brown fox jumps over the lazy dog — unicode: 日本語 🦊";
242        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
243            .await
244            .expect("encrypt");
245        let decrypted = decrypt_body(&receiver, &encrypted, &sender_pk, None)
246            .await
247            .expect("decrypt");
248
249        assert_eq!(decrypted, message);
250    }
251
252    #[tokio::test]
253    async fn try_decrypt_plaintext_passthrough() {
254        let wallet = make_wallet();
255        let other = make_wallet();
256        let other_pk = identity_hex(&other).await;
257
258        let result = try_decrypt_message(&wallet, "plain text body", &other_pk, None).await;
259        assert_eq!(result, "plain text body");
260    }
261
262    #[tokio::test]
263    async fn try_decrypt_json_without_encrypted_message_passthrough() {
264        let wallet = make_wallet();
265        let other = make_wallet();
266        let other_pk = identity_hex(&other).await;
267
268        let input = r#"{"foo":"bar","baz":42}"#;
269        let result = try_decrypt_message(&wallet, input, &other_pk, None).await;
270        assert_eq!(result, input);
271    }
272
273    #[tokio::test]
274    async fn try_decrypt_with_encrypted_body_decrypts() {
275        let sender = make_wallet();
276        let receiver = make_wallet();
277        let sender_pk = identity_hex(&sender).await;
278        let receiver_pk = identity_hex(&receiver).await;
279
280        let message = "secret message content";
281        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
282            .await
283            .expect("encrypt");
284
285        let result = try_decrypt_message(&receiver, &encrypted, &sender_pk, None).await;
286        assert_eq!(result, message);
287    }
288
289    #[tokio::test]
290    async fn try_decrypt_unwraps_payment_envelope_and_decrypts() {
291        let sender = make_wallet();
292        let receiver = make_wallet();
293        let sender_pk = identity_hex(&sender).await;
294        let receiver_pk = identity_hex(&receiver).await;
295
296        let message = "payment wrapped message";
297        let encrypted = encrypt_body(&sender, message, &receiver_pk, None)
298            .await
299            .expect("encrypt");
300
301        // Simulate payment envelope: {"message": "<encrypted_json>", "payment": {...}}
302        let wrapped = serde_json::json!({
303            "message": encrypted,
304            "payment": {"txid": "abc123"}
305        })
306        .to_string();
307
308        let result = try_decrypt_message(&receiver, &wrapped, &sender_pk, None).await;
309        assert_eq!(result, message);
310    }
311
312    #[tokio::test]
313    async fn generate_message_id_returns_64_char_hex() {
314        let wallet = make_wallet();
315        let other = make_wallet();
316        let other_pk = identity_hex(&other).await;
317
318        let id = generate_message_id(&wallet, "test body", &other_pk, None)
319            .await
320            .expect("generate_message_id");
321
322        assert_eq!(id.len(), 64, "HMAC hex should be 64 chars (32 bytes)");
323        assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "all hex chars");
324        assert!(id.chars().all(|c| !c.is_uppercase()), "lowercase hex");
325    }
326
327    #[tokio::test]
328    async fn generate_message_id_is_deterministic() {
329        let wallet = make_wallet();
330        let other = make_wallet();
331        let other_pk = identity_hex(&other).await;
332
333        let id1 = generate_message_id(&wallet, "same body", &other_pk, None)
334            .await
335            .expect("first call");
336        let id2 = generate_message_id(&wallet, "same body", &other_pk, None)
337            .await
338            .expect("second call");
339
340        assert_eq!(id1, id2, "same inputs must produce same HMAC");
341    }
342}