Skip to content

Commit d51f5b9

Browse files
committed
crypto: Rotate fallback keys in a time-based manner
Fallback keys until now have been rotated on the basis that the homeserver tells us that a fallback key has been used. Now this leads to various problems if the server tells us too often that it has been used, i.e. if we receive the same sync response too often. It leaves us also open to the homeserver being dishonest and never telling us that the fallback key has been used. Let's avoid all these problems by just rotating the fallback key in a time-based manner.
1 parent cced512 commit d51f5b9

File tree

3 files changed

+103
-16
lines changed

3 files changed

+103
-16
lines changed

crates/matrix-sdk-crypto/src/dehydrated_devices.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ impl DehydratedDevice {
318318
let mut transaction = self.store.transaction().await;
319319

320320
let account = transaction.account().await?;
321-
account.generate_fallback_key_helper();
321+
account.generate_fallback_key_if_needed();
322322

323323
let (device_keys, one_time_keys, fallback_keys) = account.keys_for_upload();
324324

crates/matrix-sdk-crypto/src/machine.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2287,7 +2287,7 @@ pub(crate) mod tests {
22872287
.store()
22882288
.with_transaction(|mut tr| async {
22892289
let account = tr.account().await.unwrap();
2290-
account.generate_fallback_key_helper();
2290+
account.generate_fallback_key_if_needed();
22912291
account.update_uploaded_key_count(0);
22922292
account.generate_one_time_keys_if_needed();
22932293
let request = machine

crates/matrix-sdk-crypto/src/olm/account.rs

Lines changed: 101 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use std::{
1717
fmt,
1818
ops::{Deref, Not as _},
1919
sync::Arc,
20+
time::Duration,
2021
};
2122

2223
use ruma::{
@@ -327,6 +328,14 @@ pub struct Account {
327328
/// needs to set this for us, depending on the count we will suggest the
328329
/// client to upload new keys.
329330
uploaded_signed_key_count: u64,
331+
/// The timestamp of the last time we generated a fallback key. Fallback
332+
/// keys are rotated in a time-based manner, this field records when we
333+
/// either generated our first fallback key or rotated one.
334+
///
335+
/// Will be `None` if we never created a fallback key, or if we're migrating
336+
/// from a `AccountPickle` that didn't use time-based fallback key
337+
/// rotation.
338+
fallback_creation_timestamp: Option<MilliSecondsSinceUnixEpoch>,
330339
}
331340

332341
impl Deref for Account {
@@ -358,6 +367,9 @@ pub struct PickledAccount {
358367
/// as creation time of own device
359368
#[serde(default = "default_account_creation_time")]
360369
pub creation_local_time: MilliSecondsSinceUnixEpoch,
370+
/// The timestamp of the last time we generated a fallback key.
371+
#[serde(default)]
372+
pub fallback_key_creation_timestamp: Option<MilliSecondsSinceUnixEpoch>,
361373
}
362374

363375
fn default_account_creation_time() -> MilliSecondsSinceUnixEpoch {
@@ -404,6 +416,7 @@ impl Account {
404416
inner: Box::new(account),
405417
shared: false,
406418
uploaded_signed_key_count: 0,
419+
fallback_creation_timestamp: None,
407420
}
408421
}
409422

@@ -496,11 +509,11 @@ impl Account {
496509
self.generate_one_time_keys_if_needed();
497510
}
498511

499-
if let Some(unused) = unused_fallback_keys {
500-
if !unused.contains(&DeviceKeyAlgorithm::SignedCurve25519) {
501-
// Generate a new fallback key if we don't have one.
502-
self.generate_fallback_key_helper();
503-
}
512+
// If the server supports fallback keys or if it did so in the past, shown by
513+
// the existence of a fallback creation timestamp, generate a new one if
514+
// we don't have one, or if the current fallback key expired.
515+
if unused_fallback_keys.is_some() || self.fallback_creation_timestamp.is_some() {
516+
self.generate_fallback_key_if_needed();
504517
}
505518
}
506519

@@ -543,17 +556,59 @@ impl Account {
543556
Some(key_count as u64)
544557
}
545558

546-
pub(crate) fn generate_fallback_key_helper(&mut self) {
547-
if self.inner.fallback_key().is_empty() {
559+
pub(crate) fn generate_fallback_key_if_needed(&mut self) {
560+
if self.inner.fallback_key().is_empty() && self.fallback_key_expired() {
548561
let removed_fallback_key = self.inner.generate_fallback_key();
562+
self.fallback_creation_timestamp = Some(MilliSecondsSinceUnixEpoch::now());
549563

550564
debug!(
551565
?removed_fallback_key,
552-
"No unused fallback keys were found on the server, generated a new fallback key.",
566+
"The fallback key either expired or we didn't have one, generated a new fallback key.",
553567
);
554568
}
555569
}
556570

571+
/// Check if our most recent fallback key has expired.
572+
///
573+
/// We consider the fallback key to be expired if it's older than a week.
574+
/// This is the lower bound for the recommended signed pre-key bundle
575+
/// rotation interval in the X3DH spec[1].
576+
///
577+
/// [1]: https://signal.org/docs/specifications/x3dh/#publishing-keys
578+
fn fallback_key_expired(&self) -> bool {
579+
const FALLBACK_KEY_MAX_AGE: Duration = Duration::from_secs(3600 * 24 * 7);
580+
581+
if let Some(time) = self.fallback_creation_timestamp {
582+
// `to_system_time()` returns `None` if the the UNIX_EPOCH + `time` doesn't fit
583+
// into a i64. This will likely never happen, but let's rotate the
584+
// key in case the values are messed up for some other reason.
585+
let Some(system_time) = time.to_system_time() else {
586+
return true;
587+
};
588+
589+
// `elapsed()` errors if the `system_time` is in the future, this should mean
590+
// that our clock has changed to the past, let's rotate just in case
591+
// and then we'll get to a normal time.
592+
let Ok(elapsed) = system_time.elapsed() else {
593+
return true;
594+
};
595+
596+
// Alright, our times are normal and we know how much time elapsed since the
597+
// last time we created/rotated a fallback key.
598+
//
599+
// If the key is older than a week, then we rotate it.
600+
if elapsed > FALLBACK_KEY_MAX_AGE {
601+
true
602+
} else {
603+
false
604+
}
605+
} else {
606+
// We never created a fallback key, or we're migrating to the time-based
607+
// fallback key rotation, so let's generate a new fallback key.
608+
true
609+
}
610+
}
611+
557612
fn fallback_key(&self) -> HashMap<KeyId, Curve25519PublicKey> {
558613
self.inner.fallback_key()
559614
}
@@ -595,6 +650,7 @@ impl Account {
595650
shared: self.shared(),
596651
uploaded_signed_key_count: self.uploaded_key_count(),
597652
creation_local_time: self.static_data.creation_local_time,
653+
fallback_key_creation_timestamp: self.fallback_creation_timestamp,
598654
}
599655
}
600656

@@ -651,6 +707,7 @@ impl Account {
651707
inner: Box::new(account),
652708
shared: pickle.shared,
653709
uploaded_signed_key_count: pickle.uploaded_signed_key_count,
710+
fallback_creation_timestamp: pickle.fallback_key_creation_timestamp,
654711
})
655712
}
656713

@@ -1371,6 +1428,7 @@ mod tests {
13711428
use std::{
13721429
collections::{BTreeMap, BTreeSet},
13731430
ops::Deref,
1431+
time::Duration,
13741432
};
13751433

13761434
use anyhow::Result;
@@ -1442,30 +1500,59 @@ mod tests {
14421500
// We don't create fallback keys since we don't know if the server
14431501
// supports them, we need to receive a sync response to decide if we're
14441502
// going to create them or not.
1445-
assert!(fallback_keys.is_empty());
1503+
assert!(
1504+
fallback_keys.is_empty(),
1505+
"We should not upload fallback keys until we know if the server supports them."
1506+
);
14461507

14471508
let one_time_keys = BTreeMap::from([(DeviceKeyAlgorithm::SignedCurve25519, 50u8.into())]);
14481509

14491510
// A `None` here means that the server doesn't support fallback keys, no
14501511
// fallback key gets uploaded.
14511512
account.update_key_counts(&one_time_keys, None);
14521513
let (_, _, fallback_keys) = account.keys_for_upload();
1453-
assert!(fallback_keys.is_empty());
1514+
assert!(
1515+
fallback_keys.is_empty(),
1516+
"We should not upload a fallback key if we're certain that the server doesn't support \
1517+
them."
1518+
);
14541519

14551520
// The empty array means that the server supports fallback keys but
14561521
// there isn't a unused fallback key on the server. This time we upload
14571522
// a fallback key.
14581523
let unused_fallback_keys = &[];
14591524
account.update_key_counts(&one_time_keys, Some(unused_fallback_keys.as_ref()));
14601525
let (_, _, fallback_keys) = account.keys_for_upload();
1461-
assert!(!fallback_keys.is_empty());
1526+
assert!(
1527+
!fallback_keys.is_empty(),
1528+
"We should upload the initial fallback key if the server supports them."
1529+
);
14621530
account.mark_keys_as_published();
14631531

1464-
// There's an unused fallback key on the server, nothing to do here.
1465-
let unused_fallback_keys = &[DeviceKeyAlgorithm::SignedCurve25519];
1532+
// There's no unused fallback key on the server, but our initial fallback key
1533+
// did not yet expire.
1534+
let unused_fallback_keys = &[];
14661535
account.update_key_counts(&one_time_keys, Some(unused_fallback_keys.as_ref()));
14671536
let (_, _, fallback_keys) = account.keys_for_upload();
1468-
assert!(fallback_keys.is_empty());
1537+
assert!(
1538+
fallback_keys.is_empty(),
1539+
"We should not upload new fallback keys unless our current fallback key expires."
1540+
);
1541+
1542+
let fallback_key_timestamp =
1543+
account.fallback_creation_timestamp.unwrap().to_system_time().unwrap()
1544+
- Duration::from_secs(3600 * 24 * 30);
1545+
1546+
account.fallback_creation_timestamp =
1547+
Some(MilliSecondsSinceUnixEpoch::from_system_time(fallback_key_timestamp).unwrap());
1548+
1549+
account.update_key_counts(&one_time_keys, None);
1550+
let (_, _, fallback_keys) = account.keys_for_upload();
1551+
assert!(
1552+
!fallback_keys.is_empty(),
1553+
"Now that our fallback key has expired, we should try to upload a new one, even if the \
1554+
server supposedly doesn't support fallback keys anymore"
1555+
);
14691556

14701557
Ok(())
14711558
}

0 commit comments

Comments
 (0)