From ab74fdbc01bebb6139ed31646b43de5c43985da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:20:44 -0300 Subject: [PATCH 1/2] test(signal): add durability chaos coverage --- .../workflows/signal-durability-nightly.yml | 80 ++ AGENTS.md | 1 + agent_docs/signal_durability.md | 134 ++++ tests/signal_durability_sqlite.rs | 253 ++++++ wacore/Cargo.toml | 2 +- wacore/src/store/signal_cache.rs | 4 + .../store/signal_cache_durability_chaos.rs | 729 ++++++++++++++++++ 7 files changed, 1202 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/signal-durability-nightly.yml create mode 100644 agent_docs/signal_durability.md create mode 100644 tests/signal_durability_sqlite.rs create mode 100644 wacore/src/store/signal_cache_durability_chaos.rs diff --git a/.github/workflows/signal-durability-nightly.yml b/.github/workflows/signal-durability-nightly.yml new file mode 100644 index 000000000..3a968931a --- /dev/null +++ b/.github/workflows/signal-durability-nightly.yml @@ -0,0 +1,80 @@ +name: Signal Durability Nightly + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + seed: + description: First deterministic seed (decimal or 0x-prefixed) + required: false + default: '0x51A6DA7AB1E50001' + seeds: + description: Number of seeds + required: false + default: '128' + steps: + description: Actions per seed + required: false + default: '256' + +permissions: + contents: read + +concurrency: + group: signal-durability-nightly + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + PROTOC_VERSION: '3.25.3' + SCCACHE_GHA_ENABLED: 'true' + RUSTC_WRAPPER: sccache + CARGO_INCREMENTAL: '0' + +jobs: + durability: + name: Signal durability chaos + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Reclaim unused Android SDK space + run: | + df -h / + sudo rm -rf --one-file-system /usr/local/lib/android + df -h / + - uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-06-16 + - name: Install protoc + uses: taiki-e/install-action@v2 + with: + tool: protoc@${{ env.PROTOC_VERSION }} + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.10 + - name: Cache Rust build (registry + target) + uses: Swatinem/rust-cache@v2 + with: + cache-targets: 'true' + - name: Run deterministic DM and group matrix + env: + SIGNAL_CHAOS_SEED: ${{ inputs.seed || '0x51A6DA7AB1E50001' }} + SIGNAL_CHAOS_SEEDS: ${{ inputs.seeds || '128' }} + SIGNAL_CHAOS_STEPS: ${{ inputs.steps || '256' }} + run: >- + cargo test -p wacore --lib signal_durability_chaos_nightly -- + --ignored --nocapture + - name: Run abrupt SQLite restart test + run: >- + cargo test -p whatsapp-rust --test signal_durability_sqlite + signal_durability_sqlite_process_restart -- + --ignored --exact --nocapture + - name: Preserve failed SQLite fixture + if: failure() + uses: actions/upload-artifact@v4 + with: + name: signal-durability-sqlite-${{ github.run_id }} + path: /tmp/whatsapp-rust-signal-durability-*.db* + if-no-files-found: ignore + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 52eac20f1..b17d5c72d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,5 +44,6 @@ Read these when working on the relevant area: - `agent_docs/debugging.md` — evcxr REPL, binary protocol debugging - `agent_docs/binary_size_ci.md` — size-tracking CI: metrics, budgets, baseline semantics - `agent_docs/observability.md` — per-session stats (I/O, memory report, TaskInstrument/CPU), design rules +- `agent_docs/signal_durability.md` — Signal counter leases, pre-wire gates, crash recovery, review checklist When adding comments to the code, dont be so verbose, also only explain why, not what diff --git a/agent_docs/signal_durability.md b/agent_docs/signal_durability.md new file mode 100644 index 000000000..393e2ed29 --- /dev/null +++ b/agent_docs/signal_durability.md @@ -0,0 +1,134 @@ +# Signal durability + +This document is the checklist for code that reads, mutates, persists, or sends +Signal state. The security property is simple: an outbound message key and IV +must never reach the wire twice, including after cancellation, storage failure, +reconnect, or process death. + +## State and leases + +DM sessions and group sender keys use the same durability scheme: + +| State | Counter | Cache gate | +| --- | --- | --- | +| `SessionRecord` | `reserved_sender_chain_index` | `reservation_pending` | +| `SenderKeyRecord` | `reserved_iteration` | `wire_gate_pending` | + +The reservation is an exclusive upper bound. A send below it is covered by a +previously persisted lease and can use write-behind. A send at the bound raises +it by `SENDER_CHAIN_RESERVATION_BATCH` and must wait for a successful durable +flush before its ciphertext is published. + +The cache takes ownership of transient record gates. A failed write, a checked +out record skipped by a flush, or a tombstone whose delete failed must remain +gated. Only the backend operation that persisted that address may release it. +Decrypt-side advances are dirty but not pre-wire gated: they can be derived +forward again after a crash. + +Stored records carry the cache incarnation that wrote them: + +- A reload in the same live cache is exact. Eviction and `clear_after_flush()` + must not burn the unused part of a lease. +- A new process or lossy cache reset has a new incarnation. It fast-forwards to + the stored reservation because any counter below it may already have been + published. +- Stores that bypass `SignalStoreCache` cannot claim an exact reload. They must + use the incarnation-aware record format or conservatively recover as a new + incarnation. + +The current batch is 64 while the peer forward-jump limit is 2000. That makes a +single crash gap at most 3.2% of the receiver limit and amortizes a monotonic +sender chain to one synchronous reservation write per 64 messages. Tune this +only with sender-chain run-length and restart data; transport stanza counts do +not expose Signal iterations. Keep the batch well below `MAX_FORWARD_JUMPS` and +run the recovery matrix after any change. + +Real crash/send cycles can accumulate burned ranges for a receiver that misses +every intervening message. Crossing its forward-jump bound is recoverable via +the retry/SKDM path, but clean cache reloads must never contribute to that gap. + +## Publication boundary + +All ciphertext APIs follow this order: + +1. Load the record for mutation under its per-address or per-sender-key lock. +2. Derive the message key, advance the chain, and return the record to the + cache even if the future can be cancelled. +3. If the advance raised a lease, let the cache adopt its transient gate. +4. Call `persist_signal_state_pre_wire()` after every recipient has been + encrypted and before handing the stanza to the transport. +5. Abort the send if that flush fails. + +The predicate is intentionally global. A pending lease for one address can +force another send to flush, but no call site can accidentally omit an address +whose ciphertext is already part of the stanza. + +Do not replace the batch-safe flush with a raw cache flush. During offline +drain, inbound rows must become durable before their ratchet advances; otherwise +a crash can turn redelivery into an acknowledged duplicate and lose the event. + +## Cancellation, deletion, and teardown + +`SessionCheckout` owns the only mutable copy while a session operation is in +flight. Its drop path returns the advanced record synchronously or queues the +restore if the cache lock is contended. A checkout token and recovery generation +prevent a stale owner from overwriting a delete, newer owner, or lossy reset. + +Deletion is durable state. A session or sender-key tombstone must retain any +existing pre-wire gate until the backend delete succeeds. A consumed one-time +prekey is deleted only after its promoted session is durable, so a crash cannot +lose both recovery inputs. + +Clean reconnect teardown flushes and then calls `clear_after_flush()`. Dirty +state from a failed final flush stays resident for the next attempt. `clear()` +is lossy: it changes the incarnation and is only valid when the corresponding +uncommitted inbound work is also dropped so the server can redeliver it. + +## Review checklist + +For any new or changed ciphertext path, verify: + +- The chain mutation is returned to the cache across every error and + cancellation edge. +- A newly raised lease reaches durable storage before any ciphertext derived + from it reaches the transport. +- A failed flush aborts publication and leaves both dirty state and gate intact. +- Covered sends avoid synchronous storage without skipping eventual + write-behind. +- Clean eviction/reload preserves the exact counter; crash reload burns to the + exclusive reservation ceiling. +- Deletes cannot be undone by a stale checkout or in-flight sender-key writer. +- Lock ordering matches existing session, sender-key, inbound-drain, and cache + ordering; no backend await is added under an unrelated device lock. +- Tests use fictitious JIDs and never log key material, plaintext, or production + identifiers. + +## Verification + +Focused unit tests live beside `SignalStoreCache`, record serialization, and +the libsignal ciphers. The deterministic state machine combines DM and group +sends, failed writes, cancellation, checkout/flush overlap, tombstones, clean +reloads, lossy clears, crash recovery, out-of-order group delivery, and retry +redistribution. + +```bash +# Small matrix in normal CI +cargo test -p wacore signal_durability_chaos_smoke + +# Nightly-sized local run +SIGNAL_CHAOS_SEEDS=128 SIGNAL_CHAOS_STEPS=256 \ + cargo test -p wacore --lib signal_durability_chaos_nightly -- --ignored --nocapture + +# Replay the seed printed by a failure +SIGNAL_CHAOS_SEED=0x... SIGNAL_CHAOS_SEEDS=1 SIGNAL_CHAOS_STEPS=256 \ + cargo test -p wacore --lib signal_durability_chaos_nightly -- --ignored --nocapture + +# Real SQLite database across a SIGKILL and restart on Unix +cargo test -p whatsapp-rust --test signal_durability_sqlite \ + signal_durability_sqlite_process_restart -- --ignored --exact --nocapture +``` + +The scheduled workflow runs the large state-machine matrix and the SQLite +subprocess test. A state-machine failure reports its replay seed, step, and +action; a failed SQLite job retains its synthetic database as a short-lived CI +artifact. diff --git a/tests/signal_durability_sqlite.rs b/tests/signal_durability_sqlite.rs new file mode 100644 index 000000000..0c4968428 --- /dev/null +++ b/tests/signal_durability_sqlite.rs @@ -0,0 +1,253 @@ +#![cfg(feature = "sqlite-storage")] + +use std::path::Path; +use std::process::Command; + +use rand::SeedableRng; +use wacore::libsignal::protocol::consts::SENDER_CHAIN_RESERVATION_BATCH; +use wacore::libsignal::protocol::{ + ChainKey, IdentityKey, KeyPair, RootKey, SenderKeyRecord, SenderKeyStore, SessionRecord, + SessionState, create_sender_key_distribution_message, group_encrypt, +}; +use wacore::libsignal::store::sender_key_name::SenderKeyName; +use whatsapp_rust::store::SqliteStore; +use whatsapp_rust::store::signal_cache::SignalStoreCache; + +const CHILD_MARKER: &str = "SIGNAL_DURABILITY_CRASH_CHILD"; +const DATABASE_ENV: &str = "SIGNAL_DURABILITY_DATABASE"; +#[cfg(not(unix))] +const CHILD_EXIT_CODE: i32 = 91; +const FIXTURE_SEED: u64 = 0x51A6_5A17_EC4A_5E01; + +type DmFingerprint = ([u8; 32], [u8; 16]); + +struct CachedSenderKeyStore<'a> { + cache: &'a SignalStoreCache, + backend: &'a SqliteStore, +} + +#[async_trait::async_trait] +impl SenderKeyStore for CachedSenderKeyStore<'_> { + async fn store_sender_key( + &mut self, + name: &SenderKeyName, + record: SenderKeyRecord, + ) -> wacore::libsignal::protocol::error::Result<()> { + self.cache.put_sender_key(name, record).await; + Ok(()) + } + + async fn load_sender_key( + &self, + name: &SenderKeyName, + ) -> wacore::libsignal::protocol::error::Result> { + Ok(self + .cache + .get_sender_key(name, self.backend) + .await + .map_err(|error| { + wacore::libsignal::protocol::SignalProtocolError::BackendError( + "SQLite durability test", + error.into(), + ) + })? + .map(|record| (*record).clone())) + } +} + +fn dm_address() -> wacore::libsignal::protocol::ProtocolAddress { + wacore::libsignal::protocol::ProtocolAddress::new("15550008001".to_string(), 1.into()) +} + +fn group_name() -> SenderKeyName { + SenderKeyName::from_parts("120363000000080001@g.us", "15550008002@s.whatsapp.net:0") +} + +fn fresh_session(rng: &mut rand::rngs::StdRng) -> SessionRecord { + let local = IdentityKey::new(KeyPair::generate(rng).public_key); + let remote = IdentityKey::new(KeyPair::generate(rng).public_key); + let base_key = KeyPair::generate(rng).public_key; + let mut state = SessionState::new(3, &local, &remote, &RootKey::new([7; 32]), &base_key); + state.set_sender_chain(&KeyPair::generate(rng), &ChainKey::new([11; 32], 0)); + SessionRecord::new(state) +} + +fn spend_dm(record: &mut SessionRecord) -> (u32, DmFingerprint) { + let chain = record + .session_state() + .expect("session state") + .get_sender_chain_key() + .expect("sender chain"); + let keys = chain.message_keys().generate_keys(); + let fingerprint = (*keys.cipher_key(), *keys.iv()); + let next = chain.next_chain_key().expect("next chain key"); + record + .session_state_mut() + .expect("session state") + .set_sender_chain_key(&next) + .expect("sender chain update"); + if chain.index() >= record.reserved_sender_chain_index() { + record.reserve_sender_chain_counters(chain.index()); + } + (chain.index(), fingerprint) +} + +async fn crash_child(database: &str) -> ! { + let store = SqliteStore::new(database).await.expect("SQLite store"); + let cache = SignalStoreCache::new(); + let address = dm_address(); + let name = group_name(); + let mut rng = rand::rngs::StdRng::seed_from_u64(FIXTURE_SEED); + + let mut dm = fresh_session(&mut rng); + assert_eq!(spend_dm(&mut dm).0, 0); + cache.put_session(&address, dm).await; + + let mut sender = CachedSenderKeyStore { + cache: &cache, + backend: &store, + }; + create_sender_key_distribution_message(&name, &mut sender, &mut rng) + .await + .expect("sender-key setup"); + let first = group_encrypt(&mut sender, &name, b"first", &mut rng) + .await + .expect("first group encrypt"); + assert_eq!(first.iteration(), 0); + + cache.flush(&store).await.expect("durable lease flush"); + assert!(!cache.needs_pre_wire_flush().await); + + let mut dm = cache + .get_session(&address, &store) + .await + .expect("session load") + .expect("session"); + for expected in 1..=5 { + assert_eq!(spend_dm(&mut dm).0, expected); + } + cache.put_session(&address, dm).await; + for expected in 1..=5 { + let message = group_encrypt(&mut sender, &name, b"unflushed", &mut rng) + .await + .expect("group encrypt"); + assert_eq!(message.iteration(), expected); + } + + crash_now() +} + +#[cfg(unix)] +fn crash_now() -> ! { + // SIGKILL prevents process-exit hooks from making the fixture cleaner than a real crash. + let result = unsafe { libc::raise(libc::SIGKILL) }; + panic!("SIGKILL failed with {result}") +} + +#[cfg(not(unix))] +fn crash_now() -> ! { + std::process::exit(CHILD_EXIT_CODE) +} + +async fn verify_recovery(database: &str) { + let store = SqliteStore::new(database) + .await + .expect("reopen SQLite store"); + let cache = SignalStoreCache::new(); + let address = dm_address(); + let name = group_name(); + let mut rng = rand::rngs::StdRng::seed_from_u64(FIXTURE_SEED ^ 0xFFFF); + + let mut child_record = fresh_session(&mut rand::rngs::StdRng::seed_from_u64(FIXTURE_SEED)); + let mut child_fingerprints = Vec::with_capacity(6); + for _ in 0..6 { + child_fingerprints.push(spend_dm(&mut child_record).1); + } + + let mut recovered = cache + .get_session(&address, &store) + .await + .expect("recovery load") + .expect("durable session"); + let (counter, fingerprint) = spend_dm(&mut recovered); + assert_eq!(counter, SENDER_CHAIN_RESERVATION_BATCH); + assert!(!child_fingerprints.contains(&fingerprint)); + cache.put_session(&address, recovered).await; + + let mut sender = CachedSenderKeyStore { + cache: &cache, + backend: &store, + }; + let recovered_group = group_encrypt(&mut sender, &name, b"recovered", &mut rng) + .await + .expect("group recovery encrypt"); + assert_eq!(recovered_group.iteration(), SENDER_CHAIN_RESERVATION_BATCH); + assert!(cache.needs_pre_wire_flush().await); + cache.flush(&store).await.expect("recovery lease flush"); + assert!(!cache.needs_pre_wire_flush().await); + + cache.clear_after_flush().await; + let mut exact = cache + .get_session(&address, &store) + .await + .expect("clean reload") + .expect("durable session"); + assert_eq!(spend_dm(&mut exact).0, SENDER_CHAIN_RESERVATION_BATCH + 1); + cache.put_session(&address, exact).await; + let exact_group = group_encrypt(&mut sender, &name, b"exact", &mut rng) + .await + .expect("clean group reload"); + assert_eq!(exact_group.iteration(), SENDER_CHAIN_RESERVATION_BATCH + 1); + assert!(!cache.needs_pre_wire_flush().await); +} + +fn remove_database(path: &Path) { + let _ = std::fs::remove_file(path); + let base = path.as_os_str().to_string_lossy(); + let _ = std::fs::remove_file(format!("{base}-wal")); + let _ = std::fs::remove_file(format!("{base}-shm")); +} + +#[tokio::test] +#[ignore = "run as a subprocess by the SQLite durability test"] +async fn signal_durability_sqlite_crash_child() { + if std::env::var_os(CHILD_MARKER).is_none() { + return; + } + let database = std::env::var(DATABASE_ENV).expect("child database path"); + crash_child(&database).await; +} + +#[tokio::test] +#[ignore = "run by signal-durability-nightly.yml"] +async fn signal_durability_sqlite_process_restart() { + let database = std::env::temp_dir().join(format!( + "whatsapp-rust-signal-durability-{}.db", + uuid::Uuid::new_v4() + )); + let executable = std::env::current_exe().expect("current test executable"); + let child_database = database.clone(); + let status = tokio::task::spawn_blocking(move || { + Command::new(executable) + .arg("signal_durability_sqlite_crash_child") + .arg("--exact") + .arg("--ignored") + .arg("--nocapture") + .env(CHILD_MARKER, "1") + .env(DATABASE_ENV, child_database) + .status() + }) + .await + .expect("child task") + .expect("start child process"); + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + assert_eq!(status.signal(), Some(libc::SIGKILL)); + } + #[cfg(not(unix))] + assert_eq!(status.code(), Some(CHILD_EXIT_CODE)); + + verify_recovery(database.to_str().expect("UTF-8 database path")).await; + remove_database(&database); +} diff --git a/wacore/Cargo.toml b/wacore/Cargo.toml index 17126c5ba..6bba1afec 100644 --- a/wacore/Cargo.toml +++ b/wacore/Cargo.toml @@ -96,7 +96,7 @@ sha2 = { workspace = true } [dev-dependencies] divan = { workspace = true } futures = { workspace = true, features = ["executor", "thread-pool"] } -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt", "time"] } [[bench]] name = "reporting_token_benchmark" diff --git a/wacore/src/store/signal_cache.rs b/wacore/src/store/signal_cache.rs index 9c9e7c79e..979ef7211 100644 --- a/wacore/src/store/signal_cache.rs +++ b/wacore/src/store/signal_cache.rs @@ -3577,3 +3577,7 @@ mod pre_wire_gate_tests { assert!(!cache.needs_pre_wire_flush().await); } } + +#[cfg(test)] +#[path = "signal_cache_durability_chaos.rs"] +mod durability_chaos_tests; diff --git a/wacore/src/store/signal_cache_durability_chaos.rs b/wacore/src/store/signal_cache_durability_chaos.rs new file mode 100644 index 000000000..0a9d8faa0 --- /dev/null +++ b/wacore/src/store/signal_cache_durability_chaos.rs @@ -0,0 +1,729 @@ +use super::*; + +use std::collections::{HashSet, VecDeque}; +use std::time::Duration; + +use anyhow::{Context, ensure}; +use rand::{RngExt, SeedableRng}; + +use crate::libsignal::protocol::{ + ChainKey, CiphertextMessageType, IdentityKey, KeyPair, RootKey, SenderKeyMessage, + SenderKeyStore, SessionState, SignalProtocolError, create_sender_key_distribution_message, + group_decrypt, group_encrypt, process_sender_key_distribution_message, +}; +use crate::store::in_memory::InMemoryBackend; + +const SMOKE_SEEDS: usize = 4; +const SMOKE_STEPS: usize = 64; +const NIGHTLY_SEEDS: usize = 128; +const NIGHTLY_STEPS: usize = 256; +const DEFAULT_SEED: u64 = 0x51A6_DA7A_B1E5_0001; + +type DmFingerprint = ([u8; 32], [u8; 16]); +type GroupFingerprint = (u32, u32); + +struct CachedSenderKeyStore<'a> { + cache: &'a SignalStoreCache, + backend: &'a InMemoryBackend, +} + +#[async_trait::async_trait] +impl SenderKeyStore for CachedSenderKeyStore<'_> { + async fn store_sender_key( + &mut self, + name: &SenderKeyName, + record: SenderKeyRecord, + ) -> crate::libsignal::protocol::error::Result<()> { + self.cache.put_sender_key(name, record).await; + Ok(()) + } + + async fn load_sender_key( + &self, + name: &SenderKeyName, + ) -> crate::libsignal::protocol::error::Result> { + Ok(self + .cache + .get_sender_key(name, self.backend) + .await + .map_err(|error| { + crate::libsignal::protocol::SignalProtocolError::BackendError( + "durability chaos store", + error.into(), + ) + })? + .map(|record| (*record).clone())) + } +} + +#[derive(Clone, Copy, Debug)] +enum Action { + DmSend { fail_gate: bool }, + GroupSend { fail_gate: bool }, + DmCancel, + DeliverGroup { newest: bool }, + Flush, + FailPendingFlush, + CleanReload, + CrashReload, + LossyClear, + DeleteDm, + DeleteGroup, + CheckoutDuringFlush, +} + +#[derive(Clone, Copy)] +enum FlushFailure { + None, + Session, + SenderKey, +} + +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut value = self.0; + value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + value ^ (value >> 31) + } + + fn action(&mut self) -> Action { + match self.next() % 24 { + 0 => Action::DmSend { fail_gate: true }, + 1..=6 => Action::DmSend { fail_gate: false }, + 7 => Action::GroupSend { fail_gate: true }, + 8..=13 => Action::GroupSend { fail_gate: false }, + 14 => Action::DmCancel, + 15 => Action::DeliverGroup { + newest: self.next() & 1 == 0, + }, + 16 => Action::Flush, + 17 => Action::FailPendingFlush, + 18 => Action::CleanReload, + 19 => Action::CrashReload, + 20 => Action::LossyClear, + 21 => Action::DeleteDm, + 22 => Action::DeleteGroup, + 23 => Action::CheckoutDuringFlush, + _ => unreachable!(), + } + } +} + +struct ChaosHarness { + backend: InMemoryBackend, + cache: SignalStoreCache, + receiver_backend: InMemoryBackend, + receiver_cache: SignalStoreCache, + dm_address: ProtocolAddress, + group_name: SenderKeyName, + crypto_rng: rand::rngs::StdRng, + incarnation_generation: u64, + published_dm: HashSet, + published_group: HashSet, + pending_group: VecDeque, +} + +impl ChaosHarness { + async fn new(seed: u64) -> anyhow::Result { + let mut harness = Self { + backend: InMemoryBackend::new(), + cache: SignalStoreCache::with_max_entries_and_incarnation(32, incarnation(seed, 0)), + receiver_backend: InMemoryBackend::new(), + receiver_cache: SignalStoreCache::with_max_entries_and_incarnation( + 32, + incarnation(seed ^ 0xA5A5_A5A5_A5A5_A5A5, 0), + ), + dm_address: ProtocolAddress::new("15550007001".to_string(), 1.into()), + group_name: SenderKeyName::from_parts( + "120363000000070001@g.us", + "15550007002@s.whatsapp.net:0", + ), + crypto_rng: rand::rngs::StdRng::seed_from_u64(seed ^ 0xC4A5_5EED_5AFE_0001), + incarnation_generation: 0, + published_dm: HashSet::new(), + published_group: HashSet::new(), + pending_group: VecDeque::new(), + }; + + ensure!(harness.dm_send(false).await?, "initial DM send was gated"); + harness.sync_group_distribution().await?; + ensure!( + harness.group_send(false).await?, + "initial group send was gated" + ); + harness.deliver_group(false).await?; + harness.assert_invariants().await?; + Ok(harness) + } + + async fn apply(&mut self, action: Action) -> anyhow::Result<()> { + match action { + Action::DmSend { fail_gate } => { + self.dm_send(fail_gate).await?; + } + Action::GroupSend { fail_gate } => { + self.group_send(fail_gate).await?; + } + Action::DmCancel => self.dm_cancel().await?, + Action::DeliverGroup { newest } => self.deliver_group(newest).await?, + Action::Flush => self.flush_successfully().await?, + Action::FailPendingFlush => self.fail_pending_flush().await?, + Action::CleanReload => self.clean_reload().await?, + Action::CrashReload => self.crash_reload(), + Action::LossyClear => self.lossy_clear().await?, + Action::DeleteDm => self.cache.delete_session(&self.dm_address).await, + Action::DeleteGroup => { + self.cache + .delete_sender_key(self.group_name.cache_key()) + .await; + } + Action::CheckoutDuringFlush => self.checkout_during_flush().await?, + } + self.assert_invariants().await + } + + async fn dm_send(&mut self, fail_gate: bool) -> anyhow::Result { + let (record, checkout) = self + .cache + .checkout_session(&self.dm_address, &self.backend) + .await?; + let had_session = record.is_some(); + let mut record = record.unwrap_or_else(|| fresh_session(&mut self.crypto_rng)); + let chain = record + .session_state() + .context("DM session state missing")? + .get_sender_chain_key() + .map_err(|_| anyhow::anyhow!("DM sender chain missing"))?; + let keys = chain.message_keys().generate_keys(); + let fingerprint = (*keys.cipher_key(), *keys.iv()); + let next = chain.next_chain_key()?; + record + .session_state_mut() + .context("DM session state missing")? + .set_sender_chain_key(&next) + .map_err(|_| anyhow::anyhow!("DM sender chain update failed"))?; + if chain.index() >= record.reserved_sender_chain_index() { + record.reserve_sender_chain_counters(chain.index()); + } + self.commit_dm(record, checkout, had_session).await?; + + let published = self + .release_wire_gate(if fail_gate { + FlushFailure::Session + } else { + FlushFailure::None + }) + .await?; + if published { + ensure!( + self.published_dm.insert(fingerprint), + "DM key/IV was published twice at counter {}", + chain.index() + ); + } + Ok(published) + } + + async fn dm_cancel(&mut self) -> anyhow::Result<()> { + let (record, checkout) = self + .cache + .checkout_session(&self.dm_address, &self.backend) + .await?; + let Some(mut record) = record else { + self.cache + .cancel_session_checkout(&self.dm_address, checkout); + return Ok(()); + }; + let chain = record + .session_state() + .context("DM session state missing")? + .get_sender_chain_key() + .map_err(|_| anyhow::anyhow!("DM sender chain missing"))?; + let next = chain.next_chain_key()?; + record + .session_state_mut() + .context("DM session state missing")? + .set_sender_chain_key(&next) + .map_err(|_| anyhow::anyhow!("DM sender chain update failed"))?; + if chain.index() >= record.reserved_sender_chain_index() { + record.reserve_sender_chain_counters(chain.index()); + } + self.commit_dm(record, checkout, true).await + } + + async fn commit_dm( + &self, + record: SessionRecord, + checkout: SessionCheckoutKey, + had_session: bool, + ) -> anyhow::Result<()> { + match self.cache.restore_session_from_checkout( + &self.dm_address, + record, + checkout, + had_session, + ) { + SessionCheckoutStoreResult::Stored => Ok(()), + SessionCheckoutStoreResult::Pending(completion) => { + self.cache.complete_session_checkout().await; + ensure!( + completion.load(Ordering::Acquire), + "queued DM checkout restore was rejected" + ); + Ok(()) + } + SessionCheckoutStoreResult::Rejected => { + anyhow::bail!("DM checkout restore was rejected") + } + SessionCheckoutStoreResult::Unhandled(_) => { + anyhow::bail!("cache did not handle a DM checkout restore") + } + } + } + + async fn group_send(&mut self, fail_gate: bool) -> anyhow::Result { + if self + .cache + .get_sender_key(&self.group_name, &self.backend) + .await? + .is_none() + { + self.sync_group_distribution().await?; + } + let message = { + let mut sender = CachedSenderKeyStore { + cache: &self.cache, + backend: &self.backend, + }; + group_encrypt( + &mut sender, + &self.group_name, + b"durability-chaos", + &mut self.crypto_rng, + ) + .await? + }; + let fingerprint = (message.chain_id(), message.iteration()); + let published = self + .release_wire_gate(if fail_gate { + FlushFailure::SenderKey + } else { + FlushFailure::None + }) + .await?; + if published { + ensure!( + self.published_group.insert(fingerprint), + "group chain/iteration was published twice: {fingerprint:?}" + ); + self.pending_group.push_back(message); + if self.pending_group.len() > 64 { + self.pending_group.pop_front(); + } + } + Ok(published) + } + + async fn sync_group_distribution(&mut self) -> anyhow::Result<()> { + let distribution = { + let mut sender = CachedSenderKeyStore { + cache: &self.cache, + backend: &self.backend, + }; + create_sender_key_distribution_message( + &self.group_name, + &mut sender, + &mut self.crypto_rng, + ) + .await? + }; + let mut receiver = CachedSenderKeyStore { + cache: &self.receiver_cache, + backend: &self.receiver_backend, + }; + process_sender_key_distribution_message(&self.group_name, &distribution, &mut receiver) + .await?; + self.pending_group.clear(); + Ok(()) + } + + async fn deliver_group(&mut self, newest: bool) -> anyhow::Result<()> { + let Some(message) = (if newest { + self.pending_group.pop_back() + } else { + self.pending_group.pop_front() + }) else { + return Ok(()); + }; + let mut receiver = CachedSenderKeyStore { + cache: &self.receiver_cache, + backend: &self.receiver_backend, + }; + match group_decrypt(message.serialized(), &mut receiver, &self.group_name).await { + Ok(_) => return Ok(()), + Err(SignalProtocolError::InvalidMessage( + CiphertextMessageType::SenderKey, + "message from too far into the future", + )) + | Err(SignalProtocolError::NoSenderKeyState(_)) => {} + Err(error) => return Err(error.into()), + } + + self.sync_group_distribution().await?; + ensure!( + self.group_send(false).await?, + "retry group message remained behind a durability gate" + ); + let retry = self + .pending_group + .pop_back() + .context("group retry message missing")?; + let mut receiver = CachedSenderKeyStore { + cache: &self.receiver_cache, + backend: &self.receiver_backend, + }; + let plaintext = group_decrypt(retry.serialized(), &mut receiver, &self.group_name).await?; + ensure!( + plaintext == b"durability-chaos", + "group retry decrypted the wrong plaintext" + ); + Ok(()) + } + + async fn release_wire_gate(&self, failure: FlushFailure) -> anyhow::Result { + if !self.cache.needs_pre_wire_flush().await { + return Ok(true); + } + self.backend + .set_fail_session_writes(matches!(failure, FlushFailure::Session)); + self.backend + .set_fail_sender_key_writes(matches!(failure, FlushFailure::SenderKey)); + let result = self.cache.flush(&self.backend).await; + self.backend.set_fail_session_writes(false); + self.backend.set_fail_sender_key_writes(false); + + if matches!(failure, FlushFailure::None) { + result?; + ensure!( + !self.cache.needs_pre_wire_flush().await, + "successful pre-wire flush left a gate pending" + ); + return Ok(true); + } + ensure!(result.is_err(), "injected pre-wire flush did not fail"); + ensure!( + self.cache.needs_pre_wire_flush().await, + "failed pre-wire flush released its gate" + ); + Ok(false) + } + + async fn fail_pending_flush(&self) -> anyhow::Result<()> { + let session_pending = !self + .cache + .lock_sessions() + .await + .reservation_pending + .is_empty(); + let sender_pending = !self + .cache + .sender_keys + .lock() + .await + .wire_gate_pending + .is_empty(); + let failure = if session_pending { + FlushFailure::Session + } else if sender_pending { + FlushFailure::SenderKey + } else { + return Ok(()); + }; + ensure!( + !self.release_wire_gate(failure).await?, + "injected flush unexpectedly released the wire" + ); + Ok(()) + } + + async fn flush_successfully(&self) -> anyhow::Result<()> { + self.cache.flush(&self.backend).await?; + ensure!( + !self.cache.needs_pre_wire_flush().await, + "successful background flush left a gate pending" + ); + Ok(()) + } + + async fn clean_reload(&self) -> anyhow::Result<()> { + let dm_before = self + .cache + .peek_session(&self.dm_address, &self.backend) + .await? + .map(|record| dm_chain_index(&record)) + .transpose()?; + let group_before = self + .cache + .get_sender_key(&self.group_name, &self.backend) + .await? + .map(|record| group_position(&record)) + .transpose()?; + + self.flush_successfully().await?; + self.cache.clear_after_flush().await; + + let dm_after = self + .cache + .peek_session(&self.dm_address, &self.backend) + .await? + .map(|record| dm_chain_index(&record)) + .transpose()?; + let group_after = self + .cache + .get_sender_key(&self.group_name, &self.backend) + .await? + .map(|record| group_position(&record)) + .transpose()?; + ensure!(dm_after == dm_before, "clean DM reload burned a lease"); + ensure!( + group_after == group_before, + "clean group reload burned a lease" + ); + Ok(()) + } + + fn crash_reload(&mut self) { + self.incarnation_generation = self.incarnation_generation.wrapping_add(1); + self.cache = SignalStoreCache::with_max_entries_and_incarnation( + 32, + incarnation(DEFAULT_SEED, self.incarnation_generation), + ); + } + + async fn lossy_clear(&mut self) -> anyhow::Result<()> { + let (record, checkout) = self + .cache + .checkout_session(&self.dm_address, &self.backend) + .await?; + self.incarnation_generation = self.incarnation_generation.wrapping_add(1); + self.cache + .clear_with_incarnation(incarnation( + DEFAULT_SEED ^ 0xFFFF_0000_FFFF_0000, + self.incarnation_generation, + )) + .await; + if let Some(record) = record { + ensure!( + matches!( + self.cache.restore_session_from_checkout( + &self.dm_address, + record, + checkout, + true, + ), + SessionCheckoutStoreResult::Rejected + ), + "lossy clear accepted a stale checkout" + ); + } else { + self.cache + .cancel_session_checkout(&self.dm_address, checkout); + } + ensure!( + !self.cache.needs_pre_wire_flush().await, + "lossy clear retained a stale wire gate" + ); + Ok(()) + } + + async fn checkout_during_flush(&self) -> anyhow::Result<()> { + let (record, checkout) = self + .cache + .checkout_session(&self.dm_address, &self.backend) + .await?; + let dm_was_gated = self + .cache + .lock_sessions() + .await + .reservation_pending + .contains(self.dm_address.as_str()); + self.cache.flush(&self.backend).await?; + match record { + Some(record) => self.commit_dm(record, checkout, true).await?, + None => self + .cache + .cancel_session_checkout(&self.dm_address, checkout), + } + if dm_was_gated { + ensure!( + self.cache.needs_pre_wire_flush().await, + "flush released a checked-out session gate" + ); + } + Ok(()) + } + + async fn assert_invariants(&self) -> anyhow::Result<()> { + let sessions = self.cache.lock_sessions().await; + ensure!( + sessions + .reservation_pending + .iter() + .all(|key| sessions.dirty.contains(key) || sessions.deleted.contains(key)), + "DM gate escaped dirty/tombstone tracking" + ); + ensure!( + sessions + .dirty + .iter() + .all(|key| sessions.cache.contains_key(key.as_ref())), + "dirty DM session was evicted" + ); + ensure!( + sessions + .cache + .values() + .all(|entry| !matches!(entry, SessionEntry::CheckedOut { .. })), + "DM checkout remained stranded" + ); + let session_gate = !sessions.reservation_pending.is_empty(); + drop(sessions); + + ensure!( + self.cache.pending_session_restores().is_empty(), + "queued DM restore remained undrained" + ); + let sender_keys = self.cache.sender_keys.lock().await; + ensure!( + sender_keys + .wire_gate_pending + .iter() + .all(|key| sender_keys.dirty.contains(key)), + "group gate escaped dirty tracking" + ); + ensure!( + sender_keys + .dirty + .iter() + .all(|key| sender_keys.cache.contains_key(key.as_ref())), + "dirty sender key was evicted" + ); + let sender_gate = !sender_keys.wire_gate_pending.is_empty(); + drop(sender_keys); + ensure!( + self.cache.needs_pre_wire_flush().await == (session_gate || sender_gate), + "wire-gate query disagrees with cache state" + ); + Ok(()) + } +} + +fn fresh_session(rng: &mut rand::rngs::StdRng) -> SessionRecord { + let local = IdentityKey::new(KeyPair::generate(rng).public_key); + let remote = IdentityKey::new(KeyPair::generate(rng).public_key); + let base_key = KeyPair::generate(rng).public_key; + let mut root = [0; 32]; + let mut chain = [0; 32]; + rng.fill(&mut root); + rng.fill(&mut chain); + let mut state = SessionState::new(3, &local, &remote, &RootKey::new(root), &base_key); + state.set_sender_chain(&KeyPair::generate(rng), &ChainKey::new(chain, 0)); + SessionRecord::new(state) +} + +fn dm_chain_index(record: &SessionRecord) -> anyhow::Result { + Ok(record + .session_state() + .context("DM session state missing")? + .get_sender_chain_key() + .map_err(|_| anyhow::anyhow!("DM sender chain missing"))? + .index()) +} + +fn group_position(record: &SenderKeyRecord) -> anyhow::Result<(u32, u32)> { + let state = record + .sender_key_state() + .map_err(|_| anyhow::anyhow!("group sender-key state missing"))?; + Ok(( + state.chain_id(), + state + .sender_chain_key() + .context("group sender chain missing")? + .iteration(), + )) +} + +fn incarnation(seed: u64, generation: u64) -> StoreIncarnation { + let mut rng = SplitMix64(seed ^ generation.wrapping_mul(0xD134_2543_DE82_EF95)); + let mut value = [0; 16]; + value[..8].copy_from_slice(&rng.next().to_le_bytes()); + value[8..].copy_from_slice(&rng.next().to_le_bytes()); + value +} + +fn env_u64(name: &str, default: u64) -> u64 { + let Some(value) = std::env::var(name).ok() else { + return default; + }; + if let Some(hex) = value.strip_prefix("0x") { + u64::from_str_radix(hex, 16).unwrap_or(default) + } else { + value.parse().unwrap_or(default) + } +} + +fn env_usize(name: &str, default: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + .clamp(1, 4096) +} + +async fn run_seed(seed: u64, steps: usize) -> anyhow::Result<()> { + let mut harness = tokio::time::timeout(Duration::from_secs(5), ChaosHarness::new(seed)) + .await + .context("chaos setup timed out")??; + let mut actions = SplitMix64(seed); + for step in 0..steps { + let action = actions.action(); + let result = tokio::time::timeout(Duration::from_secs(2), harness.apply(action)) + .await + .with_context(|| { + format!("seed=0x{seed:016x} step={step} action={action:?} timed out") + })?; + result.with_context(|| format!("seed=0x{seed:016x} step={step} action={action:?}"))?; + } + Ok(()) +} + +async fn run_matrix(first_seed: u64, seeds: usize, steps: usize) { + for index in 0..seeds { + let seed = first_seed.wrapping_add((index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)); + if let Err(error) = run_seed(seed, steps).await { + panic!( + "Signal durability chaos failed: {error:#}\n\ + replay with SIGNAL_CHAOS_SEED=0x{seed:016x} SIGNAL_CHAOS_SEEDS=1 \ + SIGNAL_CHAOS_STEPS={steps}" + ); + } + } +} + +#[tokio::test] +async fn signal_durability_chaos_smoke() { + run_matrix(DEFAULT_SEED, SMOKE_SEEDS, SMOKE_STEPS).await; +} + +#[tokio::test] +#[ignore = "run by signal-durability-nightly.yml"] +async fn signal_durability_chaos_nightly() { + let seed = env_u64("SIGNAL_CHAOS_SEED", DEFAULT_SEED); + let seeds = env_usize("SIGNAL_CHAOS_SEEDS", NIGHTLY_SEEDS); + let steps = env_usize("SIGNAL_CHAOS_STEPS", NIGHTLY_STEPS); + run_matrix(seed, seeds, steps).await; +} From 3b9c48616dc7cce5e8d91ef40266f44b643303b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:41:13 -0300 Subject: [PATCH 2/2] test(signal): harden durability recovery coverage --- .../workflows/signal-durability-nightly.yml | 14 +- agent_docs/signal_durability.md | 4 +- tests/signal_durability_sqlite.rs | 4 +- .../store/signal_cache_durability_chaos.rs | 136 ++++++++++++++---- 4 files changed, 124 insertions(+), 34 deletions(-) diff --git a/.github/workflows/signal-durability-nightly.yml b/.github/workflows/signal-durability-nightly.yml index 3a968931a..7b1b55182 100644 --- a/.github/workflows/signal-durability-nightly.yml +++ b/.github/workflows/signal-durability-nightly.yml @@ -38,23 +38,25 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + with: + persist-credentials: false - name: Reclaim unused Android SDK space run: | df -h / sudo rm -rf --one-file-system /usr/local/lib/android df -h / - - uses: dtolnay/rust-toolchain@master + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c with: toolchain: nightly-2026-06-16 - name: Install protoc - uses: taiki-e/install-action@v2 + uses: taiki-e/install-action@ed67fa35ac944f3a9b33f12c4dd43b6f31a47e20 with: tool: protoc@${{ env.PROTOC_VERSION }} - name: Setup sccache - uses: mozilla-actions/sccache-action@v0.0.10 + uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 - name: Cache Rust build (registry + target) - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 with: cache-targets: 'true' - name: Run deterministic DM and group matrix @@ -72,7 +74,7 @@ jobs: --ignored --exact --nocapture - name: Preserve failed SQLite fixture if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: signal-durability-sqlite-${{ github.run_id }} path: /tmp/whatsapp-rust-signal-durability-*.db* diff --git a/agent_docs/signal_durability.md b/agent_docs/signal_durability.md index 393e2ed29..9d9136a51 100644 --- a/agent_docs/signal_durability.md +++ b/agent_docs/signal_durability.md @@ -108,8 +108,8 @@ For any new or changed ciphertext path, verify: Focused unit tests live beside `SignalStoreCache`, record serialization, and the libsignal ciphers. The deterministic state machine combines DM and group sends, failed writes, cancellation, checkout/flush overlap, tombstones, clean -reloads, lossy clears, crash recovery, out-of-order group delivery, and retry -redistribution. +reloads, lossy clears, crash recovery, out-of-order group delivery, receiver +state loss, and retry redistribution. ```bash # Small matrix in normal CI diff --git a/tests/signal_durability_sqlite.rs b/tests/signal_durability_sqlite.rs index 0c4968428..a7f494e68 100644 --- a/tests/signal_durability_sqlite.rs +++ b/tests/signal_durability_sqlite.rs @@ -249,5 +249,7 @@ async fn signal_durability_sqlite_process_restart() { assert_eq!(status.code(), Some(CHILD_EXIT_CODE)); verify_recovery(database.to_str().expect("UTF-8 database path")).await; - remove_database(&database); + tokio::task::spawn_blocking(move || remove_database(&database)) + .await + .expect("database cleanup task"); } diff --git a/wacore/src/store/signal_cache_durability_chaos.rs b/wacore/src/store/signal_cache_durability_chaos.rs index 0a9d8faa0..5ef7dfbea 100644 --- a/wacore/src/store/signal_cache_durability_chaos.rs +++ b/wacore/src/store/signal_cache_durability_chaos.rs @@ -19,8 +19,7 @@ const NIGHTLY_SEEDS: usize = 128; const NIGHTLY_STEPS: usize = 256; const DEFAULT_SEED: u64 = 0x51A6_DA7A_B1E5_0001; -type DmFingerprint = ([u8; 32], [u8; 16]); -type GroupFingerprint = (u32, u32); +type KeyIvFingerprint = ([u8; 32], [u8; 16]); struct CachedSenderKeyStore<'a> { cache: &'a SignalStoreCache, @@ -70,6 +69,7 @@ enum Action { DeleteDm, DeleteGroup, CheckoutDuringFlush, + RecoverGroup, } #[derive(Clone, Copy)] @@ -91,7 +91,7 @@ impl SplitMix64 { } fn action(&mut self) -> Action { - match self.next() % 24 { + match self.next() % 25 { 0 => Action::DmSend { fail_gate: true }, 1..=6 => Action::DmSend { fail_gate: false }, 7 => Action::GroupSend { fail_gate: true }, @@ -108,6 +108,7 @@ impl SplitMix64 { 21 => Action::DeleteDm, 22 => Action::DeleteGroup, 23 => Action::CheckoutDuringFlush, + 24 => Action::RecoverGroup, _ => unreachable!(), } } @@ -122,8 +123,8 @@ struct ChaosHarness { group_name: SenderKeyName, crypto_rng: rand::rngs::StdRng, incarnation_generation: u64, - published_dm: HashSet, - published_group: HashSet, + published_dm: HashSet, + published_group: HashSet, pending_group: VecDeque, } @@ -169,7 +170,9 @@ impl ChaosHarness { self.group_send(fail_gate).await?; } Action::DmCancel => self.dm_cancel().await?, - Action::DeliverGroup { newest } => self.deliver_group(newest).await?, + Action::DeliverGroup { newest } => { + self.deliver_group(newest).await?; + } Action::Flush => self.flush_successfully().await?, Action::FailPendingFlush => self.fail_pending_flush().await?, Action::CleanReload => self.clean_reload().await?, @@ -182,6 +185,7 @@ impl ChaosHarness { .await; } Action::CheckoutDuringFlush => self.checkout_during_flush().await?, + Action::RecoverGroup => self.recover_group().await?, } self.assert_invariants().await } @@ -286,14 +290,21 @@ impl ChaosHarness { } async fn group_send(&mut self, fail_gate: bool) -> anyhow::Result { - if self + let record = if let Some(record) = self .cache .get_sender_key(&self.group_name, &self.backend) .await? - .is_none() { + record + } else { self.sync_group_distribution().await?; - } + self.cache + .get_sender_key(&self.group_name, &self.backend) + .await? + .context("sender-key setup did not populate the cache")? + }; + let fingerprint = group_key_fingerprint(&record)?; + drop(record); let message = { let mut sender = CachedSenderKeyStore { cache: &self.cache, @@ -307,7 +318,6 @@ impl ChaosHarness { ) .await? }; - let fingerprint = (message.chain_id(), message.iteration()); let published = self .release_wire_gate(if fail_gate { FlushFailure::SenderKey @@ -318,7 +328,9 @@ impl ChaosHarness { if published { ensure!( self.published_group.insert(fingerprint), - "group chain/iteration was published twice: {fingerprint:?}" + "group key/IV was published twice at chain {} iteration {}", + message.chain_id(), + message.iteration() ); self.pending_group.push_back(message); if self.pending_group.len() > 64 { @@ -351,20 +363,20 @@ impl ChaosHarness { Ok(()) } - async fn deliver_group(&mut self, newest: bool) -> anyhow::Result<()> { + async fn deliver_group(&mut self, newest: bool) -> anyhow::Result { let Some(message) = (if newest { self.pending_group.pop_back() } else { self.pending_group.pop_front() }) else { - return Ok(()); + return Ok(false); }; let mut receiver = CachedSenderKeyStore { cache: &self.receiver_cache, backend: &self.receiver_backend, }; match group_decrypt(message.serialized(), &mut receiver, &self.group_name).await { - Ok(_) => return Ok(()), + Ok(_) => return Ok(false), Err(SignalProtocolError::InvalidMessage( CiphertextMessageType::SenderKey, "message from too far into the future", @@ -391,6 +403,23 @@ impl ChaosHarness { plaintext == b"durability-chaos", "group retry decrypted the wrong plaintext" ); + Ok(true) + } + + async fn recover_group(&mut self) -> anyhow::Result<()> { + if self.pending_group.is_empty() { + ensure!( + self.group_send(false).await?, + "group recovery setup remained behind a durability gate" + ); + } + self.receiver_cache + .delete_sender_key(self.group_name.cache_key()) + .await; + ensure!( + self.deliver_group(false).await?, + "lost receiver state did not exercise group recovery" + ); Ok(()) } @@ -398,6 +427,7 @@ impl ChaosHarness { if !self.cache.needs_pre_wire_flush().await { return Ok(true); } + let failure = self.failure_for_pending_gate(failure).await; self.backend .set_fail_session_writes(matches!(failure, FlushFailure::Session)); self.backend @@ -422,6 +452,32 @@ impl ChaosHarness { Ok(false) } + async fn failure_for_pending_gate(&self, preferred: FlushFailure) -> FlushFailure { + if matches!(preferred, FlushFailure::None) { + return preferred; + } + let session_pending = !self + .cache + .lock_sessions() + .await + .reservation_pending + .is_empty(); + let sender_pending = !self + .cache + .sender_keys + .lock() + .await + .wire_gate_pending + .is_empty(); + match preferred { + FlushFailure::Session if session_pending => preferred, + FlushFailure::SenderKey if sender_pending => preferred, + _ if session_pending => FlushFailure::Session, + _ if sender_pending => FlushFailure::SenderKey, + _ => preferred, + } + } + async fn fail_pending_flush(&self) -> anyhow::Result<()> { let session_pending = !self .cache @@ -657,6 +713,21 @@ fn group_position(record: &SenderKeyRecord) -> anyhow::Result<(u32, u32)> { )) } +fn group_key_fingerprint(record: &SenderKeyRecord) -> anyhow::Result { + let message_key = record + .sender_key_state() + .map_err(|_| anyhow::anyhow!("group sender-key state missing"))? + .sender_chain_key() + .context("group sender chain missing")? + .sender_message_key(); + let cipher_key: [u8; 32] = message_key + .cipher_key() + .try_into() + .context("group cipher key length")?; + let iv: [u8; 16] = message_key.iv().try_into().context("group IV length")?; + Ok((cipher_key, iv)) +} + fn incarnation(seed: u64, generation: u64) -> StoreIncarnation { let mut rng = SplitMix64(seed ^ generation.wrapping_mul(0xD134_2543_DE82_EF95)); let mut value = [0; 16]; @@ -666,22 +737,37 @@ fn incarnation(seed: u64, generation: u64) -> StoreIncarnation { } fn env_u64(name: &str, default: u64) -> u64 { - let Some(value) = std::env::var(name).ok() else { - return default; + let value = match std::env::var(name) { + Ok(value) => value, + Err(std::env::VarError::NotPresent) => return default, + Err(std::env::VarError::NotUnicode(value)) => { + panic!("invalid {name}={value:?}: expected Unicode") + } }; - if let Some(hex) = value.strip_prefix("0x") { - u64::from_str_radix(hex, 16).unwrap_or(default) + let parsed = if let Some(hex) = value.strip_prefix("0x") { + u64::from_str_radix(hex, 16) } else { - value.parse().unwrap_or(default) - } + value.parse() + }; + parsed.unwrap_or_else(|error| panic!("invalid {name}={value:?}: {error}")) } fn env_usize(name: &str, default: usize) -> usize { - std::env::var(name) - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(default) - .clamp(1, 4096) + let value = match std::env::var(name) { + Ok(value) => value, + Err(std::env::VarError::NotPresent) => return default, + Err(std::env::VarError::NotUnicode(value)) => { + panic!("invalid {name}={value:?}: expected Unicode") + } + }; + let parsed = value + .parse::() + .unwrap_or_else(|error| panic!("invalid {name}={value:?}: {error}")); + assert!( + (1..=4096).contains(&parsed), + "invalid {name}={value:?}: expected 1..=4096" + ); + parsed } async fn run_seed(seed: u64, steps: usize) -> anyhow::Result<()> {