1use crate::context::XmtpSharedContext;
6use crate::identity::IdentityError;
7use crate::state_tx::state_write;
8use crate::worker::NeedsDbReconnect;
9use crate::worker::tasks::enqueue_pull_in;
10use thiserror::Error;
11use tls_codec::Serialize;
12use xmtp_configuration::CREATE_PQ_KEY_PACKAGE_EXTENSION;
13use xmtp_db::StorageError;
14use xmtp_db::TransactionOutcome::Continue;
15use xmtp_db::prelude::*;
16use xmtp_db::sql_key_store::{KEY_PACKAGE_REFERENCES, KEY_PACKAGE_WRAPPER_PRIVATE_KEY};
17use xmtp_db::tasks::{NEVER_EXPIRES, NewTask, TaskDataHash, data_hash_for};
18use xmtp_proto::xmtp::mls::database::{
19 KpDeletion, KpRotation, Task as TaskProto, task::Task as TaskKind,
20};
21
22#[derive(Debug, Error)]
23pub enum KeyPackageMaintenanceError {
24 #[error("generic storage error: {0}")]
25 Storage(#[from] StorageError),
26 #[error("generic identity error: {0}")]
27 Identity(#[from] IdentityError),
28 #[error("metadata error: {0}")]
29 Metadata(StorageError),
30 #[error("failed to fetch expired key packages: {0}")]
31 Fetch(StorageError),
32 #[error("failed to delete key package: {0}")]
33 DeleteKeyPackage(IdentityError),
34 #[error("deletion error: {0}")]
35 Deletion(StorageError),
36 #[error("rotation error: {0}")]
37 Rotation(IdentityError),
38}
39
40impl NeedsDbReconnect for KeyPackageMaintenanceError {
41 fn needs_db_reconnect(&self) -> bool {
42 match self {
43 Self::Storage(s) => s.db_needs_connection(),
44 Self::Identity(s) => s.needs_db_reconnect(),
45 Self::Metadata(s) => s.db_needs_connection(),
46 Self::Fetch(s) => s.db_needs_connection(),
47 Self::DeleteKeyPackage(s) => s.needs_db_reconnect(),
48 Self::Deletion(s) => s.db_needs_connection(),
49 Self::Rotation(s) => s.needs_db_reconnect(),
50 }
51 }
52}
53
54pub(crate) fn kp_rotation_proto() -> TaskProto {
55 TaskProto {
56 task: Some(TaskKind::KpRotation(KpRotation {})),
57 }
58}
59
60pub(crate) fn kp_deletion_proto() -> TaskProto {
61 TaskProto {
62 task: Some(TaskKind::KpDeletion(KpDeletion {})),
63 }
64}
65
66pub(crate) fn kp_rotation_hash() -> TaskDataHash {
67 data_hash_for(&kp_rotation_proto())
68}
69
70pub(crate) fn kp_deletion_hash() -> TaskDataHash {
71 data_hash_for(&kp_deletion_proto())
72}
73
74pub(crate) fn kp_seed(proto: TaskProto, now: i64) -> Result<NewTask, StorageError> {
77 NewTask::builder()
78 .originating_message_sequence_id(0)
79 .expires_at_ns(NEVER_EXPIRES)
80 .max_attempts(i32::MAX)
81 .next_attempt_at_ns(now)
82 .build(proto)
83}
84
85pub(crate) async fn rotate_if_needed<Context: XmtpSharedContext>(
88 context: &Context,
89) -> Result<bool, KeyPackageMaintenanceError> {
90 if !context
91 .db()
92 .is_identity_needs_rotation()
93 .map_err(KeyPackageMaintenanceError::Metadata)?
94 {
95 return Ok(false);
96 }
97 context
98 .identity()
99 .rotate_and_upload_key_package(
100 context.api(),
101 context.mls_storage(),
102 CREATE_PQ_KEY_PACKAGE_EXTENSION,
103 )
104 .await
105 .map_err(KeyPackageMaintenanceError::Rotation)?;
106 Ok(true)
107}
108
109#[cfg(test)]
111pub(crate) fn delete_key_package<Context: XmtpSharedContext>(
112 context: &Context,
113 hash_ref: Vec<u8>,
114 pq_pub_key: Option<Vec<u8>>,
115) -> Result<(), IdentityError> {
116 state_write(context.mls_storage(), |tx| {
117 delete_key_package_material(&tx.storage(), &hash_ref, pq_pub_key.as_deref())?;
118 Ok::<_, IdentityError>(Continue(()))
119 })?;
120 Ok(())
121}
122
123fn delete_key_package_material(
125 key_store: &impl XmtpMlsStorageProvider,
126 hash_ref: &[u8],
127 pq_pub_key: Option<&[u8]>,
128) -> Result<(), IdentityError> {
129 let openmls_hash_ref = crate::identity::deserialize_key_package_hash_ref(hash_ref)?;
130 let bundle: Option<openmls::prelude::KeyPackageBundle> =
131 key_store.key_package(&openmls_hash_ref)?;
132 if let Some(bundle) = bundle {
133 let init_key = bundle
134 .key_package()
135 .hpke_init_key()
136 .tls_serialize_detached()?;
137 key_store.delete(KEY_PACKAGE_REFERENCES, &init_key)?;
138 }
139 key_store.delete_key_package(&openmls_hash_ref)?;
140
141 if let Some(pq_pub_key) = pq_pub_key {
142 key_store.delete(
143 KEY_PACKAGE_REFERENCES,
144 crate::identity::pq_key_package_references_key(pq_pub_key)?.as_slice(),
145 )?;
146 key_store.delete(KEY_PACKAGE_WRAPPER_PRIVATE_KEY, hash_ref)?;
147 }
148
149 Ok(())
150}
151
152pub(crate) fn sweep_expired<Context: XmtpSharedContext>(
155 context: &Context,
156) -> Result<(), KeyPackageMaintenanceError> {
157 state_write(context.mls_storage(), |tx| {
158 let storage = tx.storage();
159 let conn = storage.db();
160 if conn.has_pending_welcomes()? {
162 return Ok::<_, IdentityError>(Continue(()));
163 }
164 let expired = conn.get_expired_key_packages()?;
165 for kp in &expired {
166 delete_key_package_material(
167 &storage,
168 &kp.key_package_hash_ref,
169 kp.post_quantum_public_key.as_deref(),
170 )?;
171 conn.delete_key_package_entry_with_id(kp.id)?;
172 }
173 Ok(Continue(()))
174 })?;
175 Ok(())
176}
177
178pub(crate) fn queue_key_rotation<Context: XmtpSharedContext>(
181 context: &Context,
182) -> Result<(), StorageError> {
183 state_write(context.mls_storage(), |tx| {
184 queue_key_rotation_in(&tx.storage())?;
185 Ok::<_, StorageError>(Continue(()))
186 })?;
187 context.task_channels().wake();
189 Ok(())
190}
191
192pub(crate) fn queue_key_rotation_in(
195 storage: &impl XmtpMlsStorageProvider,
196) -> Result<(), StorageError> {
197 let now = xmtp_common::time::now_ns();
198 storage
199 .db()
200 .queue_key_rotation_with_nudge(&kp_rotation_hash(), kp_seed(kp_rotation_proto(), now)?)
201}
202
203pub(crate) fn nudge_deletion<Context: XmtpSharedContext>(
208 context: &Context,
209) -> Result<(), StorageError> {
210 let db = context.db();
211 let now = xmtp_common::time::now_ns();
212 if let Some(at) = db.min_key_package_delete_at_ns()? {
213 db.create_or_ignore_task(kp_seed(kp_deletion_proto(), now)?)?;
214 enqueue_pull_in(context, kp_deletion_hash(), at, NEVER_EXPIRES)?;
215 }
216 Ok(())
217}
218
219pub(crate) fn seed_and_reconcile_kp_tasks<Context: XmtpSharedContext>(
222 context: &Context,
223) -> Result<(), StorageError> {
224 let db = context.db();
225 let now = xmtp_common::time::now_ns();
226 db.create_or_ignore_task(kp_seed(kp_rotation_proto(), now)?)?;
227 db.create_or_ignore_task(kp_seed(kp_deletion_proto(), now)?)?;
228 if let Some(rot) = db.next_key_package_rotation_ns()? {
231 enqueue_pull_in(context, kp_rotation_hash(), rot, NEVER_EXPIRES)?;
232 }
233 if let Some(del) = db.min_key_package_delete_at_ns()? {
234 enqueue_pull_in(context, kp_deletion_hash(), del, NEVER_EXPIRES)?;
235 }
236 Ok(())
237}
238
239#[cfg(all(test, not(target_arch = "wasm32")))]
241mod tests {
242 use super::*;
243 use crate::tester;
244 use crate::worker::tasks::TaskWorker;
245 use crate::worker::{WorkerConfig, WorkerKind};
246 use openmls_traits::storage::StorageProvider;
247 use prost::Message;
248 use xmtp_db::ConnectionExt;
249 use xmtp_proto::xmtp::mls::database::Task as TaskProtoDecode;
250
251 fn disconnect_storage() -> xmtp_db::StorageError {
253 xmtp_db::StorageError::Platform(xmtp_db::PlatformStorageError::PoolNeedsConnection)
254 }
255
256 fn benign_storage() -> xmtp_db::StorageError {
258 xmtp_db::StorageError::InvalidHmacLength
259 }
260
261 fn no_runner_cfg() -> WorkerConfig {
262 let mut cfg = WorkerConfig::default();
263 cfg.enabled.insert(WorkerKind::TaskRunner, false);
264 cfg
265 }
266
267 fn row_by_hash(db: &impl QueryTasks, hash: impl AsRef<[u8]>) -> Option<xmtp_db::tasks::Task> {
268 db.get_tasks()
269 .expect("get_tasks should not fail")
270 .into_iter()
271 .find(|t| t.data_hash == hash.as_ref())
272 }
273
274 async fn make_rotation_due(db: &impl QueryIdentity) {
275 db.queue_key_package_rotation()
276 .expect("queue_key_package_rotation should not fail"); xmtp_common::time::sleep(std::time::Duration::from_secs(6)).await;
278 }
279
280 #[xmtp_common::test]
281 fn kp_errors_forward_db_reconnect() {
282 use crate::worker::NeedsDbReconnect;
283 use crate::worker::tasks::TaskWorkerError;
284 let e = TaskWorkerError::from(KeyPackageMaintenanceError::Storage(disconnect_storage()));
285 assert!(
286 e.needs_db_reconnect(),
287 "DB outage during KP work must trigger supervisor reconnect, not plain backoff"
288 );
289 let e = TaskWorkerError::from(crate::identity::IdentityError::from(disconnect_storage()));
290 assert!(e.needs_db_reconnect());
291 let e = TaskWorkerError::from(crate::identity::IdentityError::OpenMlsStorageError(
293 xmtp_db::sql_key_store::SqlKeyStoreError::Connection(
294 xmtp_db::ConnectionError::Platform(
295 xmtp_db::PlatformStorageError::PoolNeedsConnection,
296 ),
297 ),
298 ));
299 assert!(e.needs_db_reconnect());
300 let e = TaskWorkerError::from(KeyPackageMaintenanceError::Storage(benign_storage()));
302 assert!(
303 !e.needs_db_reconnect(),
304 "benign storage errors must back off, not restart the supervisor"
305 );
306 }
307
308 #[xmtp_common::test(unwrap_try = true)]
309 async fn manual_rotation_nudges_deletion() {
310 tester!(alix, worker_config: no_runner_cfg());
311 let db = alix.context.db();
312 assert!(row_by_hash(&db, kp_deletion_hash()).is_none());
313
314 alix.rotate_and_upload_key_package().await?;
315
316 assert!(
317 row_by_hash(&db, kp_deletion_hash()).is_some(),
318 "manual rotation must self-heal the deletion singleton"
319 );
320 let has_pull_in = db.get_tasks()?.into_iter().any(|t| matches!(
321 TaskProtoDecode::decode(t.data.as_slice()).ok().and_then(|p| p.task),
322 Some(TaskKind::PullInDeadline(p)) if p.target_data_hash == kp_deletion_hash().as_ref()
323 ));
324 assert!(
325 has_pull_in,
326 "manual rotation must enqueue a deletion pull-in"
327 );
328 }
329
330 #[xmtp_common::test(unwrap_try = true)]
331 async fn pending_welcome_preserves_expired_keys_until_completion() {
332 use xmtp_db::diesel::prelude::*;
333 use xmtp_db::incoming_envelope::{
334 IncomingLimits, NetworkEntityKind, NewIncomingEnvelope, PendingBudget, StreamTopic,
335 };
336 use xmtp_db::schema::key_package_history::dsl;
337 use xmtp_proto::types::Cursor;
338
339 tester!(alix, disable_workers);
340 let db = alix.context.db();
341 let history = db
342 .find_key_package_history_entries_before_id(i32::MAX)?
343 .pop()?;
344 let hash =
345 crate::identity::deserialize_key_package_hash_ref(&history.key_package_hash_ref)?;
346 db.raw_query(|conn| {
347 diesel::update(dsl::key_package_history.filter(dsl::id.eq(history.id)))
348 .set(dsl::delete_at_ns.eq(0))
349 .execute(conn)
350 })?;
351 let topic = StreamTopic {
352 entity_id: alix.context.installation_id().to_vec(),
353 kind: NetworkEntityKind::Welcome,
354 };
355 let limit = PendingBudget { rows: 1, bytes: 1 };
356 db.admit_ordered_batch(
357 &topic,
358 Cursor(0),
359 &[NewIncomingEnvelope {
360 sequence_id: Cursor(1),
361 envelope: vec![1],
362 }],
363 IncomingLimits {
364 batch: limit,
365 topic: limit,
366 kind: limit,
367 },
368 )?;
369
370 sweep_expired(&alix.context)?;
371 let retained: Option<openmls::prelude::KeyPackageBundle> =
372 alix.context.mls_storage().key_package(&hash)?;
373 assert!(retained.is_some());
374
375 db.complete_pending_envelope(&topic, Cursor(1))?;
376 sweep_expired(&alix.context)?;
377 let removed: Option<openmls::prelude::KeyPackageBundle> =
378 alix.context.mls_storage().key_package(&hash)?;
379 assert!(removed.is_none());
380 assert!(
381 db.find_key_package_history_entry_by_hash_ref(history.key_package_hash_ref)
382 .is_err()
383 );
384 }
385
386 #[xmtp_common::test(unwrap_try = true)]
387 async fn rotation_task_rotates_and_reschedules() {
388 tester!(alix, worker_config: no_runner_cfg());
389 let db = alix.context.db();
390 let now = xmtp_common::time::now_ns();
391 db.create_or_ignore_task(kp_seed(kp_rotation_proto(), now)?)?;
392 make_rotation_due(&db).await;
393
394 let row = row_by_hash(&db, kp_rotation_hash()).unwrap();
395 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
396
397 assert!(
398 !db.is_identity_needs_rotation()?,
399 "rotation must have happened"
400 );
401 let after = row_by_hash(&db, kp_rotation_hash()).expect("recurring row survives");
402 let col = db.next_key_package_rotation_ns()?.unwrap();
403 assert_eq!(
404 after.next_attempt_at_ns, col,
405 "reschedule must read the live column"
406 );
407 assert_eq!(after.attempts, 0);
408 }
409
410 #[xmtp_common::test(unwrap_try = true)]
411 async fn rotation_ensures_and_pulls_in_deletion_when_singleton_missing() {
412 tester!(alix, worker_config: no_runner_cfg());
413 let db = alix.context.db();
414 let now = xmtp_common::time::now_ns();
415 db.create_or_ignore_task(kp_seed(kp_rotation_proto(), now)?)?;
416 make_rotation_due(&db).await;
418
419 let row = row_by_hash(&db, kp_rotation_hash()).unwrap();
420 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
421
422 assert!(
423 row_by_hash(&db, kp_deletion_hash()).is_some(),
424 "rotation must recreate a missing KpDeletion singleton"
425 );
426 let has_pull_in = db.get_tasks()?.iter().any(|t| {
427 matches!(
428 TaskProtoDecode::decode(t.data.as_slice()).ok().and_then(|p| p.task),
429 Some(TaskKind::PullInDeadline(p)) if p.target_data_hash == kp_deletion_hash().as_ref()
430 )
431 });
432 assert!(has_pull_in, "rotation must enqueue a deletion pull-in");
433 }
434
435 #[xmtp_common::test(unwrap_try = true)]
436 async fn deletion_task_sweeps_and_reschedules() {
437 tester!(alix, worker_config: no_runner_cfg());
438 let db = alix.context.db();
439 let now = xmtp_common::time::now_ns();
440 db.create_or_ignore_task(kp_seed(kp_deletion_proto(), now)?)?;
441
442 make_rotation_due(&db).await;
444 rotate_if_needed(&alix.context).await?;
445 assert!(db.min_key_package_delete_at_ns()?.is_some());
446 xmtp_common::time::sleep(std::time::Duration::from_secs(4)).await; let row = row_by_hash(&db, kp_deletion_hash()).unwrap();
449 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
450
451 assert!(
452 db.get_expired_key_packages()?.is_empty(),
453 "sweep must delete expired KPs"
454 );
455 let after = row_by_hash(&db, kp_deletion_hash()).expect("recurring row survives");
456 assert!(
457 after.next_attempt_at_ns > xmtp_common::time::now_ns(),
458 "deletion reschedules to next pending deadline or far-future"
459 );
460 }
461
462 #[xmtp_common::test(unwrap_try = true)]
463 async fn kp_tasks_seeded_when_workers_run_absent_when_passive() {
464 tester!(alix); let db = alix.context.db();
466 assert!(row_by_hash(&db, kp_rotation_hash()).is_some());
467 assert!(row_by_hash(&db, kp_deletion_hash()).is_some());
468
469 tester!(bo, worker_config: no_runner_cfg()); let db = bo.context.db();
471 assert!(row_by_hash(&db, kp_rotation_hash()).is_none());
472 assert!(row_by_hash(&db, kp_deletion_hash()).is_none());
473 }
474
475 #[xmtp_common::test(unwrap_try = true)]
476 async fn startup_reconcile_pulls_in_far_scheduled_row() {
477 tester!(alix, worker_config: no_runner_cfg());
478 let db = alix.context.db();
479 let now = xmtp_common::time::now_ns();
480 db.create_or_ignore_task(kp_seed(kp_rotation_proto(), now)?)?;
483 let row = row_by_hash(&db, kp_rotation_hash()).unwrap();
484 db.update_task(row.id, 0, now, now + 30 * xmtp_common::NS_IN_DAY)?;
485 db.queue_key_package_rotation()?; seed_and_reconcile_kp_tasks(&alix.context)?;
488
489 let pull_in = db
490 .get_tasks()?
491 .into_iter()
492 .find(|t| {
493 matches!(
494 TaskProtoDecode::decode(t.data.as_slice()).ok().and_then(|p| p.task),
495 Some(TaskKind::PullInDeadline(p)) if p.target_data_hash == kp_rotation_hash().as_ref()
496 )
497 })
498 .expect("reconcile must enqueue a rotation pull-in");
499 TaskWorker::run_and_reschedule_task(pull_in, &alix.context).await?;
500
501 let after = row_by_hash(&db, kp_rotation_hash()).unwrap();
502 let col = db.next_key_package_rotation_ns()?.unwrap();
503 assert_eq!(after.next_attempt_at_ns, col);
504 }
505
506 #[xmtp_common::test(unwrap_try = true)]
509 async fn rotation_task_not_due_reschedules_without_rotating() {
510 tester!(alix, worker_config: no_runner_cfg());
511 let db = alix.context.db();
512 let now = xmtp_common::time::now_ns();
513 db.create_or_ignore_task(kp_seed(kp_rotation_proto(), now)?)?;
514 let row = row_by_hash(&db, kp_rotation_hash()).unwrap();
516 TaskWorker::run_and_reschedule_task(row, &alix.context).await?;
517
518 assert!(
519 row_by_hash(&db, kp_deletion_hash()).is_none(),
520 "must not seed deletion"
521 );
522 assert!(db.min_key_package_delete_at_ns()?.is_none());
523 let after = row_by_hash(&db, kp_rotation_hash()).unwrap();
524 assert_eq!(
525 after.next_attempt_at_ns,
526 db.next_key_package_rotation_ns()?.unwrap()
527 );
528 }
529
530 #[xmtp_common::test(unwrap_try = true)]
533 async fn welcome_nudge_selfheals_missing_rotation_seed() {
534 tester!(alix, worker_config: no_runner_cfg()); let db = alix.context.db();
536 assert!(row_by_hash(&db, kp_rotation_hash()).is_none());
537
538 queue_key_rotation(&alix.context)?;
539
540 assert!(
541 row_by_hash(&db, kp_rotation_hash()).is_some(),
542 "nudge must recreate the missing KpRotation singleton"
543 );
544 let has_pull_in = db.get_tasks()?.iter().any(|t| {
545 matches!(
546 TaskProtoDecode::decode(t.data.as_slice()).ok().and_then(|p| p.task),
547 Some(TaskKind::PullInDeadline(p)) if p.target_data_hash == kp_rotation_hash().as_ref()
548 )
549 });
550 assert!(has_pull_in, "nudge must enqueue a rotation pull-in");
551 }
552
553 #[xmtp_common::test(unwrap_try = true)]
556 async fn welcome_nudge_pulls_in_parked_rotation() {
557 tester!(alix, worker_config: no_runner_cfg());
558 let db = alix.context.db();
559 let now = xmtp_common::time::now_ns();
560 db.create_or_ignore_task(kp_seed(kp_rotation_proto(), now)?)?;
561 let parked = row_by_hash(&db, kp_rotation_hash()).unwrap();
563 TaskWorker::run_and_reschedule_task(parked, &alix.context).await?;
564 let parked_at = row_by_hash(&db, kp_rotation_hash())
565 .unwrap()
566 .next_attempt_at_ns;
567 assert!(
568 parked_at > now + xmtp_common::NS_IN_DAY,
569 "precondition: parked far out"
570 );
571
572 queue_key_rotation(&alix.context)?; let pull_in = db
575 .get_tasks()?
576 .into_iter()
577 .find(|t| {
578 matches!(
579 TaskProtoDecode::decode(t.data.as_slice()).ok().and_then(|p| p.task),
580 Some(TaskKind::PullInDeadline(p)) if p.target_data_hash == kp_rotation_hash().as_ref()
581 )
582 })
583 .expect("nudge must enqueue a durable pull-in");
584 TaskWorker::run_and_reschedule_task(pull_in, &alix.context).await?;
585
586 let after = row_by_hash(&db, kp_rotation_hash()).unwrap();
587 let col = db.next_key_package_rotation_ns()?.unwrap();
588 assert_eq!(
589 after.next_attempt_at_ns, col,
590 "rotation row must be pulled in to the lowered column"
591 );
592 assert!(after.next_attempt_at_ns <= now + 7 * xmtp_common::NS_IN_SEC);
594 }
595}