fix group issue
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled

This commit is contained in:
otsmr 2026-09-06 04:07:06 +02:00
parent 1ba5c15279
commit 7d850de90d
28 changed files with 1256 additions and 55 deletions

View file

@ -7,7 +7,6 @@ import 'package:twonly/src/services/webxdc/webxdc.service.dart';
import 'package:twonly/src/services/webxdc/webxdc_host.dart'; import 'package:twonly/src/services/webxdc/webxdc_host.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart';
import 'package:twonly/src/visual/views/webxdc/webxdc_app.view.dart'; import 'package:twonly/src/visual/views/webxdc/webxdc_app.view.dart';

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO signal_session_resets(\n name, device_id, last_reset_at, window_started_at, resets_in_window\n )\n VALUES (?, ?, ?, ?, 1)\n ON CONFLICT(name, device_id) DO UPDATE SET\n last_reset_at = excluded.last_reset_at,\n -- A window that has run out starts over with this reset as its\n -- first, so a peer that broke months ago is not still blocked.\n window_started_at = CASE\n WHEN signal_session_resets.window_started_at <= ?\n THEN excluded.last_reset_at\n ELSE signal_session_resets.window_started_at\n END,\n resets_in_window = CASE\n WHEN signal_session_resets.window_started_at <= ? THEN 1\n ELSE signal_session_resets.resets_in_window + 1\n END\n -- Every SET expression above reads the row as it was before the\n -- update, and this predicate decides whether the update happens at\n -- all: zero affected rows means the claim was refused. A first\n -- reset for a peer takes the INSERT path and is always allowed.\n WHERE signal_session_resets.last_reset_at <= ?\n AND (signal_session_resets.window_started_at <= ?\n OR signal_session_resets.resets_in_window < ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "1c17357b35a0a3063df88521641d570b008e98adf4a94a763e298b7606f8da11"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT contact_id, member_state FROM group_members WHERE group_id = ?",
"describe": {
"columns": [
{
"name": "contact_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "group_members",
"name": "contact_id"
}
}
},
{
"name": "member_state",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "group_members",
"name": "member_state"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "3b2c67b996d35fade4d83005cbd20a93f258716b668c500d3535927064cfc863"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM messages WHERE message_id = ?",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "413d276a374ee54f098a809ed55cbd57e4bc60e7a0abb519f1bd80348f13c37c"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE signal_session_resets\n SET last_reset_at = last_reset_at - ?, window_started_at = window_started_at - ?\n WHERE name = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "44772267c7aef1f2c6c453dc25adc52fbe79d66f075bc47b0fd1c21a70fe04a5"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM signal_sessions WHERE name = ? AND device_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "58f456ebf839809283af9ad02b7818aa1d104eff8fcacadfa5bf6c427aa8e0a8"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM signal_session_resets WHERE name = ? AND device_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7cdd5e1cb18606d1fb36fafc939ebdded92ee03640c347b6017d2229b6a686e6"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT record_bytes FROM signal_sessions WHERE name = ? AND device_id = 1",
"describe": {
"columns": [
{
"name": "record_bytes",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "signal_sessions",
"name": "record_bytes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "ca6f26d1b5a9ffd0072c890ffed0a069626a920e14d669ce097c0c4720ec01a3"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE receipts\n SET receipt_id = ?,\n mark_for_retry = CAST(strftime('%s', 'now') AS INTEGER),\n retry_count = retry_count + 1,\n ack_by_server_at = NULL\n WHERE receipt_id = ? AND contact_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "d2688abdeef4590a1c762a1683a638a25806d9be376027c4ac86955a42135ef2"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM group_members\n WHERE group_id = ? AND contact_id = ? AND member_state = 'leftGroup'\n )\n ",
"describe": {
"columns": [
{
"name": "EXISTS(\n SELECT 1\n FROM group_members\n WHERE group_id = ? AND contact_id = ? AND member_state = 'leftGroup'\n )",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "d6f2b713b634721d0c5992f24eef566a1060e3ece94dd0de35d6fdea8648f93e"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE receipts\n SET receipt_id = ?,\n mark_for_retry = CAST(strftime('%s', 'now') AS INTEGER),\n retry_count = retry_count + 1,\n ack_by_server_at = NULL,\n -- A rebuild needs a server round-trip, so it cannot run inside this\n -- transaction. `release_deferred_receipts` frees the receipt once\n -- the new session stands.\n deferred_until_session = CASE\n WHEN ? THEN CAST(strftime('%s', 'now') AS INTEGER)\n ELSE deferred_until_session\n END\n WHERE receipt_id = ? AND contact_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "e765e45ef6696e2b39570a04b53046ee9530cd01d899c05b605be9222dcdef98"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE signal_sessions SET record_bytes = ? WHERE name = ? AND device_id = 1",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e79f0f07d9c5ab9080f6c14afeea737a2562b8d174d04998ac729157df8230d8"
}

View file

@ -20,7 +20,7 @@ use crate::api::messages::content_type_kind;
use crate::api::messages::incoming::messages::{ use crate::api::messages::incoming::messages::{
ensure_contact_exists, handle_plaintext_content, handle_sender_delivery_receipt, ensure_contact_exists, handle_plaintext_content, handle_sender_delivery_receipt,
process_encrypted_or_queue_error, queue_decryption_error, queue_sender_delivery_receipt, process_encrypted_or_queue_error, queue_decryption_error, queue_sender_delivery_receipt,
retransmit_queued_receipts, spawn_receipt_delivery, release_deferred_receipts, retransmit_queued_receipts, spawn_receipt_delivery,
}; };
use crate::api::proto::client as proto; use crate::api::proto::client as proto;
use crate::api::proto::server_to_client::NewMessage; use crate::api::proto::server_to_client::NewMessage;
@ -29,9 +29,12 @@ use crate::context::Context;
use crate::database::app::tables::{Contact, Group, Receipt}; use crate::database::app::tables::{Contact, Group, Receipt};
use crate::error::{Result, TwonlyError}; use crate::error::{Result, TwonlyError};
use crate::services::contacts::ContactService; use crate::services::contacts::ContactService;
use crate::services::groups::GroupService;
use crate::signal::reset::SessionResetLimiter;
use client_to_server::response::{ok, Response}; use client_to_server::response::{ok, Response};
use prost::Message as _; use prost::Message as _;
use proto::message::Type; use proto::message::Type;
use proto::plaintext_content::decryption_error_message::Type as DecryptionErrorType;
use server_to_client::v0::Kind; use server_to_client::v0::Kind;
use sqlx::{Sqlite, Transaction}; use sqlx::{Sqlite, Transaction};
use std::collections::HashMap; use std::collections::HashMap;
@ -239,6 +242,60 @@ pub(crate) async fn handle_request_new_pqc_prekeys(
)) ))
} }
/// Retires our session with a peer when it can no longer open what they send,
/// and picks the error type the peer is answered with.
///
/// A session whose ratchet has diverged -- the usual cause is one side
/// restoring a backup, which rewinds its record behind the peer's -- never
/// recovers by decrypting the same ciphertext again. Retiring it locally is
/// only half the repair: the peer holds the matching half and has to open a
/// new session, which `SESSION_RESET_REQUIRED` asks them to do. Anything else
/// (a duplicate, an identity change, a storage failure) keeps the plain
/// `UNKNOWN` error, whose retry the peer already knows how to handle.
///
/// A refused rate-limit claim also falls back to `UNKNOWN`: the session stays
/// as it is and the message fails, rather than two clients trading resets.
async fn reset_unusable_session(
ctx: &Arc<Context>,
from_user_id: i64,
error: &TwonlyError,
) -> DecryptionErrorType {
if !matches!(error, TwonlyError::SignalSessionUnusable(_)) {
return DecryptionErrorType::Unknown;
}
let name = from_user_id.to_string();
let rust_database = ctx.rust_db.read().await.clone();
match SessionResetLimiter::claim(&rust_database.pool, &name, 1).await {
Ok(false) => return DecryptionErrorType::Unknown,
Ok(true) => {}
Err(error) => {
tracing::warn!(from_user_id, "could not claim a session reset: {error}");
return DecryptionErrorType::Unknown;
}
}
let engine = ctx.signal_engine.lock().await;
let Some(engine) = engine.as_ref() else {
return DecryptionErrorType::Unknown;
};
match engine.reset_session(&name, 1, false).await {
Ok(_) => {
tracing::warn!(
from_user_id,
"signal session could not decrypt and was reset; asking the peer for a new one"
);
DecryptionErrorType::SessionResetRequired
}
Err(error) => {
tracing::warn!(from_user_id, "could not reset the signal session: {error}");
DecryptionErrorType::Unknown
}
}
}
/// Rebuilds a v2 Signal session for a peer that still speaks the legacy /// Rebuilds a v2 Signal session for a peer that still speaks the legacy
/// protocol, so the decryption error queued for the message can be answered /// protocol, so the decryption error queued for the message can be answered
/// with a session the peer can upgrade to. /// with a session the peer can upgrade to.
@ -298,7 +355,10 @@ async fn upgrade_legacy_session_to_v2(
kind = tracing::field::Empty kind = tracing::field::Empty
) )
)] )]
pub(crate) async fn handle_decoded_server_message( /// Entry point for one inbound server message, whichever transport carried it.
/// Public so tests can deliver the same message twice and prove a redelivery is
/// handled rather than dropped.
pub async fn handle_decoded_server_message(
ctx: &Arc<Context>, ctx: &Arc<Context>,
from_user_id: i64, from_user_id: i64,
message: proto::Message, message: proto::Message,
@ -360,7 +420,21 @@ pub(crate) async fn handle_decoded_server_message(
tracing::info!("Resuming a redelivered message from its parked plaintext"); tracing::info!("Resuming a redelivered message from its parked plaintext");
} }
if !claimed && resumed_plaintext.is_none() { // A claim with no plaintext parked on it says nothing about whether the
// message was handled: a decryption that failed leaves exactly the same
// trace as one that succeeded and committed. Dropping the redelivery on
// that ambiguity is what left a peer whose session had broken with no way
// back to the decrypt path -- and no answer at all, so they resent the same
// receipt forever. Decrypt again and let the outcome say which it was: a
// message that opens was never handled, and one the ratchet has already
// consumed comes back as a duplicate and is acknowledged below.
let retry_decryption = !claimed && resumed_plaintext.is_none() && is_encrypted_message;
if retry_decryption {
tracing::info!("Decrypting a redelivered message that was claimed but not handled");
}
if !claimed && resumed_plaintext.is_none() && !retry_decryption {
// Delivery receipts are terminal messages and must never themselves be // Delivery receipts are terminal messages and must never themselves be
// acknowledged. For regular messages Dart retries the delivery receipt // acknowledged. For regular messages Dart retries the delivery receipt
// after ten days, atomically claiming the retry by moving created_at. // after ten days, atomically claiming the retry by moving created_at.
@ -384,6 +458,7 @@ pub(crate) async fn handle_decoded_server_message(
} }
let mut sends_error_response = false; let mut sends_error_response = false;
let mut rebuild_session = false;
match message_type { match message_type {
Type::SenderDeliveryReceipt => { Type::SenderDeliveryReceipt => {
@ -459,12 +534,30 @@ pub(crate) async fn handle_decoded_server_message(
.await? .await?
.is_some(); .is_some();
} }
// The ratchet has already consumed this message, so it was
// handled and the peer is only missing the answer. Falling
// through without an error response queues the sender delivery
// receipt below, which is the terminal message that stops them
// resending it.
Err(TwonlyError::SignalDuplicateMessage(reason)) => {
tracing::info!(
receipt_id = message.receipt_id,
"acknowledging a message this session already handled: {reason}"
);
}
Err(error) => { Err(error) => {
tracing::warn!( tracing::warn!(
receipt_id = message.receipt_id, receipt_id = message.receipt_id,
"V2 decryption failed: {error}" "V2 decryption failed: {error}"
); );
queue_decryption_error(&mut t, from_user_id, &message.receipt_id, 0).await?; let error_type = reset_unusable_session(ctx, from_user_id, &error).await;
queue_decryption_error(
&mut t,
from_user_id,
&message.receipt_id,
error_type as i32,
)
.await?;
sends_error_response = true; sends_error_response = true;
} }
} }
@ -474,8 +567,9 @@ pub(crate) async fn handle_decoded_server_message(
let plaintext = message.plaintext_content.ok_or_else(|| { let plaintext = message.plaintext_content.ok_or_else(|| {
TwonlyError::Generic("plaintext client message has no content".into()) TwonlyError::Generic("plaintext client message has no content".into())
})?; })?;
handle_plaintext_content(ctx, &mut t, from_user_id, &message.receipt_id, plaintext) rebuild_session =
.await?; handle_plaintext_content(ctx, &mut t, from_user_id, &message.receipt_id, plaintext)
.await?;
} }
Type::TestNotification => {} Type::TestNotification => {}
} }
@ -500,6 +594,19 @@ pub(crate) async fn handle_decoded_server_message(
let ctx = ctx.clone(); let ctx = ctx.clone();
tokio::spawn(async move { tokio::spawn(async move {
// The peer reset their session, so the parked receipt has to wait for a
// session built from a fresh prekey bundle. `establish_signal_session`
// releases it, and the flush below then sends it as a prekey message
// the peer can open without any prior state.
if rebuild_session {
if let Err(error) = rebuild_session_after_peer_reset(&ctx, from_user_id).await {
tracing::warn!(
from_user_id,
"could not rebuild the signal session the peer reset: {error}"
);
}
}
if let Err(error) = retransmit_queued_receipts(&ctx).await { if let Err(error) = retransmit_queued_receipts(&ctx).await {
tracing::warn!("failed to flush messages queued during inbound handling: {error}"); tracing::warn!("failed to flush messages queued during inbound handling: {error}");
} }
@ -508,6 +615,58 @@ pub(crate) async fn handle_decoded_server_message(
Ok(()) Ok(())
} }
/// Answers a peer's session reset by opening a new session with them.
///
/// Our own record is retired first: the peer discarded the half that matches
/// it, so keeping it as the current state would only encrypt more messages
/// they cannot read. `establish_signal_session` then installs a session built
/// from their current prekey bundle and releases everything parked for it.
async fn rebuild_session_after_peer_reset(ctx: &Arc<Context>, from_user_id: i64) -> Result<()> {
let outcome = rebuild_session(ctx, from_user_id).await;
// `handle_plaintext_content` parked the receipt for this rebuild, and
// nothing else is coming to free it. A refused claim means a rebuild is
// already in flight or just finished, and a failed one leaves the send path
// to park the receipt again for the reason it actually failed on -- both
// are better than a receipt that waits for a peer who may never write
// again.
let database = ctx.app_db.read().await.clone();
release_deferred_receipts(&database, from_user_id).await?;
outcome
}
async fn rebuild_session(ctx: &Arc<Context>, from_user_id: i64) -> Result<()> {
let name = from_user_id.to_string();
let rust_database = ctx.rust_db.read().await.clone();
// The same budget the receiving side spends, so a pair of clients that
// cannot agree on a session stops trading rebuilds instead of looping.
if !SessionResetLimiter::claim(&rust_database.pool, &name, 1).await? {
return Ok(());
}
{
let engine = ctx.signal_engine.lock().await;
let engine = engine.as_ref().ok_or(TwonlyError::SignalIdentityNotFound)?;
engine.reset_session(&name, 1, false).await?;
}
ContactService::new(ctx)
.establish_signal_session(from_user_id, None)
.await?;
// The repair worked, so the next unrelated failure starts from a full
// budget rather than what this one spent.
SessionResetLimiter::clear(&rust_database.pool, &name, 1).await?;
tracing::info!(
from_user_id,
"rebuilt the signal session after a peer reset"
);
Ok(())
}
/// Dispatches already-decrypted client-to-client content to its concrete /// Dispatches already-decrypted client-to-client content to its concrete
/// feature module. Transport decoding and Signal decryption do not belong in /// feature module. Transport decoding and Signal decryption do not belong in
/// this dispatcher. /// this dispatcher.
@ -675,6 +834,15 @@ async fn handle_encrypted_inner(
groups::ensure_group_member(t, from_user_id, &group_id).await?; groups::ensure_group_member(t, from_user_id, &group_id).await?;
// `ensure_group_member` accepts a member the outgoing fan-out excludes, so
// a stale `leftGroup` row makes the group one-way without a word at either
// end. Their message says the row is wrong; the group server settles it.
// Scheduled, not awaited: it is a network round trip and this transaction
// holds the app database's one connection.
if Group::has_member_left(t, &group_id, from_user_id).await? {
GroupService::spawn_stale_membership_refresh(ctx, group_id.clone(), from_user_id);
}
if content.resend_group_public_key.is_some() { if content.resend_group_public_key.is_some() {
return groups::handle_resend_group_public_key(t, from_user_id, &group_id).await; return groups::handle_resend_group_public_key(t, from_user_id, &group_id).await;
} }

View file

@ -521,13 +521,49 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
return Ok(()); return Ok(());
}; };
// The server has no account for this contact any more, so preparing the // `account_deleted` is a latch: one `UserIdNotFound` on any contact-scoped
// payload would only fetch a prekey bundle that answers `UserIdNotFound` // request sets it, and this check runs before anything that could clear it,
// on every retry. Drop the receipt instead of queueing it forever. // so a peer who has since re-registered would stay unreachable forever --
if row.account_deleted != 0 { // every message dropped here, unsent, unacknowledged and unlogged. Ask the
Receipt::delete(&app_db.pool, receipt_id).await?; // server once more before believing it, and drop only what the server
return Ok(()); // positively denies.
} let row = if row.account_deleted != 0 {
match ContactService::new(ctx)
.establish_signal_session(row.contact_id, None)
.await
{
Err(TwonlyError::PeerAccountDeleted(contact_id)) => {
tracing::warn!(
receipt_id,
contact_id,
"dropping a message for an account the server does not know"
);
Receipt::delete(&app_db.pool, receipt_id).await?;
return Ok(());
}
// The account exists; it has just published no bundle. An existing
// session may still encrypt for it, and if not, preparing the
// payload parks the receipt for the right reason.
Ok(()) | Err(TwonlyError::PeerHasNoPrekeyBundle(_)) => {
tracing::info!(
contact_id = row.contact_id,
"contact is registered after all; clearing the deleted-account mark"
);
}
// Anything else -- a network failure above all -- must not be read
// as a deleted account. Leave the receipt queued for a later sweep.
Err(error) => return Err(error),
}
// `establish_signal_session` cleared the mark and may have changed
// `signal_version`, so the payload is prepared from the current row.
match load_queued_receipt_row(&app_db.pool, receipt_id).await? {
Some(row) => row,
None => return Ok(()),
}
} else {
row
};
let receipt = match prepare_queued_receipt_from_row(ctx, receipt_id, row).await { let receipt = match prepare_queued_receipt_from_row(ctx, receipt_id, row).await {
Ok(receipt) => receipt, Ok(receipt) => receipt,
@ -770,33 +806,57 @@ pub(crate) async fn handle_sender_delivery_receipt(
Ok(()) Ok(())
} }
/// Requeues the receipt a peer could not process, under a new ID.
///
/// Returns whether the peer also retired their Signal session, in which case
/// the caller has to rebuild one before the requeued receipt can go out. The
/// receipt is parked here so the flush that follows the caller's commit cannot
/// send it through the session the peer just threw away.
pub async fn handle_plaintext_content( pub async fn handle_plaintext_content(
_ctx: &Context, _ctx: &Context,
transaction: &mut Transaction<'_, Sqlite>, transaction: &mut Transaction<'_, Sqlite>,
from_user_id: i64, from_user_id: i64,
receipt_id: &str, receipt_id: &str,
plaintext: proto::PlaintextContent, plaintext: proto::PlaintextContent,
) -> Result<Option<String>> { ) -> Result<bool> {
if plaintext.decryption_error_message.is_some() || plaintext.retry_control_error.is_some() { if plaintext.decryption_error_message.is_none() && plaintext.retry_control_error.is_none() {
let new_receipt_id = uuid::Uuid::new_v4().to_string(); return Ok(false);
sqlx::query!(
r#"
UPDATE receipts
SET receipt_id = ?,
mark_for_retry = CAST(strftime('%s', 'now') AS INTEGER),
retry_count = retry_count + 1,
ack_by_server_at = NULL
WHERE receipt_id = ? AND contact_id = ?
"#,
new_receipt_id,
receipt_id,
from_user_id,
)
.execute(&mut **transaction)
.await?;
return Ok(Some(new_receipt_id));
} }
Ok(None)
// An unknown type comes from a client newer than this one. Treating it as
// a plain retry request is what every release before session resets did,
// so it stays the fallback rather than an error.
let rebuild_session = plaintext.decryption_error_message.is_some_and(|error| {
proto::plaintext_content::decryption_error_message::Type::try_from(error.r#type)
== Ok(proto::plaintext_content::decryption_error_message::Type::SessionResetRequired)
});
let new_receipt_id = uuid::Uuid::new_v4().to_string();
sqlx::query!(
r#"
UPDATE receipts
SET receipt_id = ?,
mark_for_retry = CAST(strftime('%s', 'now') AS INTEGER),
retry_count = retry_count + 1,
ack_by_server_at = NULL,
-- A rebuild needs a server round-trip, so it cannot run inside this
-- transaction. `release_deferred_receipts` frees the receipt once
-- the new session stands.
deferred_until_session = CASE
WHEN ? THEN CAST(strftime('%s', 'now') AS INTEGER)
ELSE deferred_until_session
END
WHERE receipt_id = ? AND contact_id = ?
"#,
new_receipt_id,
rebuild_session,
receipt_id,
from_user_id,
)
.execute(&mut **transaction)
.await?;
Ok(rebuild_session)
} }
#[cfg(test)] #[cfg(test)]

View file

@ -25,6 +25,12 @@ message PlaintextContent {
enum Type { enum Type {
UNKNOWN = 0; UNKNOWN = 0;
PREKEY_UNKNOWN = 1; PREKEY_UNKNOWN = 1;
// The receiver's session could not open the message and will never be
// able to, so it retired the session. Resending the same ciphertext is
// pointless: the sender has to build a new session from a fresh prekey
// bundle first. Older clients ignore the type and only retry, which is
// the previous behaviour.
SESSION_RESET_REQUIRED = 2;
} }
Type type = 1; Type type = 1;
} }

View file

@ -324,6 +324,24 @@ impl BackupArchive {
ctx.replace_rust_database(rust_database, &key_manager) ctx.replace_rust_database(rust_database, &key_manager)
.await?; .await?;
// The restored `user.json` says prekeys were published recently, but
// the ones the server hands out may have been uploaded after this
// archive was written, and their private halves are not in it. Sessions
// peers build from those bundles could never be opened. Clearing the
// marks makes the next `on_connected` publish a signed prekey and a
// fresh batch of PQC prekeys that this database actually holds.
//
// Sessions restored alongside them are left as they are: they are only
// broken for peers who ratcheted past this archive, and the first
// message that fails to decrypt resets that peer's session on its own.
// See `signal::reset`.
if let Err(error) = crate::user_config::UserConfig::update(ctx, |config| {
config.signal_last_signed_pre_key_updated = None;
config.signal_last_pqc_pre_keys_uploaded = None;
}) {
tracing::warn!("could not schedule a prekey republish after the restore: {error}");
}
std::fs::remove_dir_all(&restore_temp_dir)?; std::fs::remove_dir_all(&restore_temp_dir)?;
Ok(()) Ok(())

View file

@ -0,0 +1,10 @@
-- Records that a claimed message never decrypted.
--
-- The claim is committed before the message is handled, so a redelivery is not
-- processed twice. Without this column a claim left behind by a failed
-- decryption looks exactly like one left by a message that was handled, and the
-- redelivery is dropped -- which is how a peer whose session broke could never
-- reach the decrypt path again, and so never trigger the session reset that
-- would have repaired it.
ALTER TABLE received_receipts
ADD COLUMN decryption_failed INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,11 @@
-- Drops the column added by 0017.
--
-- It marked claims left behind by a failed decryption so their redelivery
-- would decrypt again. That distinction turned out to be both unknowable and
-- unnecessary: a claim carrying no parked plaintext says nothing about whether
-- its message was handled, and claims written before the column existed could
-- never be marked at all. Every redelivered encrypted message is decrypted
-- again instead, and the outcome answers the question -- it opens, or it comes
-- back as a duplicate the ratchet already consumed.
ALTER TABLE received_receipts
DROP COLUMN decryption_failed;

View file

@ -262,6 +262,33 @@ impl Group {
Ok(is_direct) Ok(is_direct)
} }
/// Whether this member is recorded as having left the group.
///
/// [`Self::is_member`] deliberately ignores `member_state` so a member who
/// left can still be seen to have written; the outgoing fan-out does not.
/// This is the seam between the two, for callers that need to know the
/// directions disagree.
pub async fn has_member_left(
tr: &mut Transaction<'_, Sqlite>,
group_id: &str,
contact_id: i64,
) -> Result<bool> {
Ok(sqlx::query_scalar!(
r#"
SELECT EXISTS(
SELECT 1
FROM group_members
WHERE group_id = ? AND contact_id = ? AND member_state = 'leftGroup'
)
"#,
group_id,
contact_id,
)
.fetch_one(&mut **tr)
.await?
!= 0)
}
pub async fn is_member( pub async fn is_member(
tr: &mut Transaction<'_, Sqlite>, tr: &mut Transaction<'_, Sqlite>,
group_id: &str, group_id: &str,

View file

@ -0,0 +1,14 @@
-- Bookkeeping for Signal session resets.
--
-- A reset asks the peer to open a fresh session. If their side cannot honour
-- that -- a mismatched identity key, a peer stuck on a state we reject -- both
-- clients would keep answering each other's reset requests forever. These rows
-- bound that: one reset per peer per cooldown, and a capped number per window.
CREATE TABLE IF NOT EXISTS signal_session_resets (
name TEXT NOT NULL,
device_id INTEGER NOT NULL,
last_reset_at INTEGER NOT NULL,
window_started_at INTEGER NOT NULL,
resets_in_window INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (name, device_id)
);

View file

@ -115,6 +115,13 @@ pub enum TwonlyError {
#[error("peer {0} has published no prekey bundle")] #[error("peer {0} has published no prekey bundle")]
PeerHasNoPrekeyBundle(i64), PeerHasNoPrekeyBundle(i64),
/// The server does not know this account. Distinct from every other reason
/// a lookup can fail, because it is the only one that justifies dropping a
/// message instead of retrying it: a network error must never be mistaken
/// for a peer who no longer exists.
#[error("the server does not know peer {0}")]
PeerAccountDeleted(i64),
#[error("{0}")] #[error("{0}")]
IoError(#[from] std::io::Error), IoError(#[from] std::io::Error),
@ -153,6 +160,20 @@ pub enum TwonlyError {
#[error("Signal protocol error: {0}")] #[error("Signal protocol error: {0}")]
Signal(String), Signal(String),
/// The Signal session for a peer can no longer decrypt what they send:
/// the ratchet states have diverged past repair, the session record is
/// structurally invalid, or it is gone entirely. Retrying the same
/// ciphertext against it never succeeds, so callers reset the session and
/// ask the peer to open a fresh one. See `signal::reset`.
#[error("Signal session is unusable: {0}")]
SignalSessionUnusable(String),
/// The peer sent a message this session already ratcheted past. The
/// session is healthy; the message is simply a duplicate and must not
/// trigger a reset.
#[error("duplicated Signal message: {0}")]
SignalDuplicateMessage(String),
} }
impl From<String> for TwonlyError { impl From<String> for TwonlyError {
@ -169,6 +190,32 @@ impl From<aes_gcm::Error> for TwonlyError {
impl From<libsignal_protocol::SignalProtocolError> for TwonlyError { impl From<libsignal_protocol::SignalProtocolError> for TwonlyError {
fn from(error: libsignal_protocol::SignalProtocolError) -> Self { fn from(error: libsignal_protocol::SignalProtocolError) -> Self {
TwonlyError::Signal(error.to_string()) use libsignal_protocol::SignalProtocolError as Error;
let message = error.to_string();
match error {
// libsignal collapses every failed candidate session into a single
// `InvalidMessage`; the per-session causes ("post-quantum ratchet
// error: epoch not in valid range", "message keys not found", ...)
// only reach its log. Whatever the cause, no session in the record
// could open the message, which is exactly the reset condition.
Error::InvalidMessage(..)
| Error::SessionNotFound(..)
| Error::InvalidSessionStructure(..)
| Error::InvalidRegistrationId(..)
// A restored backup can lack the private prekey for a bundle the
// server still hands out, so inbound sessions built from it can
// never be opened either.
| Error::InvalidPreKeyId
| Error::InvalidSignedPreKeyId
| Error::InvalidKyberPreKeyId => TwonlyError::SignalSessionUnusable(message),
Error::DuplicatedMessage(..) => TwonlyError::SignalDuplicateMessage(message),
// An identity change is not a broken session: resetting would
// silently trust a new key. It stays a plain Signal error so the
// key-verification flow keeps owning it.
_ => TwonlyError::Signal(message),
}
} }
} }

View file

@ -84,6 +84,12 @@ impl ContactService {
let user = match Server::get_user_by_id(&self.ctx, user_id).await? { let user = match Server::get_user_by_id(&self.ctx, user_id).await? {
ServerResult::Ok(user) => user, ServerResult::Ok(user) => user,
ServerResult::ErrorCode(code) => { ServerResult::ErrorCode(code) => {
// Typed, because the send path drops a message only for an
// account the server positively denies. Anything else is a
// reason to try again later.
if code == crate::api::proto::error::ErrorCode::UserIdNotFound as i32 {
return Err(TwonlyError::PeerAccountDeleted(user_id));
}
return Err(TwonlyError::Generic(format!( return Err(TwonlyError::Generic(format!(
"Could not load prekey bundle for user {user_id}: server error {code}" "Could not load prekey bundle for user {user_id}: server error {code}"
))); )));

View file

@ -98,6 +98,35 @@ struct PendingCompaction {
static PUBLIC_KEY_REQUESTS: LazyLock<Mutex<HashMap<(String, i64), Instant>>> = static PUBLIC_KEY_REQUESTS: LazyLock<Mutex<HashMap<(String, i64), Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); LazyLock::new(|| Mutex::new(HashMap::new()));
/// When each group was last refreshed because a member it excludes wrote to it.
///
/// Process-local for the same reason as [`PUBLIC_KEY_REQUESTS`]: it exists to
/// keep a busy group from refetching its state on every inbound message while
/// the server still reports the member as gone, not to remember across
/// restarts.
static STALE_MEMBERSHIP_REFRESHES: LazyLock<Mutex<HashMap<String, Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Shortest gap between two refreshes prompted by the same group.
const STALE_MEMBERSHIP_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
/// Records a refresh and reports whether it should be fetched at all.
fn claim_stale_membership_refresh(group_id: &str) -> bool {
let mut refreshes = match STALE_MEMBERSHIP_REFRESHES.lock() {
Ok(refreshes) => refreshes,
Err(poisoned) => poisoned.into_inner(),
};
let now = Instant::now();
if refreshes
.get(group_id)
.is_some_and(|at| now.duration_since(*at) < STALE_MEMBERSHIP_REFRESH_INTERVAL)
{
return false;
}
refreshes.insert(group_id.to_owned(), now);
true
}
/// Records a request and reports whether it should be sent at all. /// Records a request and reports whether it should be sent at all.
fn claim_public_key_request(group_id: &str, contact_id: i64, force: bool) -> bool { fn claim_public_key_request(group_id: &str, contact_id: i64, force: bool) -> bool {
let mut requests = match PUBLIC_KEY_REQUESTS.lock() { let mut requests = match PUBLIC_KEY_REQUESTS.lock() {
@ -858,6 +887,32 @@ impl GroupService {
/// lock, invert the lock order this module depends on. Running it detached /// lock, invert the lock order this module depends on. Running it detached
/// lets the caller commit first; every call site treats the refresh as best /// lets the caller commit first; every call site treats the refresh as best
/// effort already. /// effort already.
/// Reconciles a group whose local membership contradicts what the group
/// itself is doing.
///
/// A member recorded as having left is excluded from every outgoing group
/// message, while `Group::is_member` accepts what they send: they can talk
/// to us and we can never talk to them, and nothing says so at either end.
/// A message from such a member is proof the local row is stale -- a
/// restored backup predating their join, or a membership update lost while
/// their session was broken. The group server holds the authority, so the
/// state is refetched rather than the message being taken as permission to
/// rejoin them; if the server agrees they are gone, nothing changes and the
/// interval keeps a busy group from asking again.
pub fn spawn_stale_membership_refresh(ctx: &Arc<Context>, group_id: String, contact_id: i64) {
if !claim_stale_membership_refresh(&group_id) {
return;
}
tracing::info!(
group_id,
contact_id,
"a group member recorded as having left is still writing to the group; \
refreshing the group state"
);
Self::spawn_state_refresh(ctx, Some(group_id));
}
pub fn spawn_state_refresh(ctx: &Arc<Context>, group_id: Option<String>) { pub fn spawn_state_refresh(ctx: &Arc<Context>, group_id: Option<String>) {
let ctx = ctx.clone(); let ctx = ctx.clone();
tokio::spawn(async move { tokio::spawn(async move {

View file

@ -93,14 +93,37 @@ impl MessageService {
sqlx::query!("UPDATE groups SET last_message_exchange = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ?", group_id) sqlx::query!("UPDATE groups SET last_message_exchange = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ?", group_id)
.execute(&mut **t).await?; .execute(&mut **t).await?;
} }
let members = sqlx::query_scalar!( // Members are filtered here rather than in SQL so an excluded one can be
r#"SELECT contact_id FROM group_members // named. A member the sender believes has left is absent from every
WHERE group_id = ? AND (member_state IS NULL OR member_state != 'leftGroup')"#, // group message, and silently: a membership row the group has moved
// past -- a restored backup holding a stale state, say -- looks exactly
// like a delivery that failed, from either end.
let rows = sqlx::query!(
r#"SELECT contact_id, member_state FROM group_members WHERE group_id = ?"#,
group_id, group_id,
) )
.fetch_all(&mut **t) .fetch_all(&mut **t)
.await?; .await?;
let mut members = Vec::with_capacity(rows.len());
for row in rows {
if row.member_state.as_deref() == Some("leftGroup") {
tracing::info!(
group_id,
contact_id = row.contact_id,
"not sending to a group member recorded as having left"
);
continue;
}
members.push(row.contact_id);
}
tracing::info!(
group_id,
members = members.len(),
"sending a group message to its members"
);
let bytes = content.encode_to_vec(); let bytes = content.encode_to_vec();
let mut receipt_ids = Vec::new(); let mut receipt_ids = Vec::new();
@ -123,6 +146,12 @@ impl MessageService {
.fetch_one(&mut **t) .fetch_one(&mut **t)
.await?; .await?;
if count > 10 { if count > 10 {
tracing::info!(
group_id,
contact_id,
open_receipts = count,
"not sending to a group member with too many open receipts"
);
continue; continue;
} }
} }

View file

@ -10,8 +10,8 @@ use chrono::{Duration, Utc};
use libsignal_protocol::{ use libsignal_protocol::{
message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey, message_encrypt, process_prekey_bundle, CiphertextMessageType, DeviceId, GenericSignedPreKey,
IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle, IdentityKey, IdentityKeyPair, IdentityKeyStore, KyberPreKeyId, KyberPreKeyStore, PreKeyBundle,
PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SignalMessage, PreKeyId, PreKeySignalMessage, PreKeyStore, ProtocolAddress, PublicKey, SessionStore,
SignedPreKeyId, SignedPreKeyStore, Timestamp, SignalMessage, SignedPreKeyId, SignedPreKeyStore, Timestamp,
}; };
use std::sync::Arc; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@ -491,6 +491,71 @@ impl RustSignalEngine {
Ok(()) Ok(())
} }
/// Retires the session with a peer so a fresh one can replace it.
///
/// The current state is archived rather than dropped: libsignal keeps
/// trying archived states on decrypt, so messages the peer sent on chains
/// we can still follow keep arriving while the broken chain stops being
/// the one new traffic is measured against. With no current state left,
/// encrypting for the peer reports the session as missing, which
/// `encrypt_v2_with_session_recovery` already repairs from a fresh prekey
/// bundle, and an inbound prekey message installs a new session directly.
///
/// `hard` deletes the record instead, dropping the archived states too.
/// Only a user-initiated reset should ask for that: it makes every message
/// still in flight from the peer undecryptable.
///
/// Returns whether a session existed.
pub async fn reset_session(&self, name: &str, device_id: u32, hard: bool) -> Result<bool> {
let mut store_guard = self.store.lock().await;
let store = &mut *store_guard;
let d_id = DeviceId::try_from(device_id)
.map_err(|_| TwonlyError::Generic(format!("Invalid device id: {}", device_id)))?;
let address = ProtocolAddress::new(name.to_owned(), d_id);
if hard {
let device_id_value: u32 = device_id;
let deleted = sqlx::query!(
r#"DELETE FROM signal_sessions WHERE name = ? AND device_id = ?"#,
name,
device_id_value,
)
.execute(&store.pool)
.await?
.rows_affected();
tracing::warn!(name, device_id, "deleted the signal session");
return Ok(deleted != 0);
}
let Some(mut record) = store
.session_store
.load_session(&address)
.assert_send()
.await
.map_err(|e| TwonlyError::Signal(e.to_string()))?
else {
return Ok(false);
};
// Idempotent: a record whose current state was already archived is left
// alone by libsignal, and the archived list is capped, so repeated
// resets for the same peer cannot grow the record without bound.
record
.archive_current_state()
.map_err(|e| TwonlyError::Signal(e.to_string()))?;
store
.session_store
.store_session(&address, &record)
.assert_send()
.await
.map_err(|e| TwonlyError::Signal(e.to_string()))?;
tracing::warn!(name, device_id, "archived the signal session for a reset");
Ok(true)
}
pub async fn encrypt_message( pub async fn encrypt_message(
&self, &self,
name: String, name: String,
@ -574,7 +639,10 @@ impl RustSignalEngine {
) )
.assert_send() .assert_send()
.await .await
.map_err(|e| TwonlyError::Signal(e.to_string()))? // Classified rather than stringified: the caller has to tell a
// session that can never open this message from a duplicate or
// an identity change. See `TwonlyError::SignalSessionUnusable`.
.map_err(TwonlyError::from)?
} }
CiphertextMessageType::PreKey => { CiphertextMessageType::PreKey => {
let message = PreKeySignalMessage::try_from(ciphertext) let message = PreKeySignalMessage::try_from(ciphertext)
@ -592,7 +660,7 @@ impl RustSignalEngine {
) )
.assert_send() .assert_send()
.await .await
.map_err(|e| TwonlyError::Signal(e.to_string()))? .map_err(TwonlyError::from)?
} }
_ => { _ => {
return Err(TwonlyError::Signal("Invalid message type".to_string())); return Err(TwonlyError::Signal("Invalid message type".to_string()));
@ -699,6 +767,145 @@ mod tests {
assert_eq!(plaintext.to_vec(), decrypted); assert_eq!(plaintext.to_vec(), decrypted);
} }
/// Encrypts on one engine and decrypts on the other.
async fn ping(from: &RustSignalEngine, to: &RustSignalEngine, text: &str) -> Result<Vec<u8>> {
let ciphertext = from
.encrypt_message(to.local_name.clone(), 1, text.as_bytes().to_vec())
.await?;
to.decrypt_message(from.local_name.clone(), 1, ciphertext)
.await
}
/// Overwrites a peer's session record, the way restoring a backup puts an
/// older signal database back in place.
async fn restore_session(engine: &RustSignalEngine, name: &str, record: &[u8]) {
let store = engine.store.lock().await;
sqlx::query!(
"UPDATE signal_sessions SET record_bytes = ? WHERE name = ? AND device_id = 1",
record,
name,
)
.execute(&store.pool)
.await
.unwrap();
}
async fn session_record(engine: &RustSignalEngine, name: &str) -> Vec<u8> {
let store = engine.store.lock().await;
sqlx::query_scalar!(
"SELECT record_bytes FROM signal_sessions WHERE name = ? AND device_id = 1",
name,
)
.fetch_one(&store.pool)
.await
.unwrap()
}
/// A restored backup rewinds one side's ratchet behind the other's. The
/// session cannot be decrypted with again, the failure is reported as such,
/// and a reset plus a fresh prekey bundle brings the pair back.
#[tokio::test]
async fn a_restored_session_is_reported_unusable_and_recovers_after_a_reset() {
let (alice, _alice_dir) = create_test_engine("alice").await;
let (bob, _bob_dir) = create_test_engine("bob").await;
alice
.process_prekey_bundle("bob".to_string(), 1, bob.generate_bundle().await.unwrap())
.await
.unwrap();
assert_eq!(ping(&alice, &bob, "one").await.unwrap(), b"one");
assert_eq!(ping(&bob, &alice, "two").await.unwrap(), b"two");
// The state a backup taken at this point would carry.
let backup = session_record(&bob, "alice").await;
// Both sides keep ratcheting, so the archive falls behind a ratchet
// step whose private key it never held.
for _ in 0..3 {
ping(&alice, &bob, "before").await.unwrap();
ping(&bob, &alice, "before").await.unwrap();
}
restore_session(&bob, "alice", &backup).await;
let ciphertext = alice
.encrypt_message("bob".to_string(), 1, b"after the restore".to_vec())
.await
.unwrap();
let error = bob
.decrypt_message("alice".to_string(), 1, ciphertext.clone())
.await
.expect_err("a rewound session cannot open a message from the peer's current chain");
assert!(
matches!(error, TwonlyError::SignalSessionUnusable(_)),
"a diverged ratchet has to be classified as unusable, got: {error:?}"
);
// Retrying the very same ciphertext is what the old error path did, and
// it fails the same way -- which is why a reset is the only repair.
assert!(bob
.decrypt_message("alice".to_string(), 1, ciphertext)
.await
.is_err());
// Bob retires his half and asks Alice for a new session; Alice retires
// hers and builds one from Bob's current bundle.
assert!(bob.reset_session("alice", 1, false).await.unwrap());
assert!(alice.reset_session("bob", 1, false).await.unwrap());
alice
.process_prekey_bundle("bob".to_string(), 1, bob.generate_bundle().await.unwrap())
.await
.unwrap();
assert_eq!(ping(&alice, &bob, "after").await.unwrap(), b"after");
assert_eq!(ping(&bob, &alice, "back").await.unwrap(), b"back");
}
/// Archiving keeps the states a peer may still be sending on, so messages
/// that crossed the reset in flight are not lost with it.
#[tokio::test]
async fn a_reset_still_decrypts_messages_sent_before_it() {
let (alice, _alice_dir) = create_test_engine("alice").await;
let (bob, _bob_dir) = create_test_engine("bob").await;
alice
.process_prekey_bundle("bob".to_string(), 1, bob.generate_bundle().await.unwrap())
.await
.unwrap();
// Bob only has a record once he has received from Alice.
assert_eq!(ping(&alice, &bob, "hello").await.unwrap(), b"hello");
let in_flight = alice
.encrypt_message("bob".to_string(), 1, b"sent before the reset".to_vec())
.await
.unwrap();
assert!(bob.reset_session("alice", 1, false).await.unwrap());
assert_eq!(
bob.decrypt_message("alice".to_string(), 1, in_flight)
.await
.unwrap(),
b"sent before the reset"
);
// A hard reset is the user-initiated one and does drop them.
let in_flight = alice
.encrypt_message("bob".to_string(), 1, b"dropped".to_vec())
.await
.unwrap();
assert!(bob.reset_session("alice", 1, true).await.unwrap());
assert!(bob
.decrypt_message("alice".to_string(), 1, in_flight)
.await
.is_err());
// Resetting a peer we have no session with is not an error.
assert!(!bob.reset_session("carol", 1, false).await.unwrap());
}
#[tokio::test] #[tokio::test]
async fn test_twonly_api_100_messages() -> std::result::Result<(), Box<dyn std::error::Error>> { async fn test_twonly_api_100_messages() -> std::result::Result<(), Box<dyn std::error::Error>> {
use crate::database::signal::Database; use crate::database::signal::Database;

View file

@ -5,4 +5,5 @@
pub mod assert_send; pub mod assert_send;
pub mod engine; pub mod engine;
pub mod reset;
pub mod store; pub mod store;

192
rust/src/signal/reset.rs Normal file
View file

@ -0,0 +1,192 @@
/*
* Copyright (c) 2026, Tobias Müller git@tsmr.eu
*
*/
//! Rate limiting for Signal session resets.
//!
//! Resetting a session is a repair both sides have to agree on: we retire our
//! record and ask the peer to open a new one. When the peer cannot honour that
//! -- their identity key no longer matches the one we trust, or their own
//! record is equally broken -- every message would trigger another reset
//! request in each direction. The counters here bound that loop: a peer gets at
//! most one reset per [`RESET_COOLDOWN`], and at most [`MAX_RESETS_PER_WINDOW`]
//! within [`RESET_WINDOW`], after which the message is dropped and the failure
//! is left to the user-visible retry path.
use crate::error::Result;
use sqlx::SqlitePool;
/// Shortest gap between two resets of the same session. A reset needs a server
/// round-trip and a message each way, so anything faster only stacks duplicate
/// repairs for a repair already in flight.
const RESET_COOLDOWN: i64 = 60;
/// Window the reset budget is counted over.
const RESET_WINDOW: i64 = 60 * 60;
/// Resets allowed per peer per [`RESET_WINDOW`]. A repair that works needs one;
/// a handful covers messages that crossed it in flight. Beyond that the session
/// is not the problem and resetting again will not make it one.
const MAX_RESETS_PER_WINDOW: i64 = 5;
pub(crate) struct SessionResetLimiter;
impl SessionResetLimiter {
/// Claims permission to reset the session with `name`/`device_id`.
///
/// Returns `false` when the peer is inside the cooldown or has spent its
/// budget for the window, in which case the caller must leave the session
/// alone and let the message fail.
pub(crate) async fn claim(pool: &SqlitePool, name: &str, device_id: u32) -> Result<bool> {
let now = crate::utils::current_time().timestamp();
let window_start = now - RESET_WINDOW;
// One statement so two inbound messages racing on the same peer cannot
// both read a stale count and each claim the last slot.
let claimed = sqlx::query!(
r#"
INSERT INTO signal_session_resets(
name, device_id, last_reset_at, window_started_at, resets_in_window
)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(name, device_id) DO UPDATE SET
last_reset_at = excluded.last_reset_at,
-- A window that has run out starts over with this reset as its
-- first, so a peer that broke months ago is not still blocked.
window_started_at = CASE
WHEN signal_session_resets.window_started_at <= ?
THEN excluded.last_reset_at
ELSE signal_session_resets.window_started_at
END,
resets_in_window = CASE
WHEN signal_session_resets.window_started_at <= ? THEN 1
ELSE signal_session_resets.resets_in_window + 1
END
-- Every SET expression above reads the row as it was before the
-- update, and this predicate decides whether the update happens at
-- all: zero affected rows means the claim was refused. A first
-- reset for a peer takes the INSERT path and is always allowed.
WHERE signal_session_resets.last_reset_at <= ?
AND (signal_session_resets.window_started_at <= ?
OR signal_session_resets.resets_in_window < ?)
"#,
name,
device_id,
now,
now,
window_start,
window_start,
// Cooldown: the update only lands once the previous reset is old
// enough for a repair to have had a chance to complete.
now - RESET_COOLDOWN,
window_start,
MAX_RESETS_PER_WINDOW,
)
.execute(pool)
.await?
.rows_affected()
!= 0;
if !claimed {
tracing::warn!(
name,
device_id,
"signal session reset suppressed: the peer is inside the cooldown or over budget"
);
}
Ok(claimed)
}
/// Forgets the reset history for a peer, so a session that works again
/// starts from a full budget.
pub(crate) async fn clear(pool: &SqlitePool, name: &str, device_id: u32) -> Result<()> {
sqlx::query!(
r#"DELETE FROM signal_session_resets WHERE name = ? AND device_id = ?"#,
name,
device_id,
)
.execute(pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::signal::Database;
async fn test_pool() -> SqlitePool {
let database = Database::new(&"sqlite::memory:".to_string(), None, false)
.await
.unwrap();
database.run_migrations().await.unwrap();
database.pool.clone()
}
/// Moves a peer's bookkeeping back in time, standing in for a cooldown or a
/// window that has since elapsed.
async fn age_by(pool: &SqlitePool, name: &str, seconds: i64) {
sqlx::query!(
r#"
UPDATE signal_session_resets
SET last_reset_at = last_reset_at - ?, window_started_at = window_started_at - ?
WHERE name = ?
"#,
seconds,
seconds,
name,
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn the_cooldown_holds_off_a_second_reset_for_the_same_peer() {
let pool = test_pool().await;
assert!(SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
// The repair from the first claim is still in flight.
assert!(!SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
// A different peer has its own budget.
assert!(SessionResetLimiter::claim(&pool, "2", 1).await.unwrap());
age_by(&pool, "1", RESET_COOLDOWN).await;
assert!(SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
}
#[tokio::test]
async fn a_peer_that_cannot_be_repaired_runs_out_of_budget() {
let pool = test_pool().await;
for reset in 0..MAX_RESETS_PER_WINDOW {
assert!(
SessionResetLimiter::claim(&pool, "1", 1).await.unwrap(),
"reset {reset} is within the budget"
);
age_by(&pool, "1", RESET_COOLDOWN).await;
}
// Out of budget: the cooldown has passed, but the window has not.
assert!(!SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
// Ageing past the window starts the budget over, so a peer that broke
// long ago is not blocked forever.
age_by(&pool, "1", RESET_WINDOW).await;
assert!(SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
}
#[tokio::test]
async fn a_repaired_session_starts_from_a_full_budget() {
let pool = test_pool().await;
assert!(SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
SessionResetLimiter::clear(&pool, "1", 1).await.unwrap();
// Neither the cooldown nor the spent budget survives the clear.
assert!(SessionResetLimiter::claim(&pool, "1", 1).await.unwrap());
}
}

View file

@ -1,6 +1,10 @@
use super::{init_tracing, Tester}; use super::{init_tracing, Tester};
use prost::Message as _;
use rust_lib_twonly::api::messages::incoming::handle_decoded_server_message;
use rust_lib_twonly::api::messages::outgoing::send_c2c_message_to_contact;
use rust_lib_twonly::api::proto::client::{self as proto, encrypted_content};
use rust_lib_twonly::bridge::api::ApiConnectionState; use rust_lib_twonly::bridge::api::ApiConnectionState;
use rust_lib_twonly::database::app::tables::Group; use rust_lib_twonly::database::app::tables::{Group, Receipt};
use rust_lib_twonly::services::contacts::ContactService; use rust_lib_twonly::services::contacts::ContactService;
use rust_lib_twonly::services::messages::MessageService; use rust_lib_twonly::services::messages::MessageService;
@ -66,3 +70,184 @@ async fn test_signal_session_auto_recovery_on_missing_session() -> anyhow::Resul
Ok(()) Ok(())
} }
/// A restored backup rewinds one side's ratchet behind the peer's, so nothing
/// the peer sends afterwards decrypts and resending it never helps. The
/// receiver has to retire its session, the sender has to build a new one from
/// a fresh prekey bundle, and the parked message has to arrive over it.
#[tokio::test]
async fn test_signal_session_reset_after_a_restored_backup() -> anyhow::Result<()> {
init_tracing();
let tester_a = create_authenticated_tester().await?;
let tester_b = create_authenticated_tester().await?;
ContactService::new(&tester_a.context)
.request_by_username(tester_b.username.clone(), true)
.await?;
tester_b
.wait_for_contact_state(tester_a.user_id, false, true)
.await?;
ContactService::new(&tester_b.context)
.accept_request(tester_a.user_id, true)
.await?;
tester_a
.wait_for_contact_state(tester_b.user_id, true, false)
.await?;
let group_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id);
let message_id = MessageService::new(&tester_a.context)
.insert_and_send_text(group_id.clone(), "Before the backup".into(), None, None)
.await?;
tester_b
.wait_for_text_message(&message_id, tester_a.user_id, "Before the backup")
.await?;
// The session state a backup taken at this point would hold.
let name_a = tester_a.user_id.to_string();
let rust_db_b = tester_b.context.rust_db.read().await.clone();
let backup = sqlx::query_scalar!(
"SELECT record_bytes FROM signal_sessions WHERE name = ? AND device_id = 1",
name_a,
)
.fetch_one(&rust_db_b.pool)
.await?;
// Both sides ratchet on, past the step the archive was taken at.
for round in 0..2 {
let from_b = MessageService::new(&tester_b.context)
.insert_and_send_text(group_id.clone(), format!("B {round}"), None, None)
.await?;
tester_a
.wait_for_text_message(&from_b, tester_b.user_id, &format!("B {round}"))
.await?;
let from_a = MessageService::new(&tester_a.context)
.insert_and_send_text(group_id.clone(), format!("A {round}"), None, None)
.await?;
tester_b
.wait_for_text_message(&from_a, tester_a.user_id, &format!("A {round}"))
.await?;
}
// B restores the backup, putting its half of the session back in the past.
sqlx::query!(
"UPDATE signal_sessions SET record_bytes = ? WHERE name = ? AND device_id = 1",
backup,
name_a,
)
.execute(&rust_db_b.pool)
.await?;
// B cannot decrypt this and cannot be helped by a resend: it resets its
// session and asks A for a new one, A rebuilds from B's prekey bundle and
// sends the parked message over it.
let message_id = MessageService::new(&tester_a.context)
.insert_and_send_text(group_id.clone(), "After the restore".into(), None, None)
.await?;
tester_b
.wait_for_text_message(&message_id, tester_a.user_id, "After the restore")
.await?;
// The repaired session carries traffic in both directions.
let message_id = MessageService::new(&tester_b.context)
.insert_and_send_text(
group_id.clone(),
"Reply on the new session".into(),
None,
None,
)
.await?;
tester_a
.wait_for_text_message(&message_id, tester_b.user_id, "Reply on the new session")
.await?;
Ok(())
}
/// A claim whose message was never handled -- the trace a failed decryption
/// leaves -- must not swallow the redelivery.
///
/// The claim exists to stop a message being processed twice, but it is written
/// before the message is handled, so a decryption that failed leaves exactly
/// the same trace as one that succeeded. Dropping the redelivery on that
/// ambiguity loses the message, and answers the peer with nothing, so they
/// resend the same receipt indefinitely.
#[tokio::test]
async fn test_a_claimed_but_unhandled_message_is_delivered_on_redelivery() -> anyhow::Result<()> {
init_tracing();
let tester_a = create_authenticated_tester().await?;
let tester_b = create_authenticated_tester().await?;
ContactService::new(&tester_a.context)
.request_by_username(tester_b.username.clone(), true)
.await?;
tester_b
.wait_for_contact_state(tester_a.user_id, false, true)
.await?;
ContactService::new(&tester_b.context)
.accept_request(tester_a.user_id, true)
.await?;
tester_a
.wait_for_contact_state(tester_b.user_id, true, false)
.await?;
let group_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id);
let sender_message_id = uuid::Uuid::new_v4().to_string();
// A encrypts the message without sending it, so the test controls when B
// sees it and can claim its receipt first.
let content = proto::EncryptedContent {
group_id: Some(group_id.clone()),
text_message: Some(encrypted_content::TextMessage {
sender_message_id: sender_message_id.clone(),
text: "Claimed but never handled".into(),
timestamp: chrono::Utc::now().timestamp_millis(),
quote_message_id: None,
additional_message_data: None,
}),
..Default::default()
};
let encoded = send_c2c_message_to_contact()
.ctx(&tester_a.context)
.contact_id(tester_b.user_id)
.encrypted_content(content.encode_to_vec())
.only_return_encrypted_data(true)
.call()
.await?
.expect("the encrypted message is returned rather than sent");
let message = proto::Message::decode(encoded.as_slice())?;
// B claims the receipt and handles nothing, exactly as a decryption that
// failed leaves it.
{
let database = tester_b.context.app_db.read().await.clone();
let mut t = database.pool.begin().await?;
assert!(Receipt::claim_received(&mut t, &message.receipt_id).await?);
t.commit().await?;
}
// The claim must not swallow it: this content has never been handled.
handle_decoded_server_message(&tester_b.context, tester_a.user_id, message.clone()).await?;
tester_b
.wait_for_text_message(
&sender_message_id,
tester_a.user_id,
"Claimed but never handled",
)
.await?;
// Now it genuinely has been handled, and a further copy is the duplicate it
// looks like: the ratchet has consumed it, so it is acknowledged rather
// than delivered a second time.
handle_decoded_server_message(&tester_b.context, tester_a.user_id, message).await?;
let database = tester_b.context.app_db.read().await.clone();
let copies = sqlx::query_scalar!(
"SELECT COUNT(*) FROM messages WHERE message_id = ?",
sender_message_id,
)
.fetch_one(&database.pool)
.await?;
assert_eq!(copies, 1, "a redelivered message must not be stored twice");
Ok(())
}