lxmf: check every valid ticket in stamp validation, cull state at load, use announced stamp flexibility

This commit is contained in:
DeFiDude 2026-07-10 18:10:17 -05:00
parent 68ad7c8351
commit 20ef834281
4 changed files with 47 additions and 36 deletions

1
Cargo.lock generated
View file

@ -1095,6 +1095,7 @@ dependencies = [
"objc2-foundation",
"rand 0.8.5",
"rns-crypto",
"rns-identity",
"rns-transport",
"rns-wire",
"serde",

View file

@ -130,10 +130,13 @@ impl LxmPeer {
peer
}
/// Effective minimum stamp cost this peer will accept.
/// Effective minimum stamp cost this peer will accept, using the peer's
/// announced flexibility when known (Python `LXMPeer.sync`: cost - flex).
pub fn minimum_accepted_stamp_cost(&self) -> u8 {
match self.stamp_cost {
Some(cost) => cost.saturating_sub(PROPAGATION_COST_FLEX),
Some(cost) => {
cost.saturating_sub(self.stamp_cost_flexibility.unwrap_or(PROPAGATION_COST_FLEX))
}
None => 0,
}
}
@ -465,6 +468,13 @@ mod tests {
// cost < flex must saturate at 0.
peer.stamp_cost = Some(2);
assert_eq!(peer.minimum_accepted_stamp_cost(), 0);
// Announced flexibility overrides the default.
peer.stamp_cost = Some(16);
peer.stamp_cost_flexibility = Some(5);
assert_eq!(peer.minimum_accepted_stamp_cost(), 11);
peer.stamp_cost_flexibility = Some(0);
assert_eq!(peer.minimum_accepted_stamp_cost(), 16);
}
/// T0-4: an absurd announce-supplied peering cost must fail the bounded

View file

@ -699,9 +699,9 @@ impl LxmRouter {
.add(Ticket::new(token, destination_hash, expires));
}
/// Returns the token of the most-recently-added valid ticket for `destination_hash`.
/// Returns the token of the first valid ticket for `destination_hash`.
///
/// Python reference: `LXMRouter.get_outbound_ticket` — LXMRouter.py:1115-1123.
/// Python reference: `LXMRouter.get_outbound_ticket` — LXMRouter.py:1058-1064.
pub fn get_outbound_ticket(&self, destination_hash: &[u8; 16]) -> Option<[u8; 16]> {
let now = now_f64();
self.ticket_store
@ -903,6 +903,11 @@ impl LxmRouter {
.replace_locally_delivered(persist::load_local_deliveries(state_dir)?);
self.propagation_store
.replace_locally_processed(persist::load_locally_processed(state_dir)?);
// Python cleans tickets and stamp costs at load (LXMRouter.py:258-284).
let now = now_f64();
self.ticket_store.cull(now);
self.outbound_stamp_costs
.retain(|_, e| now - e.recorded_at < STAMP_COST_EXPIRY as f64);
Ok(())
}
@ -1173,7 +1178,10 @@ impl LxmRouter {
destination_hash: &[u8; 16],
) -> bool {
let now = now_f64();
if let Some(ticket) = self.ticket_store.find(destination_hash, now) {
for ticket in self.ticket_store.all() {
if &ticket.destination_hash != destination_hash || !ticket.is_valid(now) {
continue;
}
let mut material = Vec::with_capacity(16 + 32);
material.extend_from_slice(&ticket.token);
material.extend_from_slice(message_id);
@ -2440,6 +2448,24 @@ mod tests {
assert_eq!(router.get_inbound_tickets().len(), 2);
}
#[test]
fn test_validate_stamp_checks_all_tickets() {
let mut router = LxmRouter::new(RouterConfig::default());
let dest = [0xAA; 16];
let expires = now_f64() + 1000.0;
router.remember_ticket(dest, [0x01; 16], expires);
router.remember_ticket(dest, [0x02; 16], expires);
// Stamp derived from the SECOND ticket must still validate.
let message_id = [0x33u8; 32];
let mut material = Vec::with_capacity(16 + 32);
material.extend_from_slice(&[0x02; 16]);
material.extend_from_slice(&message_id);
let stamp = rns_crypto::sha::truncated_hash(&material);
assert!(router.validate_stamp_with_tickets(&message_id, stamp.as_ref(), 16, &dest));
}
#[test]
fn test_cancel_outbound() {
let mut router = LxmRouter::new(RouterConfig::default());

View file

@ -1,7 +1,8 @@
//! LXMF Ticket system: bypass PoW with pre-shared 16-byte tokens.
//!
//! Trusted peers may exchange tickets that bypass stamp requirements for a
//! fixed expiry window. Tickets are single-use and renewable before expiry.
//! fixed expiry window. Tickets are reusable until expiry and renewed once
//! within `TICKET_RENEW` of expiring.
use serde::{Deserialize, Serialize};
@ -33,10 +34,6 @@ impl Ticket {
pub fn should_renew(&self, now: f64) -> bool {
self.is_valid(now) && (self.expires - now) < TICKET_RENEW as f64
}
pub fn use_ticket(&mut self) {
self.used = true;
}
}
#[derive(Debug, Default)]
@ -59,17 +56,6 @@ impl TicketStore {
.find(|t| &t.destination_hash == destination_hash && t.is_valid(now))
}
/// Find and mark a ticket as used. Returns the token on success.
pub fn use_for(&mut self, destination_hash: &[u8; 16], now: f64) -> Option<[u8; 16]> {
for ticket in &mut self.tickets {
if &ticket.destination_hash == destination_hash && ticket.is_valid(now) {
ticket.use_ticket();
return Some(ticket.token);
}
}
None
}
/// Drop expired and used tickets (past TICKET_GRACE).
pub fn cull(&mut self, now: f64) {
self.tickets
@ -104,9 +90,11 @@ mod tests {
#[test]
fn test_ticket_used() {
// `used` survives only for persisted-state compat; is_valid must
// still reject such entries.
let mut ticket = Ticket::new([0xAA; 16], [0xBB; 16], 1000.0);
assert!(ticket.is_valid(500.0));
ticket.use_ticket();
ticket.used = true;
assert!(!ticket.is_valid(500.0));
}
@ -135,20 +123,6 @@ mod tests {
assert!(found.is_some());
}
#[test]
fn test_ticket_store_use() {
let mut store = TicketStore::new();
let dest = [0xBB; 16];
store.add(Ticket::new([0x01; 16], dest, 1000.0));
let token = store.use_for(&dest, 500.0);
assert_eq!(token, Some([0x01; 16]));
let token = store.use_for(&dest, 500.0);
assert!(token.is_none());
}
#[test]
fn test_ticket_store_cull() {
let mut store = TicketStore::new();