use mdk_storage_traits::groups::types as group_types;
use mdk_storage_traits::{GroupId, MdkStorageProvider};
use nostr::{Event, TagKind};
use openmls::prelude::MlsGroup;
use crate::MDK;
use crate::error::Error;
use crate::messages::crypto::decrypt_message_with_any_supported_format;
use super::{DEFAULT_EPOCH_LOOKBACK, Result};
const LEGACY_EXPORTER_SECRET_MIGRATION_DEADLINE: u64 = 1_778_803_200;
impl<Storage> MDK<Storage>
where
Storage: MdkStorageProvider,
{
pub(super) fn decrypt_message(
&self,
nostr_group_id: [u8; 32],
event: &Event,
) -> Result<(group_types::Group, MlsGroup, Vec<u8>)> {
self.decrypt_message_at(nostr_group_id, event, nostr::Timestamp::now().as_secs())
}
pub(super) fn decrypt_message_at(
&self,
nostr_group_id: [u8; 32],
event: &Event,
current_time: u64,
) -> Result<(group_types::Group, MlsGroup, Vec<u8>)> {
let group = self
.storage()
.find_group_by_nostr_group_id(&nostr_group_id)
.map_err(|_e| Error::Group("Storage error while finding group".to_string()))?
.ok_or(Error::GroupNotFound)?;
let mls_group: MlsGroup = self
.load_mls_group(&group.mls_group_id)
.map_err(|_e| Error::Group("Storage error while loading MLS group".to_string()))?
.ok_or(Error::GroupNotFound)?;
let allow_legacy_exporter_secret =
Self::allow_legacy_exporter_secret_fallback_at(current_time);
let allow_legacy_nip44 = Self::allow_legacy_nip44_wrapper_fallback_at(event, current_time);
let message_bytes: Vec<u8> = self.try_decrypt_with_recent_epochs(
&mls_group,
&event.content,
allow_legacy_exporter_secret,
allow_legacy_nip44,
)?;
Ok((group, mls_group, message_bytes))
}
fn allow_legacy_exporter_secret_fallback_at(current_time: u64) -> bool {
current_time <= LEGACY_EXPORTER_SECRET_MIGRATION_DEADLINE
}
fn allow_legacy_nip44_wrapper_fallback_at(event: &Event, current_time: u64) -> bool {
if event.tags.iter().any(|tag| {
tag.kind() == TagKind::Custom("encoding".into()) && tag.content() == Some("base64")
}) {
return false;
}
Self::allow_legacy_exporter_secret_fallback_at(current_time)
}
fn try_decrypt_with_past_epochs(
&self,
mls_group: &MlsGroup,
encrypted_content: &str,
max_epoch_lookback: u64,
allow_legacy_exporter_secret: bool,
allow_legacy_nip44: bool,
) -> Result<Vec<u8>> {
let group_id: GroupId = mls_group.group_id().into();
let current_epoch: u64 = mls_group.epoch().as_u64();
if current_epoch == 0 || max_epoch_lookback == 0 {
return Err(Error::Message(
"No past epochs available for decryption".to_string(),
));
}
let start_epoch: u64 = current_epoch.saturating_sub(1);
let end_epoch: u64 = start_epoch.saturating_sub(max_epoch_lookback.saturating_sub(1));
for epoch in (end_epoch..=start_epoch).rev() {
tracing::debug!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"Trying to decrypt with epoch {}",
epoch
);
let maybe_secret = self
.storage()
.get_group_exporter_secret(&group_id, epoch)
.map_err(|_| {
Error::Group("Storage error while finding exporter secret".to_string())
})?;
if let Some(secret) = maybe_secret.as_ref() {
match decrypt_message_with_any_supported_format(
secret,
encrypted_content,
allow_legacy_nip44,
) {
Ok(decrypted_bytes) => {
tracing::debug!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"Successfully decrypted message with epoch {}",
epoch
);
return Ok(decrypted_bytes);
}
Err(e) => {
tracing::trace!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"Failed to decrypt with epoch {}: {:?}",
epoch,
e
);
}
}
}
if allow_legacy_exporter_secret {
match self
.storage()
.get_group_legacy_exporter_secret(&group_id, epoch)
{
Ok(Some(secret)) => {
match decrypt_message_with_any_supported_format(
&secret,
encrypted_content,
allow_legacy_nip44,
) {
Ok(decrypted_bytes) => {
tracing::debug!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"Successfully decrypted message with legacy exporter secret for epoch {}",
epoch
);
return Ok(decrypted_bytes);
}
Err(e) => {
tracing::trace!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"Failed to decrypt with legacy exporter secret for epoch {}: {:?}",
epoch,
e
);
}
}
}
Ok(None) if maybe_secret.is_none() => {
tracing::trace!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"No exporter secret found for epoch {}",
epoch
);
}
Ok(None) => {}
Err(_e) => {
return Err(Error::Group(
"Storage error while finding legacy exporter secret".to_string(),
));
}
}
} else {
tracing::trace!(
target: "mdk_core::messages::try_decrypt_with_past_epochs",
"Skipping legacy exporter-secret fallback for epoch {} because the migration deadline has passed",
epoch
);
}
}
Err(Error::Message(format!(
"Failed to decrypt message with any exporter secret from epochs {} to {}",
end_epoch, start_epoch
)))
}
pub(super) fn try_decrypt_with_recent_epochs(
&self,
mls_group: &MlsGroup,
encrypted_content: &str,
allow_legacy_exporter_secret: bool,
allow_legacy_nip44: bool,
) -> Result<Vec<u8>> {
let group_id: GroupId = mls_group.group_id().into();
let secret = self.exporter_secret(&group_id)?;
match decrypt_message_with_any_supported_format(
&secret,
encrypted_content,
allow_legacy_nip44,
) {
Ok(decrypted_bytes) => {
tracing::debug!("Successfully decrypted message with current exporter secret");
Ok(decrypted_bytes)
}
Err(_) => {
if allow_legacy_exporter_secret {
let legacy_secret = self.legacy_exporter_secret(&group_id)?;
match decrypt_message_with_any_supported_format(
&legacy_secret,
encrypted_content,
allow_legacy_nip44,
) {
Ok(decrypted_bytes) => {
tracing::debug!(
"Successfully decrypted message with legacy current exporter secret"
);
Ok(decrypted_bytes)
}
Err(_) => {
tracing::debug!(
"Failed to decrypt message with current epoch secrets. Trying past ones."
);
self.try_decrypt_with_past_epochs(
mls_group,
encrypted_content,
DEFAULT_EPOCH_LOOKBACK,
allow_legacy_exporter_secret,
allow_legacy_nip44,
)
}
}
} else {
tracing::trace!(
"Skipping legacy current exporter-secret fallback because the migration deadline has passed"
);
self.try_decrypt_with_past_epochs(
mls_group,
encrypted_content,
DEFAULT_EPOCH_LOOKBACK,
allow_legacy_exporter_secret,
allow_legacy_nip44,
)
}
}
}
}
}
#[cfg(test)]
mod tests {
use mdk_storage_traits::groups::{GroupStorage, types::GroupExporterSecret};
use nostr::nips::nip44;
use nostr::{Event, EventBuilder, Keys, Kind, SecretKey, Tag, TagKind, Timestamp};
use openmls::prelude::MlsGroup;
use crate::MdkConfig;
use crate::messages::crypto::{
decrypt_message_with_any_supported_format, encrypt_message_with_exporter_secret,
};
use crate::test_util::{
create_key_package_event, create_nostr_group_config_data, create_test_rumor,
setup_two_member_group,
};
use crate::tests::{create_test_mdk, create_test_mdk_with_config};
fn build_wrapper_event(
nostr_group_id: [u8; 32],
encrypted_content: String,
include_encoding_tag: bool,
created_at: Timestamp,
) -> Event {
let mut builder = EventBuilder::new(Kind::MlsGroupMessage, encrypted_content)
.custom_created_at(created_at)
.tag(Tag::custom(TagKind::h(), [hex::encode(nostr_group_id)]));
if include_encoding_tag {
builder = builder.tag(Tag::custom(TagKind::Custom("encoding".into()), ["base64"]));
}
builder.sign_with_keys(&Keys::generate()).unwrap()
}
fn fixed_pre_deadline_ts() -> u64 {
super::LEGACY_EXPORTER_SECRET_MIGRATION_DEADLINE.saturating_sub(1)
}
fn fixed_post_deadline_ts() -> u64 {
super::LEGACY_EXPORTER_SECRET_MIGRATION_DEADLINE.saturating_add(1)
}
fn past_epoch_delivery_result(
config: MdkConfig,
) -> Result<crate::messages::MessageProcessingResult, crate::error::Error> {
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk_with_config(config.clone());
let bob_mdk = create_test_mdk_with_config(config);
let admins = vec![alice_keys.public_key(), bob_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge creation commit");
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should process welcome");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should accept welcome");
let rumor = create_test_rumor(&alice_keys, "message from the past epoch");
let past_epoch_msg = alice_mdk
.create_message(&group_id, rumor, None)
.expect("Alice should create message");
let update_result = alice_mdk
.self_update(&group_id)
.expect("Alice should self-update");
alice_mdk
.process_message(&update_result.evolution_event)
.expect("Alice should process her own evolution event");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge self-update");
bob_mdk
.process_message(&update_result.evolution_event)
.expect("Bob should process self-update commit");
bob_mdk.process_message(&past_epoch_msg)
}
#[test]
fn test_past_epoch_application_message_fails_without_max_past_epochs() {
let config = MdkConfig {
max_past_epochs: 0, ..MdkConfig::default()
};
let result = past_epoch_delivery_result(config);
match result {
Ok(crate::messages::MessageProcessingResult::ApplicationMessage(_)) => {
panic!(
"Expected Unprocessable when max_past_epochs = 0, but the message \
decrypted successfully. OpenMLS may be retaining secrets despite \
max_past_epochs = 0."
);
}
Ok(crate::messages::MessageProcessingResult::Unprocessable { .. }) => {
}
Err(crate::error::Error::Message(_)) => {
}
other => {
panic!(
"Unexpected result (expected Unprocessable or decryption failure): {:?}",
other
);
}
}
}
#[test]
fn test_past_epoch_application_message_succeeds_with_max_past_epochs() {
let config = MdkConfig {
max_past_epochs: 5, ..MdkConfig::default()
};
let result = past_epoch_delivery_result(config);
match result {
Ok(crate::messages::MessageProcessingResult::ApplicationMessage(msg)) => {
assert_eq!(
msg.content, "message from the past epoch",
"Decrypted content should match what Alice sent"
);
}
Ok(crate::messages::MessageProcessingResult::Unprocessable { .. }) => {
panic!(
"Expected ApplicationMessage with max_past_epochs = 5, but got Unprocessable. \
The fix (wiring max_past_epochs into OpenMLS group config) is not working."
);
}
Err(e) => {
panic!(
"Expected ApplicationMessage with max_past_epochs = 5, but got error: {:?}",
e
);
}
other => {
panic!(
"Unexpected result variant (expected ApplicationMessage): {:?}",
other
);
}
}
}
#[test]
fn test_current_epoch_compat_decrypts_transition_aead_with_legacy_secret() {
let (alice_mdk, bob_mdk, alice_keys, _bob_keys, group_id) = setup_two_member_group();
let mut rumor = create_test_rumor(&alice_keys, "current epoch compat");
let mut alice_group = alice_mdk
.load_mls_group(&group_id)
.expect("Alice should load MLS group")
.expect("Alice MLS group should exist");
let serialized_message = alice_mdk
.create_mls_message_payload(&mut alice_group, &mut rumor)
.expect("Alice should create MLS payload");
let legacy_secret = alice_mdk
.legacy_exporter_secret(&group_id)
.expect("Alice should derive legacy exporter secret");
let encrypted_content =
encrypt_message_with_exporter_secret(&legacy_secret, &serialized_message)
.expect("Legacy secret should still encrypt AEAD wrapper");
let bob_group = bob_mdk
.load_mls_group(&group_id)
.expect("Bob should load MLS group")
.expect("Bob MLS group should exist");
let decrypted_bytes = bob_mdk
.try_decrypt_with_recent_epochs(&bob_group, &encrypted_content, true, false)
.expect("Current-epoch legacy AEAD fallback should work before the deadline");
assert_eq!(decrypted_bytes, serialized_message);
}
#[test]
fn test_current_epoch_legacy_aead_after_deadline_is_rejected() {
let (alice_mdk, bob_mdk, alice_keys, _bob_keys, group_id) = setup_two_member_group();
let mut rumor = create_test_rumor(&alice_keys, "current epoch compat");
let mut alice_group = alice_mdk
.load_mls_group(&group_id)
.expect("Alice should load MLS group")
.expect("Alice MLS group should exist");
let serialized_message = alice_mdk
.create_mls_message_payload(&mut alice_group, &mut rumor)
.expect("Alice should create MLS payload");
let legacy_secret = alice_mdk
.legacy_exporter_secret(&group_id)
.expect("Alice should derive legacy exporter secret");
let encrypted_content =
encrypt_message_with_exporter_secret(&legacy_secret, &serialized_message)
.expect("Legacy secret should still encrypt AEAD wrapper");
let bob_group = bob_mdk
.load_mls_group(&group_id)
.expect("Bob should load MLS group")
.expect("Bob MLS group should exist");
assert!(
!crate::MDK::<mdk_memory_storage::MdkMemoryStorage>::allow_legacy_exporter_secret_fallback_at(fixed_post_deadline_ts())
);
let result =
bob_mdk.try_decrypt_with_recent_epochs(&bob_group, &encrypted_content, false, false);
assert!(
result.is_err(),
"Current-epoch legacy AEAD fallback must be skipped after the deadline"
);
}
#[test]
fn test_past_epoch_compat_decrypts_legacy_nip44_with_stored_secret() {
let (alice_mdk, bob_mdk, alice_keys, _bob_keys, group_id) = setup_two_member_group();
let mut rumor = create_test_rumor(&alice_keys, "late legacy message");
let rumor_id = rumor.id();
let mut alice_group = alice_mdk
.load_mls_group(&group_id)
.expect("Alice should load MLS group")
.expect("Alice MLS group should exist");
let message_epoch = alice_group.epoch().as_u64();
let serialized_message = alice_mdk
.create_mls_message_payload(&mut alice_group, &mut rumor)
.expect("Alice should create MLS payload");
let legacy_secret = alice_mdk
.legacy_exporter_secret(&group_id)
.expect("Alice should derive legacy exporter secret");
let secret_key = SecretKey::from_slice(legacy_secret.secret.as_ref()).unwrap();
let export_nostr_keys = Keys::new(secret_key);
let encrypted_content = nip44::encrypt(
export_nostr_keys.secret_key(),
&export_nostr_keys.public_key,
&serialized_message,
nip44::Version::default(),
)
.expect("Alice should encrypt legacy NIP-44 wrapper");
bob_mdk
.storage()
.save_group_exporter_secret(GroupExporterSecret {
mls_group_id: group_id.clone(),
epoch: message_epoch,
secret: legacy_secret.secret.clone(),
})
.expect("Bob should persist migrated legacy secret");
let update_result = alice_mdk
.self_update(&group_id)
.expect("Alice should self-update");
alice_mdk
.process_message(&update_result.evolution_event)
.expect("Alice should process her own self-update");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge self-update");
bob_mdk
.process_message(&update_result.evolution_event)
.expect("Bob should process self-update");
let direct_decrypted =
crate::messages::crypto::decrypt_message_with_legacy_exporter_secret(
&legacy_secret,
&encrypted_content,
)
.expect("Freshly encrypted legacy wrapper should round-trip");
assert_eq!(direct_decrypted, serialized_message);
let stored_secret = bob_mdk
.storage()
.get_group_exporter_secret(&group_id, message_epoch)
.expect("Bob should load stored legacy secret")
.expect("Current exporter secret should still exist");
assert_ne!(stored_secret.secret, legacy_secret.secret);
let stored_legacy_secret = bob_mdk
.storage()
.get_group_legacy_exporter_secret(&group_id, message_epoch)
.expect("Bob should load preserved legacy secret")
.expect("Legacy exporter secret should be preserved separately");
assert_eq!(stored_legacy_secret.secret, legacy_secret.secret);
let decrypted_bytes = decrypt_message_with_any_supported_format(
&stored_legacy_secret,
&encrypted_content,
true,
)
.expect("Stored legacy secret should decrypt delayed wrapper directly");
assert_eq!(decrypted_bytes, serialized_message);
let group = alice_mdk
.get_group(&group_id)
.expect("Alice should load group")
.expect("Group should exist");
let event = build_wrapper_event(
group.nostr_group_id,
encrypted_content,
false,
Timestamp::from(fixed_pre_deadline_ts()),
);
assert!(
crate::MDK::<mdk_memory_storage::MdkMemoryStorage>::allow_legacy_nip44_wrapper_fallback_at(
&event,
fixed_pre_deadline_ts(),
)
);
let result = bob_mdk
.process_message_at(&event, Timestamp::from(fixed_pre_deadline_ts()))
.expect("Bob should process delayed legacy event");
match result {
crate::messages::MessageProcessingResult::ApplicationMessage(message) => {
assert_eq!(message.id, rumor_id);
assert_eq!(message.content, "late legacy message");
}
other => panic!("Expected ApplicationMessage, got {:?}", other),
}
}
#[test]
fn test_legacy_nip44_wrapper_after_deadline_is_rejected() {
assert!(
!crate::MDK::<mdk_memory_storage::MdkMemoryStorage>::allow_legacy_nip44_wrapper_fallback_at(
&build_wrapper_event([7u8; 32], "ignored".to_string(), false, Timestamp::now()),
fixed_post_deadline_ts(),
),
"Legacy wrapper after deadline must be rejected"
);
}
#[test]
fn test_past_epoch_legacy_aead_after_deadline_is_rejected() {
let (alice_mdk, bob_mdk, alice_keys, _bob_keys, group_id) = setup_two_member_group();
let mut rumor = create_test_rumor(&alice_keys, "late legacy aead");
let mut alice_group = alice_mdk
.load_mls_group(&group_id)
.expect("Alice should load MLS group")
.expect("Alice MLS group should exist");
let serialized_message = alice_mdk
.create_mls_message_payload(&mut alice_group, &mut rumor)
.expect("Alice should create MLS payload");
let legacy_secret = alice_mdk
.legacy_exporter_secret(&group_id)
.expect("Alice should derive legacy exporter secret");
let encrypted_content =
encrypt_message_with_exporter_secret(&legacy_secret, &serialized_message)
.expect("Legacy secret should still encrypt AEAD wrapper");
let update_result = alice_mdk
.self_update(&group_id)
.expect("Alice should self-update");
alice_mdk
.process_message(&update_result.evolution_event)
.expect("Alice should process her own self-update");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge self-update");
bob_mdk
.process_message(&update_result.evolution_event)
.expect("Bob should process self-update");
let bob_group = bob_mdk
.load_mls_group(&group_id)
.expect("Bob should load MLS group")
.expect("Bob MLS group should exist");
assert!(
!crate::MDK::<mdk_memory_storage::MdkMemoryStorage>::allow_legacy_exporter_secret_fallback_at(fixed_post_deadline_ts())
);
let result = bob_mdk.try_decrypt_with_past_epochs(
&bob_group,
&encrypted_content,
super::DEFAULT_EPOCH_LOOKBACK,
false,
false,
);
assert!(
result.is_err(),
"Past-epoch legacy AEAD fallback must be skipped after the deadline"
);
}
#[test]
fn test_epoch_lookback_limits() {
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key(), bob_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should be able to create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge Alice's create commit");
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should be able to process welcome");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should be able to accept welcome");
let rumor_epoch1 = create_test_rumor(&alice_keys, "Message in epoch 1");
let msg_epoch1 = alice_mdk
.create_message(&group_id, rumor_epoch1, None)
.expect("Alice should send message in epoch 1");
let bob_process1 = bob_mdk.process_message(&msg_epoch1);
assert!(
bob_process1.is_ok(),
"Bob should process epoch 1 message initially"
);
for i in 1..=7 {
let update_result = alice_mdk
.self_update(&group_id)
.expect("Alice should be able to update");
alice_mdk
.process_message(&update_result.evolution_event)
.expect("Alice should process update");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge update");
bob_mdk
.process_message(&update_result.evolution_event)
.expect("Bob should process update");
let rumor = create_test_rumor(&alice_keys, &format!("Message in epoch {}", i + 1));
let msg = alice_mdk
.create_message(&group_id, rumor, None)
.expect("Alice should send message");
let process_result = bob_mdk.process_message(&msg);
assert!(
process_result.is_ok(),
"Bob should process message from epoch {}",
i + 1
);
}
let final_epoch = alice_mdk
.get_group(&group_id)
.expect("Failed to get group")
.expect("Group should exist")
.epoch;
assert_eq!(
final_epoch, 8,
"Group should be at epoch 8 after group creation (epoch 1) + 7 updates"
);
}
#[test]
fn test_past_epoch_decryption_guards_epoch_zero() {
let alice_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("Should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge commit");
let mls_group: MlsGroup = alice_mdk
.load_mls_group(&group_id)
.expect("Should load group")
.expect("Group should exist");
assert_eq!(
mls_group.epoch().as_u64(),
0,
"Group should be at epoch 0 after creation"
);
let result = alice_mdk.try_decrypt_with_past_epochs(
&mls_group,
"invalid_encrypted_content",
5, false,
false,
);
assert!(result.is_err(), "Should fail at epoch 0");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("No past epochs available"),
"Error should indicate no past epochs: {}",
err_msg
);
}
#[test]
fn test_past_epoch_decryption_guards_zero_lookback() {
let alice_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("Should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge commit");
for _ in 0..3 {
let update = alice_mdk.self_update(&group_id).expect("Should update");
alice_mdk
.process_message(&update.evolution_event)
.expect("Should process update");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge");
}
let mls_group: MlsGroup = alice_mdk
.load_mls_group(&group_id)
.expect("Should load group")
.expect("Group should exist");
assert!(
mls_group.epoch().as_u64() > 1,
"Group should be past epoch 1"
);
let result = alice_mdk.try_decrypt_with_past_epochs(
&mls_group,
"invalid_encrypted_content",
0, false,
false,
);
assert!(result.is_err(), "Should fail with zero lookback");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("No past epochs available"),
"Error should indicate no past epochs: {}",
err_msg
);
}
}