fix legacy upgrade issue

This commit is contained in:
otsmr 2026-09-25 01:22:16 +02:00
parent c4f99fd96d
commit c25d1eccbe
20 changed files with 500 additions and 42 deletions

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM signal_session_resets",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "0134b965d7cbf9feda5bfc93bc410d91414a44251857ba9b462c99ff88c104f3"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT group_id FROM groups ORDER BY total_media_counter DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "group_id",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "groups",
"name": "group_id"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "06fdf3188c162f0cc9240c17506c3f2afc80d819583d005471e2c9968dca85e2"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR REPLACE INTO receipts(receipt_id, contact_id, message, contact_will_sends_receipt, wake_receiver)\n VALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "141c8905f5d1f47fdbd88a007115dda16edadf2e6f92e7b190e99925870bdc18"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO signal_identities(name, identity_key, timestamp)\n VALUES('restored-peer', x'040506', 1)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "1d5f5de6b4d541014f651aa00a2fc91cf407fef2c34a5248fca3f02dc0a4c06e"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) AS \"count!: i64\" FROM signal_sessions",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "34ad29e5771ec73110b509a063ff69e5f4b349c2efab46a250a1952ea4e7ade5"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO receipts(receipt_id, contact_id, message, contact_will_sends_receipt, wake_receiver)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(receipt_id) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "36b3701a17e020cc774c15ea84267e5f99d53ac3aca4adb323697cb87e683b1f"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO legacy_upgrade_requests(contact_id, requested_at)\n VALUES (?, ?)\n ON CONFLICT(contact_id) DO UPDATE SET requested_at = excluded.requested_at\n WHERE legacy_upgrade_requests.requested_at <= ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "420d5c24bbac17a8f4db4088515782c5576ea708c9168ea2d05babd8c5a175f4"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM signal_sessions",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "43583f8246016d15d9875c4d33855317c41ae07af5676228c0533724590ec6ed"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO signal_sessions(name, device_id, record_bytes)\n VALUES('restored-peer', 1, x'010203')\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "74323299e0c048f9395ead9775a1e4b61fcc935db89378e80f30344b8c115a81"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT identity_key FROM signal_identities WHERE name = 'restored-peer'",
"describe": {
"columns": [
{
"name": "identity_key",
"ordinal": 0,
"type_info": "Blob",
"origin": {
"Table": {
"table": "signal_identities",
"name": "identity_key"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "74e4937df433524718139d6868c4fc474939148de0f885103e7a4f39b241b553"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) AS \"count!: i64\" FROM signal_session_resets",
"describe": {
"columns": [
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "75f560bc922045cfacf0b5ef1f7772372cea08954859dc64de961c715ce4c55a"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM receipts WHERE contact_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "83913e710fbd04e98cd6de21e7c917ed6472ef90230a10a69f74cb07d31a99fa"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE groups SET\n flame_counter = ?,\n last_flame_counter_change = ?,\n last_message_send = ?,\n last_message_received = ?\n WHERE group_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "a40ea0dd25f0a1909a08aa2e1db6c5f360646a931a7e747047364a81f1e67ab5"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT max_flame_counter, max_flame_counter_from FROM groups WHERE group_id = ?",
"describe": {
"columns": [
{
"name": "max_flame_counter",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "max_flame_counter"
}
}
},
{
"name": "max_flame_counter_from",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "max_flame_counter_from"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "b7c5107cc99685fcb27557600950b1abcb5af657b66319a01fbe7512341bdf6b"
}

View file

@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT flame_counter, max_flame_counter, max_flame_counter_from,\n last_message_send, last_message_received, last_flame_counter_change\n FROM groups WHERE group_id = ?",
"describe": {
"columns": [
{
"name": "flame_counter",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "flame_counter"
}
}
},
{
"name": "max_flame_counter",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "max_flame_counter"
}
}
},
{
"name": "max_flame_counter_from",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "max_flame_counter_from"
}
}
},
{
"name": "last_message_send",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_message_send"
}
}
},
{
"name": "last_message_received",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_message_received"
}
}
},
{
"name": "last_flame_counter_change",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_flame_counter_change"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
true
]
},
"hash": "cb70d13126eded79712bbbfed1ae0d13c4857c1c2442be753d5ba34bf96aceb5"
}

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 ) VALUES('restored-peer', 1, 1, 1, 1)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "db5192b62d8a4f9f23ad7befff1b9e8e463134a2cf90363a4a98d6b5b37fc723"
}

View file

@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT group_id, flame_counter, also_best_friend,\n last_message_send, last_message_received, last_flame_counter_change\n FROM groups",
"describe": {
"columns": [
{
"name": "group_id",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "groups",
"name": "group_id"
}
}
},
{
"name": "flame_counter",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "flame_counter"
}
}
},
{
"name": "also_best_friend",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "also_best_friend"
}
}
},
{
"name": "last_message_send",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_message_send"
}
}
},
{
"name": "last_message_received",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_message_received"
}
}
},
{
"name": "last_flame_counter_change",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_flame_counter_change"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
true
]
},
"hash": "df4c2b8e3b106c3f91e4d816627c7985c9f56fa459c16357d727fc292d226529"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT group_id, total_media_counter, last_flame_counter_change,\n last_flame_sync, flame_counter\n FROM groups WHERE last_flame_counter_change IS NOT NULL",
"query": "SELECT group_id, total_media_counter, last_flame_counter_change, last_flame_sync\n FROM groups WHERE last_flame_counter_change IS NOT NULL",
"describe": {
"columns": [
{
@ -46,17 +46,6 @@
"name": "last_flame_sync"
}
}
},
{
"name": "flame_counter",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "flame_counter"
}
}
}
],
"parameters": {
@ -66,9 +55,8 @@
false,
false,
true,
true,
false
true
]
},
"hash": "10facb63e1057c6acfe8b60cfe5f6deb3535eed60977d917f57cae97bb220e30"
"hash": "fef6c2ffbb80a5aa1e9cc70e3971cd9dfe0f828b984e18dff15c77389165b27c"
}

View file

@ -297,8 +297,9 @@ async fn reset_unusable_session(
}
/// 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
/// with a session the peer can upgrade to.
/// protocol, so what we send them next already goes out as v2. The decryption
/// error answering their legacy message is plaintext and does not use it; see
/// [`claim_legacy_upgrade_request`] for how the peer itself is moved to v2.
///
/// Runs before the inbound transaction opens: it awaits a server round-trip and
/// `establish_signal_session` writes through the pool, and the app database
@ -347,6 +348,43 @@ async fn upgrade_legacy_session_to_v2(
Ok(())
}
/// How long a legacy peer is given to move to v2 before it is asked again.
const LEGACY_UPGRADE_REQUEST_INTERVAL_SECS: i64 = 60 * 60;
/// Claims the right to ask a legacy peer to move to v2, at most once per
/// [`LEGACY_UPGRADE_REQUEST_INTERVAL_SECS`].
///
/// Clients before 0.6.0 fetch a fresh prekey bundle only for a
/// `PREKEY_UNKNOWN` decryption error, and that bundle is what switches the
/// contact to v2 on their side. They answer every decryption error by resending
/// the same queued message, still encrypted as v1, so answering each of those
/// resends would never end and would spend one of our one-time prekeys per
/// round. Leaving the resends unanswered stops the loop: without a reply the
/// old client does not send the message again.
async fn claim_legacy_upgrade_request(
t: &mut Transaction<'_, Sqlite>,
contact_id: i64,
) -> Result<bool> {
let now = crate::utils::current_time().timestamp();
let asked_before = now - LEGACY_UPGRADE_REQUEST_INTERVAL_SECS;
let claimed = sqlx::query!(
r#"
INSERT INTO legacy_upgrade_requests(contact_id, requested_at)
VALUES (?, ?)
ON CONFLICT(contact_id) DO UPDATE SET requested_at = excluded.requested_at
WHERE legacy_upgrade_requests.requested_at <= ?
"#,
contact_id,
now,
asked_before,
)
.execute(&mut **t)
.await?
.rows_affected()
> 0;
Ok(claimed)
}
#[tracing::instrument(
skip_all,
fields(
@ -466,7 +504,22 @@ pub async fn handle_decoded_server_message(
handle_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?;
}
Type::Ciphertext | Type::PrekeyBundle => {
queue_decryption_error(&mut t, from_user_id, &message.receipt_id, 0).await?;
if claim_legacy_upgrade_request(&mut t, from_user_id).await? {
tracing::info!("Asking the legacy peer to fetch our v2 prekey bundle");
queue_decryption_error(
&mut t,
from_user_id,
&message.receipt_id,
DecryptionErrorType::PrekeyUnknown as i32,
)
.await?;
} else {
tracing::info!(
"Leaving a legacy message unanswered; the peer was already asked to upgrade"
);
}
// Never acknowledged: the message was not read, so the sender must
// not be told it was delivered.
sends_error_response = true;
}
Type::CiphertextV2 => {
@ -913,4 +966,37 @@ mod tests {
.unwrap_or_else(PoisonError::into_inner)
.contains_key(receipt_id));
}
/// An old client resends the same v1 message for every decryption error,
/// so only the first one in an interval may be answered.
#[tokio::test]
async fn a_legacy_peer_is_asked_to_upgrade_once_per_interval() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("app.sqlite");
let database = crate::database::app::AppDatabase::new(path.to_str().unwrap(), None, false)
.await
.unwrap();
database.run_migrations().await.unwrap();
sqlx::query("INSERT INTO contacts(user_id, username) VALUES (7, 'alice')")
.execute(&database.pool)
.await
.unwrap();
let mut t = database.pool.begin().await.unwrap();
assert!(claim_legacy_upgrade_request(&mut t, 7).await.unwrap());
assert!(!claim_legacy_upgrade_request(&mut t, 7).await.unwrap());
t.commit().await.unwrap();
// Once the interval has passed without the peer upgrading, it is asked
// again, for example because its bundle fetch failed.
sqlx::query("UPDATE legacy_upgrade_requests SET requested_at = requested_at - ?")
.bind(LEGACY_UPGRADE_REQUEST_INTERVAL_SECS)
.execute(&database.pool)
.await
.unwrap();
let mut t = database.pool.begin().await.unwrap();
assert!(claim_legacy_upgrade_request(&mut t, 7).await.unwrap());
assert!(!claim_legacy_upgrade_request(&mut t, 7).await.unwrap());
t.commit().await.unwrap();
}
}

View file

@ -0,0 +1,8 @@
-- When a peer still sending legacy (v1) Signal messages was last asked to move
-- to v2. Older clients retry every decryption error with the same v1
-- ciphertext, so the request is answered once per interval and the retries in
-- between are left unanswered, which is what ends that loop.
CREATE TABLE legacy_upgrade_requests (
contact_id INTEGER PRIMARY KEY REFERENCES contacts(user_id) ON DELETE CASCADE,
requested_at INTEGER NOT NULL
);