use anyhow::{Result, anyhow};
use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon};
const IV_SIZE: usize = 12;
#[derive(Debug, Clone, Copy)]
pub struct MessageEditContext<'a> {
pub original_msg_id: &'a str,
pub original_sender_jid: &'a str,
pub editor_jid: &'a str,
}
impl<'a> MessageEditContext<'a> {
fn as_addon_ctx(&self) -> AddonContext<'a> {
AddonContext {
stanza_id: self.original_msg_id,
parent_msg_original_sender: self.original_sender_jid,
modification_sender: self.editor_jid,
modification_type: ModificationType::MessageEdit,
}
}
}
pub fn encrypt_message_edit(
inner_message: &waproto::whatsapp::Message,
message_secret: &[u8],
ctx: &MessageEditContext<'_>,
) -> Result<(Vec<u8>, [u8; IV_SIZE])> {
let plaintext = waproto::codec::message_to_vec(inner_message);
encrypt_addon(&plaintext, message_secret, &ctx.as_addon_ctx())
}
pub fn decrypt_secret_encrypted(
enc_payload: &[u8],
iv: &[u8],
message_secret: &[u8],
modification_type: ModificationType,
ctx: &MessageEditContext<'_>,
) -> Result<waproto::whatsapp::Message> {
if iv.len() != IV_SIZE {
return Err(anyhow!(
"Invalid secret-encrypted IV length: expected {IV_SIZE}, got {}",
iv.len()
));
}
let addon = AddonContext {
stanza_id: ctx.original_msg_id,
parent_msg_original_sender: ctx.original_sender_jid,
modification_sender: ctx.editor_jid,
modification_type,
};
let plaintext = decrypt_addon(enc_payload, iv, message_secret, &addon)?;
waproto::codec::message_decode(&plaintext[..])
.map_err(|e| anyhow!("Failed to decode inner secret-encrypted Message: {e}"))
}
pub fn decrypt_message_edit(
enc_payload: &[u8],
iv: &[u8],
message_secret: &[u8],
ctx: &MessageEditContext<'_>,
) -> Result<waproto::whatsapp::Message> {
decrypt_secret_encrypted(
enc_payload,
iv,
message_secret,
ModificationType::MessageEdit,
ctx,
)
}
pub fn decrypt_secret_encrypted_with_fallback(
enc_payload: &[u8],
iv: &[u8],
message_secret: &[u8],
modification_type: ModificationType,
primary: &MessageEditContext<'_>,
fallback: Option<&MessageEditContext<'_>>,
) -> Result<waproto::whatsapp::Message> {
match decrypt_secret_encrypted(enc_payload, iv, message_secret, modification_type, primary) {
Ok(m) => Ok(m),
Err(primary_err) => match fallback {
Some(fb) => decrypt_secret_encrypted(
enc_payload,
iv,
message_secret,
modification_type,
fb,
)
.map_err(|fb_err| {
anyhow!("secret-encrypted decrypt failed: primary={primary_err}; fallback={fb_err}")
}),
None => Err(primary_err),
},
}
}
pub fn decrypt_message_edit_with_fallback(
enc_payload: &[u8],
iv: &[u8],
message_secret: &[u8],
primary: &MessageEditContext<'_>,
fallback: Option<&MessageEditContext<'_>>,
) -> Result<waproto::whatsapp::Message> {
decrypt_secret_encrypted_with_fallback(
enc_payload,
iv,
message_secret,
ModificationType::MessageEdit,
primary,
fallback,
)
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use buffa::MessageField;
use waproto::whatsapp as wa;
fn make_inner_edit(new_text: &str) -> wa::Message {
wa::Message {
protocol_message: MessageField::some(wa::message::ProtocolMessage {
key: MessageField::some(wa::MessageKey {
remote_jid: Some("g@g.us".to_string()),
from_me: Some(true),
id: Some("AC1234567890ABCDEF".to_string()),
participant: None,
}),
r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT),
edited_message: MessageField::some(wa::Message {
conversation: Some(new_text.to_string()),
..Default::default()
}),
timestamp_ms: Some(1_700_000_000_000),
..Default::default()
}),
..Default::default()
}
}
#[test]
fn encrypt_decrypt_roundtrip_text() {
let secret = [0x07u8; 32];
let ctx = MessageEditContext {
original_msg_id: "AC1234567890ABCDEF",
original_sender_jid: "5511999999999@s.whatsapp.net",
editor_jid: "5511999999999@s.whatsapp.net",
};
let inner = make_inner_edit("edited text");
let (enc, iv) = encrypt_message_edit(&inner, &secret, &ctx).unwrap();
let decoded = decrypt_message_edit(&enc, &iv, &secret, &ctx).unwrap();
let edited = decoded
.protocol_message
.as_option()
.and_then(|pm| pm.edited_message.as_option())
.expect("inner edited message present");
assert_eq!(edited.conversation.as_deref(), Some("edited text"));
}
#[test]
fn encrypt_decrypt_roundtrip_with_lid_jids() {
let secret = [0x42u8; 32];
let ctx = MessageEditContext {
original_msg_id: "AC1191FE0A25A0E319BEA72064819280",
original_sender_jid: "260661598801930@lid",
editor_jid: "260661598801930@lid",
};
let inner = make_inner_edit("B");
let (enc, iv) = encrypt_message_edit(&inner, &secret, &ctx).unwrap();
let decoded = decrypt_message_edit(&enc, &iv, &secret, &ctx).unwrap();
assert_eq!(
decoded
.protocol_message
.as_option()
.and_then(|pm| pm.edited_message.as_option())
.and_then(|m| m.conversation.as_deref()),
Some("B")
);
}
#[test]
fn wrong_editor_jid_fails() {
let secret = [0x07u8; 32];
let ctx = MessageEditContext {
original_msg_id: "AC1",
original_sender_jid: "a@s.whatsapp.net",
editor_jid: "a@s.whatsapp.net",
};
let (enc, iv) = encrypt_message_edit(&make_inner_edit("x"), &secret, &ctx).unwrap();
let bad = MessageEditContext {
editor_jid: "b@s.whatsapp.net",
..ctx
};
assert!(decrypt_message_edit(&enc, &iv, &secret, &bad).is_err());
}
#[test]
fn wrong_message_secret_fails() {
let ctx = MessageEditContext {
original_msg_id: "AC1",
original_sender_jid: "a@s.whatsapp.net",
editor_jid: "a@s.whatsapp.net",
};
let (enc, iv) = encrypt_message_edit(&make_inner_edit("x"), &[0x07u8; 32], &ctx).unwrap();
assert!(decrypt_message_edit(&enc, &iv, &[0x08u8; 32], &ctx).is_err());
}
#[test]
fn invalid_iv_length_rejected() {
let ctx = MessageEditContext {
original_msg_id: "AC1",
original_sender_jid: "a@s.whatsapp.net",
editor_jid: "a@s.whatsapp.net",
};
let (enc, _iv) = encrypt_message_edit(&make_inner_edit("x"), &[0x07u8; 32], &ctx).unwrap();
assert!(decrypt_message_edit(&enc, &[0u8; 11], &[0x07u8; 32], &ctx).is_err());
assert!(decrypt_message_edit(&enc, &[0u8; 16], &[0x07u8; 32], &ctx).is_err());
}
#[test]
fn general_decrypt_roundtrips_non_edit_use_case() {
use crate::secret_enc_addon::{AddonContext, ModificationType, encrypt_addon};
use buffa::Message as _;
let secret = [0x71u8; 32];
let ctx = MessageEditContext {
original_msg_id: "POLLID",
original_sender_jid: "creator@s.whatsapp.net",
editor_jid: "editor@s.whatsapp.net",
};
let inner = wa::Message {
conversation: Some("poll edited".to_string()),
..Default::default()
};
let (enc, iv) = encrypt_addon(
&inner.encode_to_vec(),
&secret,
&AddonContext {
stanza_id: ctx.original_msg_id,
parent_msg_original_sender: ctx.original_sender_jid,
modification_sender: ctx.editor_jid,
modification_type: ModificationType::PollEdit,
},
)
.unwrap();
let out =
decrypt_secret_encrypted(&enc, &iv, &secret, ModificationType::PollEdit, &ctx).unwrap();
assert_eq!(out.conversation.as_deref(), Some("poll edited"));
assert!(decrypt_message_edit(&enc, &iv, &secret, &ctx).is_err());
}
#[test]
fn fallback_recovers_on_alternate_jid_form() {
let secret = [0x09u8; 32];
let pn_ctx = MessageEditContext {
original_msg_id: "ID",
original_sender_jid: "5511999@s.whatsapp.net",
editor_jid: "5511999@s.whatsapp.net",
};
let lid_ctx = MessageEditContext {
original_msg_id: "ID",
original_sender_jid: "12345@lid",
editor_jid: "12345@lid",
};
let (enc, iv) = encrypt_message_edit(&make_inner_edit("hello"), &secret, &pn_ctx).unwrap();
let m = decrypt_message_edit_with_fallback(&enc, &iv, &secret, &lid_ctx, Some(&pn_ctx))
.expect("fallback should rescue");
assert_eq!(
m.protocol_message
.as_option()
.and_then(|pm| pm.edited_message.as_option())
.and_then(|m| m.conversation.as_deref()),
Some("hello")
);
}
#[test]
fn fallback_returns_combined_error_when_both_fail() {
let secret = [0x09u8; 32];
let pn_ctx = MessageEditContext {
original_msg_id: "ID",
original_sender_jid: "5511999@s.whatsapp.net",
editor_jid: "5511999@s.whatsapp.net",
};
let (enc, iv) = encrypt_message_edit(&make_inner_edit("x"), &secret, &pn_ctx).unwrap();
let wrong1 = MessageEditContext {
editor_jid: "evil1@s.whatsapp.net",
..pn_ctx
};
let wrong2 = MessageEditContext {
editor_jid: "evil2@s.whatsapp.net",
..pn_ctx
};
let err = decrypt_message_edit_with_fallback(&enc, &iv, &secret, &wrong1, Some(&wrong2))
.expect_err("both fail");
let s = err.to_string();
assert!(s.contains("primary="));
assert!(s.contains("fallback="));
}
}