use anyhow::{Result, anyhow};
use sha2::{Digest, Sha256};
use crate::secret_enc_addon::{
AddonContext, ModificationType, build_aad, decrypt_addon, encrypt_addon,
};
const GCM_IV_SIZE: usize = 12;
const GCM_TAG_SIZE: usize = 16;
fn poll_vote_addon_ctx<'a>(
stanza_id: &'a str,
poll_creator_jid: &'a str,
voter_jid: &'a str,
) -> AddonContext<'a> {
AddonContext {
stanza_id,
parent_msg_original_sender: poll_creator_jid,
modification_sender: voter_jid,
modification_type: ModificationType::PollVote,
}
}
pub fn compute_option_hash(option_name: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(option_name.as_bytes());
hasher.finalize().into()
}
pub fn derive_vote_encryption_key(
message_secret: &[u8],
stanza_id: &str,
poll_creator_jid: &str,
voter_jid: &str,
) -> Result<[u8; 32]> {
crate::secret_enc_addon::derive_use_case_secret(
message_secret,
&poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid),
)
}
pub fn encrypt_poll_vote(
selected_option_hashes: &[Vec<u8>],
encryption_key: &[u8; 32],
stanza_id: &str,
voter_jid: &str,
) -> Result<(Vec<u8>, [u8; GCM_IV_SIZE])> {
use crate::libsignal::crypto::aes_256_gcm_encrypt;
use prost::Message;
use rand::Rng;
let vote_msg = waproto::whatsapp::message::PollVoteMessage {
selected_options: selected_option_hashes.to_vec(),
};
let mut plaintext = Vec::new();
vote_msg.encode(&mut plaintext)?;
let mut iv = [0u8; GCM_IV_SIZE];
rand::make_rng::<rand::rngs::StdRng>().fill_bytes(&mut iv);
let aad = build_aad(&poll_vote_addon_ctx(stanza_id, "", voter_jid));
let mut payload = Vec::with_capacity(plaintext.len() + GCM_TAG_SIZE);
aes_256_gcm_encrypt(encryption_key, &iv, &aad, &plaintext, &mut payload)
.map_err(|e| anyhow!("AES-GCM encrypt failed: {e}"))?;
Ok((payload, iv))
}
pub fn encrypt_poll_vote_with_secret(
selected_option_hashes: &[Vec<u8>],
message_secret: &[u8],
stanza_id: &str,
poll_creator_jid: &str,
voter_jid: &str,
) -> Result<(Vec<u8>, [u8; GCM_IV_SIZE])> {
use prost::Message;
let vote_msg = waproto::whatsapp::message::PollVoteMessage {
selected_options: selected_option_hashes.to_vec(),
};
let mut plaintext = Vec::new();
vote_msg.encode(&mut plaintext)?;
encrypt_addon(
&plaintext,
message_secret,
&poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid),
)
}
pub fn decrypt_poll_vote(
enc_payload: &[u8],
iv: &[u8],
encryption_key: &[u8; 32],
stanza_id: &str,
voter_jid: &str,
) -> Result<Vec<Vec<u8>>> {
use crate::libsignal::crypto::aes_256_gcm_decrypt;
use prost::Message as _;
let nonce: &[u8; GCM_IV_SIZE] = iv
.try_into()
.map_err(|_| anyhow!("Invalid IV size: expected {GCM_IV_SIZE}, got {}", iv.len()))?;
if enc_payload.len() < GCM_TAG_SIZE {
return Err(anyhow!(
"Encrypted payload too short: need at least {GCM_TAG_SIZE} bytes for tag, got {}",
enc_payload.len()
));
}
let aad = build_aad(&poll_vote_addon_ctx(stanza_id, "", voter_jid));
let mut plaintext = Vec::with_capacity(enc_payload.len().saturating_sub(GCM_TAG_SIZE));
aes_256_gcm_decrypt(encryption_key, nonce, &aad, enc_payload, &mut plaintext)
.map_err(|_| anyhow!("Poll vote GCM tag verification failed"))?;
let vote_msg = waproto::whatsapp::message::PollVoteMessage::decode(&plaintext[..])?;
Ok(vote_msg.selected_options)
}
pub fn decrypt_poll_vote_with_secret(
enc_payload: &[u8],
iv: &[u8],
message_secret: &[u8],
stanza_id: &str,
poll_creator_jid: &str,
voter_jid: &str,
) -> Result<Vec<Vec<u8>>> {
use prost::Message as _;
let plaintext = decrypt_addon(
enc_payload,
iv,
message_secret,
&poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid),
)?;
let vote_msg = waproto::whatsapp::message::PollVoteMessage::decode(&plaintext[..])?;
Ok(vote_msg.selected_options)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn option_hash_deterministic() {
let h1 = compute_option_hash("Option A");
let h2 = compute_option_hash("Option A");
let h3 = compute_option_hash("Option B");
assert_eq!(h1, h2);
assert_ne!(h1, h3);
assert_eq!(h1.len(), 32);
}
#[test]
fn vote_encrypt_decrypt_roundtrip() {
let secret = [0xCDu8; 32];
let stanza_id = "3EB0ABCD1234";
let creator = "creator@s.whatsapp.net";
let voter = "voter@s.whatsapp.net";
let hashes = vec![
compute_option_hash("Yes").to_vec(),
compute_option_hash("No").to_vec(),
];
let (enc, iv) =
encrypt_poll_vote_with_secret(&hashes, &secret, stanza_id, creator, voter).unwrap();
let out =
decrypt_poll_vote_with_secret(&enc, &iv, &secret, stanza_id, creator, voter).unwrap();
assert_eq!(out, hashes);
}
#[test]
fn legacy_decrypt_path_still_works() {
let secret = [0xCDu8; 32];
let stanza_id = "3EB0ABCD1234";
let creator = "creator@s.whatsapp.net";
let voter = "voter@s.whatsapp.net";
let hashes = vec![compute_option_hash("Yes").to_vec()];
let (enc, iv) =
encrypt_poll_vote_with_secret(&hashes, &secret, stanza_id, creator, voter).unwrap();
let key = derive_vote_encryption_key(&secret, stanza_id, creator, voter).unwrap();
let out = decrypt_poll_vote(&enc, &iv, &key, stanza_id, voter).unwrap();
assert_eq!(out, hashes);
}
#[test]
fn wrong_voter_fails() {
let secret = [0xEFu8; 32];
let (enc, iv) = encrypt_poll_vote_with_secret(
&[compute_option_hash("Yes").to_vec()],
&secret,
"id",
"c@s.whatsapp.net",
"v@s.whatsapp.net",
)
.unwrap();
assert!(
decrypt_poll_vote_with_secret(
&enc,
&iv,
&secret,
"id",
"c@s.whatsapp.net",
"wrong@s.whatsapp.net"
)
.is_err()
);
}
#[test]
fn empty_vote_roundtrip() {
let secret = [0xEFu8; 32];
let (enc, iv) = encrypt_poll_vote_with_secret(
&[],
&secret,
"id",
"c@s.whatsapp.net",
"v@s.whatsapp.net",
)
.unwrap();
let out = decrypt_poll_vote_with_secret(
&enc,
&iv,
&secret,
"id",
"c@s.whatsapp.net",
"v@s.whatsapp.net",
)
.unwrap();
assert!(out.is_empty());
}
}