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
8pub 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 let b64 = STANDARD.encode(&result.ciphertext);
46 Ok(serde_json::json!({"encryptedMessage": b64}).to_string())
47}
48
49pub 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
97pub 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 if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw_body) {
116 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 return Box::pin(try_decrypt_message(
125 wallet,
126 &inner_str,
127 sender_pubkey_hex,
128 originator,
129 ))
130 .await;
131 }
132
133 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 raw_body.to_string()
143}
144
145#[derive(Clone, Debug, PartialEq, Eq)]
157pub enum DecryptOutcome {
158 Decrypted(String),
162 Plaintext(String),
167}
168
169impl DecryptOutcome {
170 pub fn into_body(self) -> String {
173 match self {
174 DecryptOutcome::Decrypted(s) | DecryptOutcome::Plaintext(s) => s,
175 }
176 }
177
178 pub fn is_authenticated(&self) -> bool {
180 matches!(self, DecryptOutcome::Decrypted(_))
181 }
182}
183
184pub 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 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 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 DecryptOutcome::Plaintext(raw_body.to_string())
230}
231
232pub 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 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 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 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 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 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 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 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}