[Add] Better key rotation, more relays (Sealed Sender, not yet fully)

This commit is contained in:
Alex Emmet 2026-09-05 23:34:44 +02:00
commit d8ef068a48
No known key found for this signature in database
9 changed files with 924 additions and 125 deletions

View file

@ -68,6 +68,7 @@ pub struct StoredMessage {
pub sent_by_self: bool,
pub message_state: String,
pub height: i64,
pub key_version: i64,
pub reply_to: Option<i64>,
pub reactions: Vec<StoredReaction>,
}
@ -82,6 +83,7 @@ pub struct NewMessage<'a> {
pub sent_by_self: bool,
pub content: &'a str,
pub height: i64,
pub key_version: i64,
pub reply_to: Option<i64>,
pub origin_iota_received_at: Option<i64>,
pub destination_iota_received_at: Option<i64>,
@ -596,6 +598,7 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
sent_by_self,
content,
height,
key_version,
reply_to,
origin_iota_received_at,
destination_iota_received_at,
@ -617,9 +620,9 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
r#"
INSERT INTO messages (
storage_owner, external_user, message_time, content, sent_by_self,
message_state, height, reply_to, relay_signer_id, relay_message_id,
message_state, height, key_version, reply_to, relay_signer_id, relay_message_id,
authored_at, origin_iota_received_at, destination_iota_received_at, stored_at, expires_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
"#,
params![
storage_owner,
@ -629,6 +632,7 @@ pub fn add_message(message: NewMessage<'_>) -> Result<i64, StorageError> {
i64::from(sent_by_self),
initial_state.as_str(),
height,
key_version,
reply_to,
relay_signer_id,
relay_message_id,
@ -960,7 +964,7 @@ pub fn get_messages(
SELECT id, relay_signer_id, relay_message_id, message_time, authored_at,
origin_iota_received_at, destination_iota_received_at,
client_received_at, client_received_recorded_at, read_at,
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height,
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version,
reply_to, edited_count
FROM messages
WHERE storage_owner = ?1 AND external_user = ?2 AND deleted_by_external = 0 AND history_deleted = 0
@ -991,8 +995,9 @@ pub fn get_messages(
sent_by_self: row.get::<_, i64>(14)? != 0,
message_state: row.get(15)?,
height: row.get(16).unwrap_or(0),
reply_to: row.get(17).ok().flatten(),
edited: row.get::<_, i64>(18).unwrap_or(0) > 0,
key_version: row.get(17).unwrap_or(1),
reply_to: row.get(18).ok().flatten(),
edited: row.get::<_, i64>(19).unwrap_or(0) > 0,
reactions: Vec::new(),
})
},
@ -1033,7 +1038,7 @@ pub fn get_message(
SELECT id, relay_signer_id, relay_message_id, message_time, authored_at,
origin_iota_received_at, destination_iota_received_at,
client_received_at, client_received_recorded_at, read_at,
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height,
read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version,
reply_to, edited_count, external_user
FROM messages
WHERE storage_owner = ?1
@ -1064,9 +1069,10 @@ pub fn get_message(
sent_by_self: row.get::<_, i64>(14)? != 0,
message_state: row.get(15)?,
height: row.get(16).unwrap_or(0),
reply_to: row.get(17).ok().flatten(),
edited: row.get::<_, i64>(18).unwrap_or(0) > 0,
external_user: row.get(19)?,
key_version: row.get(17).unwrap_or(1),
reply_to: row.get(18).ok().flatten(),
edited: row.get::<_, i64>(19).unwrap_or(0) > 0,
external_user: row.get(20)?,
reactions: Vec::new(),
})
})?;
@ -1138,9 +1144,9 @@ pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage
// A journal id uniquely identifies a row. Load all messages for this owner and retain only
// those ids; this keeps reaction hydration identical to normal message loading.
match db::with_db(|conn| {
let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?;
let mut stmt = conn.prepare("SELECT id, relay_signer_id, relay_message_id, message_time, authored_at, origin_iota_received_at, destination_iota_received_at, client_received_at, client_received_recorded_at, read_at, read_recorded_at, delivery_failed_at, delivery_failure, content, sent_by_self, message_state, height, key_version, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0 AND history_deleted = 0")?;
let rows = stmt.query_map([storage_owner], |row| {
let external_user: i64 = row.get(19)?;
let external_user: i64 = row.get(20)?;
Ok(StoredMessage {
id: row.get(0)?,
external_user,
@ -1160,8 +1166,9 @@ pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<StoredMessage
sent_by_self: row.get::<_, i64>(14)? != 0,
message_state: row.get(15)?,
height: row.get(16).unwrap_or(0),
reply_to: row.get(17).ok().flatten(),
edited: row.get::<_, i64>(18).unwrap_or(0) > 0,
key_version: row.get(17).unwrap_or(1),
reply_to: row.get(18).ok().flatten(),
edited: row.get::<_, i64>(19).unwrap_or(0) > 0,
reactions: Vec::new(),
})
})?;

View file

@ -270,6 +270,21 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
)?;
}
let messages_exist: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages')",
[],
|row| row.get(0),
)
.unwrap_or(false);
if messages_exist {
add_column_if_missing(
conn,
"key_version",
"key_version INTEGER NOT NULL DEFAULT 1",
)?;
}
if current_version < 5 {
conn.execute_batch(
r#"
@ -814,6 +829,35 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> {
)?;
}
if current_version < 24 {
let pending_relays_exist: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'pending_relays')",
[],
|row| row.get(0),
)?;
if pending_relays_exist {
add_table_column_if_missing(
conn,
"pending_relays",
"delivery_state",
"delivery_state TEXT NOT NULL DEFAULT 'ready' CHECK (delivery_state IN ('ready', 'waiting_client', 'quarantined'))",
)?;
add_table_column_if_missing(conn, "pending_relays", "last_error", "last_error TEXT")?;
conn.execute_batch(
r#"
UPDATE pending_relays
SET delivery_state = 'quarantined', last_error = 'target_recipient_mismatch'
WHERE target_kind = 0
AND relay_destination_user_id IS NOT NULL
AND destination_id <> relay_destination_user_id;
CREATE INDEX IF NOT EXISTS idx_pending_relays_active
ON pending_relays (delivery_state, target_kind, destination_id, id);
"#,
)?;
}
conn.pragma_update(None, "user_version", 24)?;
}
Ok(())
}
@ -883,7 +927,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 23);
assert_eq!(version, 24);
for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -902,7 +946,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 23);
assert_eq!(version, 24);
for table in [
"sync_heads",
"sync_events",
@ -926,7 +970,13 @@ mod tests {
)?;
assert_eq!(exists, 1);
}
for column in ["frame_id", "target_kind", "type_map_version"] {
for column in [
"frame_id",
"target_kind",
"type_map_version",
"delivery_state",
"last_error",
] {
let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('pending_relays') WHERE name = ?1")?;
assert!(statement.exists([column])?);
@ -942,7 +992,7 @@ mod tests {
run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 23);
assert_eq!(version, 24);
for column in [
"id",
"user_id",
@ -1037,7 +1087,7 @@ mod tests {
)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(preserved, "remote_committed");
assert_eq!(version, 23);
assert_eq!(version, 24);
Ok(())
}
}

View file

@ -22,10 +22,11 @@ pub struct ChatSecretQuery {
pub user_id: String,
pub chat_id: String,
pub secret_id: Option<String>,
pub version: Option<i64>,
}
static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
db::create_shared_connection(
let database = db::create_shared_connection(
"e2ee",
r#"
PRAGMA journal_mode = WAL;
@ -44,7 +45,7 @@ static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
wrapping_scheme TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, chat_id, secret_id)
PRIMARY KEY (user_id, chat_id, secret_id, version)
);
CREATE INDEX IF NOT EXISTS idx_chat_secrets_owner
@ -52,37 +53,81 @@ static E2EE_DB: LazyLock<Arc<Mutex<rusqlite::Connection>>> = LazyLock::new(|| {
"#,
)
.expect("Failed to create or initialize E2EE DB")
.expect("Failed to create or initialize E2EE DB");
if let Ok(connection) = database.lock() {
let legacy = connection
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('chat_secrets') WHERE name = 'version' AND pk = 4",
[],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
== 0;
if legacy {
connection
.execute_batch(
"ALTER TABLE chat_secrets RENAME TO chat_secrets_legacy;
CREATE TABLE chat_secrets (
user_id TEXT NOT NULL, chat_id TEXT NOT NULL,
secret_id TEXT NOT NULL, version INTEGER NOT NULL,
encrypted_secret BLOB NOT NULL, kem_ciphertext BLOB NOT NULL,
wrapping_scheme TEXT NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, chat_id, secret_id, version)
);
INSERT INTO chat_secrets SELECT * FROM chat_secrets_legacy;
DROP TABLE chat_secrets_legacy;",
)
.expect("Failed to migrate E2EE secret history schema");
}
}
database
});
pub fn put_chat_secret(record: StoredChatSecret) -> Result<(), StorageError> {
db::with_conn(&E2EE_DB, |conn| {
conn.execute(
r#"
INSERT INTO chat_secrets (
user_id, chat_id, secret_id, version, encrypted_secret,
kem_ciphertext, wrapping_scheme, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(user_id, chat_id, secret_id) DO UPDATE SET
version = excluded.version,
encrypted_secret = excluded.encrypted_secret,
kem_ciphertext = excluded.kem_ciphertext,
wrapping_scheme = excluded.wrapping_scheme,
created_at = excluded.created_at,
updated_at = excluded.updated_at
"#,
params![
record.user_id,
record.chat_id,
record.secret_id,
record.version,
record.encrypted_secret,
record.kem_ciphertext,
record.wrapping_scheme,
record.created_at,
record.updated_at,
],
let tx = conn.unchecked_transaction()?;
let existing = tx.query_row(
"SELECT encrypted_secret, kem_ciphertext, wrapping_scheme FROM chat_secrets WHERE user_id = ?1 AND chat_id = ?2 AND secret_id = ?3 AND version = ?4",
params![&record.user_id, &record.chat_id, &record.secret_id, record.version],
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, Vec<u8>>(1)?, row.get::<_, String>(2)?)),
).optional()?;
if let Some(existing) = existing {
if existing
!= (
record.encrypted_secret.clone(),
record.kem_ciphertext.clone(),
record.wrapping_scheme.clone(),
)
{
return Err(rusqlite::Error::InvalidParameterName(
"immutable chat secret version conflict".into(),
));
}
return Ok(());
}
let latest = tx.query_row(
"SELECT MAX(version) FROM chat_secrets WHERE user_id = ?1 AND chat_id = ?2 AND secret_id = ?3",
params![&record.user_id, &record.chat_id, &record.secret_id],
|row| row.get::<_, Option<i64>>(0),
)?;
let expected = match latest {
None => 1,
Some(version) => version.checked_add(1).ok_or_else(|| {
rusqlite::Error::InvalidParameterName("chat secret version overflow".into())
})?,
};
if record.version != expected {
return Err(rusqlite::Error::InvalidParameterName(format!(
"chat secret version is out of sequence: expected {expected}, got {}",
record.version
)));
}
tx.execute(
"INSERT INTO chat_secrets (user_id, chat_id, secret_id, version, encrypted_secret, kem_ciphertext, wrapping_scheme, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![record.user_id, record.chat_id, record.secret_id, record.version, record.encrypted_secret, record.kem_ciphertext, record.wrapping_scheme, record.created_at, record.updated_at],
)?;
tx.commit()?;
Ok(())
})
}
@ -117,10 +162,11 @@ pub fn get_chat_secret(query: ChatSecretQuery) -> Result<Option<StoredChatSecret
WHERE user_id = ?1
AND chat_id = ?2
AND (?3 IS NULL OR secret_id = ?3)
ORDER BY updated_at DESC
AND (?4 IS NULL OR version = ?4)
ORDER BY version DESC, updated_at DESC
LIMIT 1
"#,
params![query.user_id, query.chat_id, query.secret_id],
params![query.user_id, query.chat_id, query.secret_id, query.version],
chat_secret_from_row,
)
.optional()

View file

@ -14,6 +14,26 @@ pub struct PendingRelay {
pub relay_signer_id: Option<i64>,
pub relay_destination_user_id: Option<i64>,
pub relay_message_id: Option<String>,
pub delivery_state: PendingRelayDeliveryState,
pub last_error: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PendingRelayDeliveryState {
Ready,
WaitingClient,
Quarantined,
}
impl PendingRelayDeliveryState {
fn from_db(value: String) -> Result<Self, rusqlite::Error> {
match value.as_str() {
"ready" => Ok(Self::Ready),
"waiting_client" => Ok(Self::WaitingClient),
"quarantined" => Ok(Self::Quarantined),
_ => Err(rusqlite::Error::InvalidQuery),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -31,6 +51,15 @@ pub fn enqueue(
frame_id: u32,
type_map_version: &str,
) -> Result<(), StorageError> {
if let RouteTarget::User(destination) = target {
let destination = i64::try_from(destination)
.map_err(|_| StorageError::Other("relay destination ID exceeds SQLite range".into()))?;
if destination != relay.destination_user_id {
return Err(StorageError::Other(
"user relay target does not match final recipient".into(),
));
}
}
db::with_immediate_transaction(|tx| {
enqueue_in_tx(
tx,
@ -79,7 +108,7 @@ pub fn enqueue_in_tx(
pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
db::with_db(|connection| {
let mut statement = connection.prepare(
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays ORDER BY id LIMIT ?1",
"SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error FROM pending_relays ORDER BY id LIMIT ?1",
)?;
let rows = statement.query_map(params![limit.clamp(1, 500)], |row| {
let destination_id = row.get::<_, i64>(1)?;
@ -118,12 +147,26 @@ pub fn list(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
relay_signer_id: row.get(7)?,
relay_destination_user_id: row.get(8)?,
relay_message_id: row.get(9)?,
delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?,
last_error: row.get(11)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
})
}
/* Select only relays whose target and signer remain locally managed. This is
* the delivery-authority check after user release, independent of row state. */
pub fn list_active(limit: i64) -> Result<Vec<PendingRelay>, StorageError> {
db::with_db(|connection| {
let mut statement = connection.prepare(
"SELECT p.id, p.destination_id, p.target_kind, p.frame, p.created_at, p.frame_id, p.type_map_version, p.relay_signer_id, p.relay_destination_user_id, p.relay_message_id, p.delivery_state, p.last_error FROM pending_relays p WHERE p.delivery_state = 'ready' AND ((p.target_kind = 0 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.destination_id)) OR (p.target_kind = 1 AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = p.relay_signer_id))) ORDER BY p.id LIMIT ?1",
)?;
let rows = statement.query_map(params![limit.clamp(1, 500)], pending_relay_from_row)?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
})
}
pub fn list_without_relay_identity() -> Result<Vec<PendingRelay>, StorageError> {
list_without_relay_identity_after(0, i64::MAX)
}
@ -133,7 +176,7 @@ pub fn list_without_relay_identity_after(
limit: i64,
) -> Result<Vec<PendingRelay>, StorageError> {
db::with_db(|connection| {
let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id FROM pending_relays WHERE id > ?1 AND (relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?;
let mut statement = connection.prepare("SELECT id, destination_id, target_kind, frame, created_at, frame_id, type_map_version, relay_signer_id, relay_destination_user_id, relay_message_id, delivery_state, last_error FROM pending_relays WHERE id > ?1 AND (relay_signer_id IS NULL OR relay_destination_user_id IS NULL OR relay_message_id IS NULL) ORDER BY id LIMIT ?2")?;
let rows = statement.query_map(
params![after_id, limit.clamp(1, 500)],
pending_relay_from_row,
@ -161,11 +204,68 @@ fn pending_relay_from_row(row: &rusqlite::Row<'_>) -> Result<PendingRelay, rusql
relay_signer_id: row.get(7)?,
relay_destination_user_id: row.get(8)?,
relay_message_id: row.get(9)?,
delivery_state: PendingRelayDeliveryState::from_db(row.get(10)?)?,
last_error: row.get(11)?,
})
}
pub fn mark_client_offline(destination_user_id: i64, frame_id: u32) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
tx.execute("UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'client_offline' WHERE target_kind = 0 AND destination_id = ?1 AND frame_id = ?2", params![destination_user_id, i64::from(frame_id)])?;
Ok(())
})
}
pub fn pause_client_deliveries(destination_user_id: i64) -> Result<(), StorageError> {
db::with_immediate_transaction(|tx| {
tx.execute(
"UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'awaiting_state_ack' WHERE target_kind = 0 AND destination_id = ?1 AND delivery_state = 'ready'",
[destination_user_id],
)?;
Ok(())
})
}
pub fn resume_client_deliveries(destination_user_id: i64) -> Result<usize, StorageError> {
db::with_db(|connection| {
Ok(connection.execute("UPDATE pending_relays SET delivery_state = 'ready', last_error = NULL WHERE target_kind = 0 AND destination_id = ?1 AND delivery_state = 'waiting_client'", [destination_user_id])?)
})
}
pub fn resume_managed_client_deliveries() -> Result<usize, StorageError> {
db::with_db(|connection| {
Ok(connection.execute("UPDATE pending_relays SET delivery_state = 'ready', last_error = NULL WHERE target_kind = 0 AND delivery_state = 'waiting_client' AND EXISTS (SELECT 1 FROM users u WHERE u.user_id = pending_relays.destination_id)", [])?)
})
}
pub fn quarantine_target_mismatch(id: i64) -> Result<(), StorageError> {
db::with_db(|connection| {
connection.execute("UPDATE pending_relays SET delivery_state = 'quarantined', last_error = 'target_recipient_mismatch' WHERE id = ?1", [id])?;
Ok(())
})
}
pub fn set_relay_identity(id: i64, relay: &RelayIdentity) -> Result<(), StorageError> {
db::with_db(|connection| {
let target_kind = connection.query_row(
"SELECT target_kind FROM pending_relays WHERE id = ?1",
[id],
|row| row.get::<_, i64>(0),
)?;
if target_kind == 0 {
let destination_id = connection.query_row(
"SELECT destination_id FROM pending_relays WHERE id = ?1",
[id],
|row| row.get::<_, i64>(0),
)?;
if destination_id != relay.destination_user_id {
connection.execute(
"UPDATE pending_relays SET delivery_state = 'quarantined', last_error = 'target_recipient_mismatch' WHERE id = ?1",
[id],
)?;
return Ok(());
}
}
connection.execute("UPDATE pending_relays SET relay_signer_id = ?2, relay_destination_user_id = ?3, relay_message_id = ?4 WHERE id = ?1", params![id, relay.signer_id, relay.destination_user_id, relay.message_id])?;
Ok(())
})