diff --git a/wacore/appstate/benches/appstate_benchmark.rs b/wacore/appstate/benches/appstate_benchmark.rs index ac9652df5..1b6df9861 100644 --- a/wacore/appstate/benches/appstate_benchmark.rs +++ b/wacore/appstate/benches/appstate_benchmark.rs @@ -7,7 +7,8 @@ use divan::black_box; use std::collections::HashMap; use std::sync::Arc; use wacore_appstate::{ - WAPATCH_INTEGRITY, encode_record, expand_app_state_keys, hash::HashState, process_patch, + WAPATCH_INTEGRITY, decode_record, encode_record, expand_app_state_keys, hash::HashState, + process_patch, }; use waproto::whatsapp as wa; @@ -140,3 +141,79 @@ fn bench_process_patch_50_validated(bencher: divan::Bencher) { ); }); } + +/// One encrypted app-state record built the way the server delivers it, so +/// `decode_record` runs its full inbound cost: AES-256-CBC decrypt, the content +/// HMAC-SHA512 and index HMAC-SHA256 validation, the prost decode of the action +/// value, and the serde_json parse of the index array. This runs once per +/// mutation, up to ~1000× per resume patch; `process_patch` covers the whole +/// loop but never isolates this inner per-record cost. +fn setup_record( + shape: &str, +) -> ( + wa::SyncdRecord, + wacore_appstate::ExpandedAppStateKeys, + Vec, +) { + let keys = expand_app_state_keys(&[0x07u8; 32]); + let key_id = b"AAAA".to_vec(); + let (index, value) = match shape { + // A starred-message mutation: 5-part index (the real STAR schema), + // tiny action value. + "star" => ( + "[\"star\",\"5511000000000@s.whatsapp.net\",\"3EB0123456789ABCDEF01234\",\"1\",\"0\"]" + .to_string(), + wa::SyncActionValue { + timestamp: Some(1_700_000_000), + star_action: Some(wa::sync_action_value::StarAction { + starred: Some(true), + }), + ..Default::default() + }, + ), + // A contact mutation: longer index plus a name-carrying value, the + // larger-payload end of the AES/HMAC cost. + "contact" => ( + "[\"contact\",\"5511999998888@s.whatsapp.net\"]".to_string(), + wa::SyncActionValue { + timestamp: Some(1_700_000_000), + contact_action: Some(wa::sync_action_value::ContactAction { + full_name: Some("Benchmark Contact Full Name".to_string()), + first_name: Some("Benchmark".to_string()), + ..Default::default() + }), + ..Default::default() + }, + ), + other => unreachable!("unknown shape {other}"), + }; + let (mutation, _value_mac) = encode_record( + wa::syncd_mutation::SyncdOperation::Set, + index.as_bytes(), + &value, + &keys, + &key_id, + &[0x11u8; 16], + 1, + ); + let record = mutation.record.expect("encoded record"); + (record, keys, key_id) +} + +#[divan::bench(args = ["star", "contact"])] +fn bench_decode_record(bencher: divan::Bencher, shape: &str) { + bencher + .with_inputs(|| setup_record(shape)) + .bench_refs(|(record, keys, key_id)| { + black_box( + decode_record( + wa::syncd_mutation::SyncdOperation::Set, + black_box(record), + black_box(keys), + black_box(key_id), + true, + ) + .unwrap(), + ) + }); +} diff --git a/wacore/benches/message_utils_benchmark.rs b/wacore/benches/message_utils_benchmark.rs index 8d16a9bc5..ef5ef125f 100644 --- a/wacore/benches/message_utils_benchmark.rs +++ b/wacore/benches/message_utils_benchmark.rs @@ -173,3 +173,96 @@ fn bench_dm_send_encode_work(bencher: divan::Bencher, shape: &str) { black_box((plaintexts, retry_bytes, reporting_result)) }); } + +/// A `` stanza with the attributes each inbound shape carries, so +/// `parse_message_info` exercises every arm of its addressing-mode dispatch. +/// Each carries an `` child like every real message, so the +/// `get_optional_child` scans run instead of returning instant-None. +fn msg_info_node(shape: &str) -> wacore_binary::node::Node { + use wacore_binary::builder::NodeBuilder; + let enc = || { + NodeBuilder::new("enc") + .attr("type", "msg") + .attr("v", "2") + .bytes(vec![0u8; 64]) + .build() + }; + match shape { + // Plain 1:1 DM from a PN peer; sender_lid carries the LID fallback a + // LID-migrated peer sends, exercising the self/other-DM sender_alt arm. + "dm_pn" => NodeBuilder::new("message") + .attr("from", "5511888887777@s.whatsapp.net") + .attr("type", "text") + .attr("id", "3EB0BENCH000000000001") + .attr("t", "1777415965") + .attr("notify", "Bench Peer") + .attr("sender_lid", "100000012345678@lid") + .children([enc()]) + .build(), + // LID-addressed group message: participant is a LID JID, participant_pn + // carries the phone fallback the LID-PN cache re-warms from. + "group_lid" => NodeBuilder::new("message") + .attr("from", "120363000000000001@g.us") + .attr("type", "text") + .attr("id", "3EB0BENCH000000000002") + .attr("t", "1777415965") + .attr("addressing_mode", "lid") + .attr("participant", "100000012345678@lid") + .attr("participant_pn", "5511888887777@s.whatsapp.net") + .attr("notify", "Bench Member") + .children([enc()]) + .build(), + // Status broadcast: from=status@broadcast, participant is the author, + // participant_lid warms the LID-PN cache. + "status_broadcast" => NodeBuilder::new("message") + .attr("from", "status@broadcast") + .attr("type", "media") + .attr("id", "3EB0BENCH000000000003") + .attr("t", "1777415965") + .attr("participant", "5511999990001@s.whatsapp.net") + .attr("participant_lid", "100000011111111@lid") + .children([enc()]) + .build(), + // Self-sent message (from == own JID): recipient drives chat resolution. + "self_sent" => NodeBuilder::new("message") + .attr("from", "5511999990000:7@s.whatsapp.net") + .attr("recipient", "5511888887777@s.whatsapp.net") + .attr("type", "text") + .attr("id", "3EB0BENCH000000000004") + .attr("t", "1777415965") + .children([enc()]) + .build(), + other => unreachable!("unknown shape {other}"), + } +} + +/// Stanza-to-MessageInfo parse: the metadata extraction that runs once per +/// inbound message before any Signal work. Exercises typed-JID attribute +/// materialization and the addressing-mode dispatch. The input is a marshal +/// round-trip decoded back into an `OwnedNodeRef` (untimed in `with_inputs`), +/// so attributes arrive wire-typed (`ValueRef::Jid`) exactly as the decoder +/// hands them to the receive path — not string-parsed, which production never +/// does. Only the parse is measured; the whole `MessageInfo` is observed. +#[divan::bench(args = ["dm_pn", "group_lid", "status_broadcast", "self_sent"])] +fn bench_parse_message_info(bencher: divan::Bencher, shape: &str) { + use wacore_binary::node::OwnedNodeRef; + let own_jid: Jid = "5511999990000@s.whatsapp.net".parse().unwrap(); + let own_lid: Jid = "100000000000000@lid".parse().unwrap(); + + bencher + .with_inputs(|| { + let bytes = wacore_binary::marshal::marshal(&msg_info_node(shape)).unwrap(); + // marshal prefixes a flag byte; the receive path strips it in unpack. + OwnedNodeRef::new(bytes[1..].to_vec()).unwrap() + }) + .bench_refs(|owned| { + black_box( + wacore::messages::parse_message_info( + black_box(owned.get()), + &own_jid, + Some(&own_lid), + ) + .unwrap(), + ) + }); +} diff --git a/wacore/libsignal/benches/libsignal_benchmark.rs b/wacore/libsignal/benches/libsignal_benchmark.rs index cd84602af..9187ed1e0 100644 --- a/wacore/libsignal/benches/libsignal_benchmark.rs +++ b/wacore/libsignal/benches/libsignal_benchmark.rs @@ -9,11 +9,11 @@ fn main() { use wacore_libsignal::protocol::{ ChainKey, CiphertextMessage, Direction, GenericSignedPreKey, IdentityChange, IdentityKey, IdentityKeyPair, IdentityKeyStore, KeyPair, MessageKeyGenerator, PreKeyBundle, PreKeyId, - PreKeyRecord, PreKeyStore, ProtocolAddress, RootKey, SenderKeyRecord, SenderKeyStore, - SessionRecord, SessionState, SessionStore, SignedPreKeyId, SignedPreKeyRecord, - SignedPreKeyStore, Timestamp, UsePQRatchet, consts, create_sender_key_distribution_message, - group_decrypt, group_encrypt, message_decrypt, message_encrypt, process_prekey_bundle, - process_sender_key_distribution_message, + PreKeyRecord, PreKeyStore, ProtocolAddress, RootKey, SenderKeyDistributionMessage, + SenderKeyRecord, SenderKeyStore, SessionRecord, SessionState, SessionStore, SignedPreKeyId, + SignedPreKeyRecord, SignedPreKeyStore, Timestamp, UsePQRatchet, consts, + create_sender_key_distribution_message, group_decrypt, group_encrypt, message_decrypt, + message_encrypt, process_prekey_bundle, process_sender_key_distribution_message, }; use wacore_libsignal::store::sender_key_name::SenderKeyName; @@ -1284,3 +1284,125 @@ fn bench_message_key_eviction(bencher: divan::Bencher) { black_box(state); }); } + +/// Worst-case group out-of-order decrypt: a receiver that fell behind by ~2000 +/// messages buffers that many skipped sender-message-keys, and each late +/// message consumes one via `SenderKeyState::remove_sender_message_key`, an +/// O(n) linear scan over the backlog (gap-analysis data-structures-22). The +/// expensive backlog fill runs once per iteration in `with_inputs` (untimed); +/// only the worst-case decrypt — the one whose key sits at the tail of the +/// backlog, forcing a full scan — is measured. +/// +/// This is the full out-of-order decrypt, not a scan-isolating microbenchmark: +/// the backlog-sized `SenderKeyRecord` clone in `load_sender_key` and the +/// signature check are the bulk of it, with the linear scan a smaller slice. Use +/// it as the out-of-order decrypt baseline; the clone is itself backlog-proportional. +fn setup_group_out_of_order_worst_case() -> (User, SenderKeyName, Vec) { + // A fill just under MAX_MESSAGE_KEYS: eviction only starts past + // MAX_MESSAGE_KEYS + MESSAGE_KEY_PRUNE_THRESHOLD, so the whole backlog + // survives intact for the scan. + const N: u32 = (consts::MAX_MESSAGE_KEYS - 1) as u32; + + let (mut alice, mut bob, sender_key_name) = setup_group_with_distribution(); + + let bob_sender_key_name = SenderKeyName::new( + sender_key_name.group_id().to_string(), + alice.address.name().to_string(), + ); + + let worst_case_ct = futures::executor::block_on(async { + let mut rng = rand::make_rng::(); + let mut ciphertexts: Vec> = Vec::with_capacity((N + 1) as usize); + for i in 0..=N { + let skm = group_encrypt( + &mut alice.sender_key_store, + &sender_key_name, + format!("group msg {i}").as_bytes(), + &mut rng, + ) + .await + .expect("group encrypt"); + ciphertexts.push(skm.serialized().to_vec()); + } + + // Decrypting the latest message first ratchets the chain forward over + // iterations 0..N-1, buffering N skipped keys in arrival order. + group_decrypt( + &ciphertexts[N as usize], + &mut bob.sender_key_store, + &bob_sender_key_name, + ) + .await + .expect("group decrypt newest"); + + // Iteration N-1 sits at the tail of the buffer: its lookup scans every + // entry, the worst case for the linear `position()`. + ciphertexts[(N - 1) as usize].clone() + }); + + (bob, bob_sender_key_name, worst_case_ct) +} + +#[divan::bench(sample_count = 30)] +fn bench_group_out_of_order_decrypt_worst_case(bencher: divan::Bencher) { + bencher + .with_inputs(setup_group_out_of_order_worst_case) + .bench_refs(|(bob, sender_key_name, ciphertext)| { + let plaintext = futures::executor::block_on(async { + group_decrypt( + black_box(ciphertext.as_slice()), + &mut bob.sender_key_store, + sender_key_name, + ) + .await + .expect("group decrypt worst case") + }); + black_box(plaintext); + }); +} + +/// SKDM ingest: installing a sender's distribution message into a fresh +/// receiver store, the work done on the first group message from each new +/// sender and on every sender-key rotation. The SKDM build and the fresh empty +/// store are prepared in `with_inputs`; only the ingest is measured. +fn setup_skdm_ingest() -> ( + SenderKeyDistributionMessage, + InMemorySenderKeyStore, + SenderKeyName, +) { + let (mut alice, sender_key_name) = setup_group_sender(); + let receiver_key_name = SenderKeyName::new( + sender_key_name.group_id().to_string(), + alice.address.name().to_string(), + ); + let skdm = futures::executor::block_on(async { + let mut rng = rand::make_rng::(); + create_sender_key_distribution_message( + &sender_key_name, + &mut alice.sender_key_store, + &mut rng, + ) + .await + .expect("skdm") + }); + (skdm, InMemorySenderKeyStore::new(), receiver_key_name) +} + +#[divan::bench] +fn bench_process_sender_key_distribution_message(bencher: divan::Bencher) { + bencher.with_inputs(setup_skdm_ingest).bench_refs( + |(skdm, receiver_store, receiver_key_name)| { + futures::executor::block_on(async { + process_sender_key_distribution_message( + receiver_key_name, + black_box(skdm), + receiver_store, + ) + .await + .expect("process skdm") + }); + // Observe the store so the store_sender_key write is not elided. + black_box(&*receiver_store); + }, + ); +}