Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions wacore/libsignal/src/protocol/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub const MESSAGE_KEY_PRUNE_THRESHOLD: usize = 50;
/// reloaded snapshot fast-forwards past them. Bounds both the sync-flush
/// amortization (one per this many sends) and the worst-case counter gap a
/// receiver sees after a crash — keep it well under MAX_FORWARD_JUMPS.
///
/// None of this applies to a record whose consumer waived the lease: it
/// reserves nothing, so there is no batch to fast-forward past and no gap.
pub const SENDER_CHAIN_RESERVATION_BATCH: u32 = 64;

/// Upper bound for the reservation fast-forward on load. A legitimate lease
Expand Down
182 changes: 182 additions & 0 deletions wacore/libsignal/src/protocol/counter_lease.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! Whether a record leases outbound counters ahead of durability.
//!
//! Message keys and IVs are derived deterministically from an outbound
//! counter, so republishing one after a crash reuses a (key, IV) pair. The
//! default guards that by leasing counters in batches: the send path needs a
//! durable flush only when a batch runs out, and any reload fast-forwards past
//! the whole lease.
//!
//! A consumer whose persistence is already synchronous and durable before the
//! ciphertext reaches the wire gets nothing from the lease and pays for it,
//! because every export has to burn the reserved range. [`CounterLease::Waived`]
//! is that consumer's declaration, and it is never inferred: the same record
//! shape can be persisted by a consumer that wants the lease and by one that
//! does not, so tying the policy to the representation would turn a storage
//! change into a silent change of guarantee.
//!
//! Waiving gives up a real guarantee. Without the lease, a crash between the
//! encrypt and the write can reissue a counter and with it the (key, IV) pair.
//! Only a consumer that persists before the wire can make that trade.

use crate::protocol::consts;

/// Reservation state, or the consumer's declaration that it needs none.
///
/// `Waived` carries no ceiling and no pending flag, so a record cannot hold a
/// reservation the send path would gate on while also having waived the lease
/// that reservation implements.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CounterLease {
Leased {
/// Exclusive ceiling of counters a durable snapshot may already have
/// published.
ceiling: u32,
/// A reservation was raised but not yet durably flushed. While set,
/// the owning ciphertext must not reach the wire.
pending_flush: bool,
},
Waived,
}

impl Default for CounterLease {
fn default() -> Self {
Self::Leased {
ceiling: 0,
pending_flush: false,
}
}
}

impl CounterLease {
pub(crate) fn from_persisted_ceiling(ceiling: u32) -> Self {
Self::Leased {
ceiling,
pending_flush: false,
}
}

/// Exclusive ceiling of the current reservation; zero when nothing is
/// reserved, which is also what a waived lease reports, since there is
/// nothing for a reload or an export to advance past.
pub(crate) fn ceiling(self) -> u32 {
match self {
Self::Leased { ceiling, .. } => ceiling,
Self::Waived => 0,
}
}

pub(crate) fn is_pending_flush(self) -> bool {
matches!(
self,
Self::Leased {
pending_flush: true,
..
}
)
}

/// Lease a fresh batch after `spent_counter` was issued.
///
/// Reservations only ever rise: a counter must not be published under a
/// ceiling lower than one a durable snapshot already carries, so a spent
/// counter still inside the current batch changes nothing.
pub(crate) fn reserve(&mut self, spent_counter: u32) {
if let Self::Leased {
ceiling,
pending_flush,
} = self
&& spent_counter >= *ceiling
{
*ceiling = spent_counter.saturating_add(consts::SENDER_CHAIN_RESERVATION_BATCH);
*pending_flush = true;
}
}

pub(crate) fn set_pending_flush(&mut self, pending: bool) {
if let Self::Leased { pending_flush, .. } = self {
*pending_flush = pending;
}
}

/// Drop the reservation without changing whether the lease is in force.
pub(crate) fn clear_reservation(&mut self) {
if let Self::Leased { ceiling, .. } = self {
*ceiling = 0;
}
}

/// Cap the ceiling at one batch after a ratchet replaced the leased chain.
pub(crate) fn rebase(&mut self) {
if let Self::Leased { ceiling, .. } = self {
*ceiling = (*ceiling).min(consts::SENDER_CHAIN_RESERVATION_BATCH);
}
}

/// Waive the lease, returning any ceiling the caller must still materialize.
///
/// A record loaded from a snapshot written while the lease was in force
/// carries a reservation that may already have been published. Waiving does
/// not make that untrue, so the caller advances past it once; from then on
/// counters are consecutive. Refusing such a record instead would strand
/// the address for good.
pub(crate) fn waive(&mut self) -> u32 {
let ceiling = self.ceiling();
*self = Self::Waived;
ceiling
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn a_waived_lease_reserves_nothing_and_never_gates_the_wire() {
let mut lease = CounterLease::Waived;
lease.reserve(0);
lease.reserve(u32::MAX);

assert_eq!(lease, CounterLease::Waived);
assert_eq!(lease.ceiling(), 0);
assert!(!lease.is_pending_flush());
}

#[test]
fn a_leased_reservation_rises_and_gates_the_wire() {
let mut lease = CounterLease::default();
assert_eq!(lease.ceiling(), 0);
assert!(!lease.is_pending_flush());

lease.reserve(0);
assert_eq!(lease.ceiling(), consts::SENDER_CHAIN_RESERVATION_BATCH);
assert!(lease.is_pending_flush());

// Inside the batch: nothing to raise, so the send stays uncovered by a
// fresh gate.
lease.set_pending_flush(false);
lease.reserve(1);
assert_eq!(lease.ceiling(), consts::SENDER_CHAIN_RESERVATION_BATCH);
assert!(!lease.is_pending_flush());

lease.reserve(consts::SENDER_CHAIN_RESERVATION_BATCH);
assert_eq!(lease.ceiling(), consts::SENDER_CHAIN_RESERVATION_BATCH * 2);
assert!(lease.is_pending_flush());
}

#[test]
fn waiving_hands_back_the_ceiling_to_materialize_once() {
let mut lease = CounterLease::from_persisted_ceiling(512);

assert_eq!(lease.waive(), 512);
assert_eq!(lease, CounterLease::Waived);
// Converged: a second waive has nothing left to materialize.
assert_eq!(lease.waive(), 0);
}

#[test]
fn rebasing_a_waived_lease_is_inert() {
let mut lease = CounterLease::Waived;
lease.rebase();
assert_eq!(lease, CounterLease::Waived);
}
}
142 changes: 136 additions & 6 deletions wacore/libsignal/src/protocol/group_cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,10 @@ pub async fn group_encrypt<S: SenderKeyStore + ?Sized, R: Rng + CryptoRng>(
// sends ride the coalesced write-behind; only the send that reaches the
// ceiling re-reserves and gates the ciphertext on a synchronous flush (which
// fast-forwards past the reservation after any reload). Decrypt-side advances
// stay ungated (they re-derive forward). Mirrors the DM counter lease in
// SessionRecord.
let spent_iteration = message_keys.iteration();
if spent_iteration >= record.reserved_iteration() {
record.reserve_iterations(spent_iteration);
}
// stay ungated (they re-derive forward). A consumer that waived the lease
// persists before the wire and reserves nothing. Mirrors the DM counter
// lease in SessionRecord.
record.reserve_iterations(message_keys.iteration());

sender_key_store
.store_sender_key(sender_key_name, record)
Expand Down Expand Up @@ -560,6 +558,138 @@ mod tests {
);
}

/// A store whose persistence is a component export, the group counterpart
/// of the DM case in `tests/counter_lease.rs`: it rebuilds the record from
/// components on every load, which is exactly what materializes a
/// reservation and burns the batch.
struct ComponentStore {
states: HashMap<SenderKeyName, crate::protocol::SenderKeyRecordComponents>,
waive: bool,
}
#[async_trait]
impl SenderKeyStore for ComponentStore {
async fn store_sender_key(
&mut self,
name: &SenderKeyName,
record: SenderKeyRecord,
) -> Result<()> {
self.states.insert(name.clone(), record.into_components()?);
Ok(())
}
async fn load_sender_key(&self, name: &SenderKeyName) -> Result<Option<SenderKeyRecord>> {
let Some(components) = self.states.get(name) else {
return Ok(None);
};
let mut record = SenderKeyRecord::from_components(components.clone())?;
if self.waive {
record.waive_counter_lease()?;
}
Ok(Some(record))
}
}

/// Iterations the group sender put on the wire over `count` sends, and the
/// skipped keys the receiver had to buffer for them.
fn exported_group_run(count: usize, waive: bool) -> (Vec<u32>, usize) {
let mut rng = rand::rng();
let name = SenderKeyName::new("group@g.us".to_string(), "bob.0".to_string());
let mut bob = ComponentStore {
states: HashMap::new(),
waive,
};
let skdm = block_on(create_sender_key_distribution_message(
&name, &mut bob, &mut rng,
))
.expect("bob creates his distribution message");
let mut alice = InMemorySenderKeyStore {
keys: HashMap::new(),
};
block_on(process_sender_key_distribution_message(
&name, &skdm, &mut alice,
))
.expect("alice processes it");

let iterations = (0..count)
.map(|_| {
let msg =
block_on(group_encrypt(&mut bob, &name, b"m", &mut rng)).expect("bob encrypts");
let iteration = msg.iteration();
block_on(group_decrypt(msg.serialized(), &mut alice, &name))
.expect("alice decrypts");
iteration
})
.collect();

let skipped = alice
.keys
.remove(&name)
.expect("alice has a record")
.into_components()
.expect("components")
.states
.iter()
.map(|state| state.message_keys.len())
.sum();
(iterations, skipped)
}

/// The group symptom, and its fix: exporting components burns a batch per
/// send, so iterations stride by 64 and the receiver buffers the gap.
#[test]
fn a_waived_lease_keeps_group_iterations_consecutive() {
let (iterations, skipped) = exported_group_run(8, true);

assert_eq!(iterations, (0..8).collect::<Vec<u32>>());
assert_eq!(skipped, 0);
}

/// The default is untouched: same run, same batch stride, same backlog.
#[test]
fn the_default_group_lease_still_burns_a_batch_per_export() {
let (iterations, skipped) = exported_group_run(8, false);

let expected: Vec<u32> = (0..8)
.map(|i| i as u32 * SENDER_CHAIN_RESERVATION_BATCH)
.collect();
assert_eq!(iterations, expected);
assert!(
skipped > 0,
"the leased run must leave the receiver with skipped keys"
);
}

/// A record written under the lease may already have published iterations
/// below its ceiling; waiving materializes that ceiling once, then runs
/// consecutively.
#[test]
fn waiving_materializes_a_previously_reserved_group_ceiling_once() {
let mut rng = rand::rng();
let name = SenderKeyName::new("group@g.us".to_string(), "bob.0".to_string());
let mut bob = InMemorySenderKeyStore {
keys: HashMap::new(),
};
block_on(create_sender_key_distribution_message(
&name, &mut bob, &mut rng,
))
.expect("distribution message");
block_on(group_encrypt(&mut bob, &name, b"m0", &mut rng)).expect("first send reserves");

let record = bob.keys.get_mut(&name).expect("record");
let ceiling = record.reserved_iteration();
assert_eq!(ceiling, SENDER_CHAIN_RESERVATION_BATCH);
record
.waive_counter_lease()
.expect("waive materializes once");
assert_eq!(record.reserved_iteration(), 0);

let first = block_on(group_encrypt(&mut bob, &name, b"after", &mut rng))
.expect("send after waiving");
assert_eq!(first.iteration(), ceiling);
let second =
block_on(group_encrypt(&mut bob, &name, b"next", &mut rng)).expect("the next send");
assert_eq!(second.iteration(), ceiling + 1);
}

/// A store that emulates the real signal-cache gate: it records whether each
/// stored advance was wire-gated and clears the transient flag, so a run of
/// sends can be counted for gate frequency.
Expand Down
1 change: 1 addition & 0 deletions wacore/libsignal/src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#![deny(unsafe_code)]

pub mod consts;
mod counter_lease;
mod crypto;
pub mod error;
mod group_cipher;
Expand Down
Loading
Loading