From fb505689e1ad5514790ad6ddad45d8449c15daff Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Mon, 3 Aug 2026 15:59:16 +0200 Subject: [PATCH 01/25] refactor(crypto): rename `Text` variant of `DecryptedMessage` to `ApplicationMessage` --- crypto/src/lib.rs | 4 +- crypto/src/mls/conversation/mod.rs | 2 +- .../mutable/decrypt/buffer_messages.rs | 4 +- .../mls/conversation/mutable/decrypt/mod.rs | 37 +++++++++---------- .../src/mls/conversation/mutable/encrypt.rs | 18 ++++++--- crypto/src/mls/conversation/pending.rs | 2 +- .../src/test_utils/test_conversation/mod.rs | 8 ++-- interop/src/main.rs | 2 +- 8 files changed, 42 insertions(+), 35 deletions(-) diff --git a/crypto/src/lib.rs b/crypto/src/lib.rs index a5af153df6..1465f26d8c 100644 --- a/crypto/src/lib.rs +++ b/crypto/src/lib.rs @@ -59,8 +59,8 @@ pub use crate::{ cipher_suite::CipherSuite, conversation::{ BufferedCommit, BufferedDecryptedMessage, Commit, CommitBundle, ConversationConfiguration, ConversationId, - CustomConfiguration, DecryptedMessage, GroupInfoBundle, GroupInfoEncryptionType, GroupInfoPayload, - Proposal, RatchetTreeType, Text, WirePolicy, + CustomConfiguration, DecryptedBytes, DecryptedMessage, GroupInfoBundle, GroupInfoEncryptionType, + GroupInfoPayload, Proposal, RatchetTreeType, WirePolicy, }, credential::{ Credential, CredentialRef, CredentialType, FindFilters as CredentialFindFilters, x509::CertificateBundle, diff --git a/crypto/src/mls/conversation/mod.rs b/crypto/src/mls/conversation/mod.rs index f8ec90f980..28d118f117 100644 --- a/crypto/src/mls/conversation/mod.rs +++ b/crypto/src/mls/conversation/mod.rs @@ -35,7 +35,7 @@ pub use self::{ immutable::Conversation, mutable::{ ConversationMut, TargetedMessagePolicy, - decrypt::{BufferedCommit, BufferedDecryptedMessage, Commit, DecryptedMessage, Proposal, Text}, + decrypt::{BufferedCommit, BufferedDecryptedMessage, Commit, DecryptedBytes, DecryptedMessage, Proposal}, }, welcome::WelcomeMessage, }; diff --git a/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs b/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs index 70adf579b7..a7f537cf06 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs @@ -206,7 +206,7 @@ mod tests { .expect("Alice's messages should have been restored at this point"); for (idx, buffered_message) in restored_messages.into_iter().enumerate() { - let text = DecryptedMessage::from(buffered_message).into_text(); + let text = DecryptedMessage::from(buffered_message).into_application_message(); if idx == 0 { // this is the application message assert_eq!(text.unwrap().plaintext, b"Hello Bob !"); @@ -302,7 +302,7 @@ mod tests { .buffered_messages .expect("Alice's messages should have been restored at this point"); for (idx, buffered_message) in restored_messages.into_iter().enumerate() { - let text = DecryptedMessage::from(buffered_message).into_text(); + let text = DecryptedMessage::from(buffered_message).into_application_message(); if idx == 0 { assert_eq!(text.unwrap().plaintext, b"Hello Alice !"); } else { diff --git a/crypto/src/mls/conversation/mutable/decrypt/mod.rs b/crypto/src/mls/conversation/mutable/decrypt/mod.rs index 0822ae064c..f71d833429 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/mod.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/mod.rs @@ -33,10 +33,10 @@ use crate::{ mls::{conversation::Error, credential::ext::CredentialExt as _}, }; -/// A decrypted MLS application message +/// Decrypted Bytes #[derive(Debug)] -pub struct Text { - /// Decrypted text message +pub struct DecryptedBytes { + /// Decrypted content pub plaintext: Vec, /// The sender's [ClientId]. pub sender_client_id: ClientId, @@ -74,12 +74,11 @@ pub struct BufferedCommit { pub identity: WireIdentity, } -/// Represents the potential items a consumer might require after passing us an encrypted message we -/// have decrypted for him +/// Represents the items a consumer might require after decrypting a message. #[derive(Debug, enum_as_inner::EnumAsInner)] pub enum DecryptedMessage { - /// The decrypted message is a text message. - Text(Text), + /// The decrypted message is an MLS application message. + ApplicationMessage(DecryptedBytes), /// The decrypted message is a commit. Commit(Commit), /// The decrypted message is a proposal. @@ -90,7 +89,7 @@ impl DecryptedMessage { /// Identity claims present in the sender credential pub fn identity(&self) -> &WireIdentity { match self { - DecryptedMessage::Text(text) => &text.identity, + DecryptedMessage::ApplicationMessage(application_message) => &application_message.identity, DecryptedMessage::Commit(commit) => &commit.identity, DecryptedMessage::Proposal(proposal) => &proposal.identity, } @@ -101,8 +100,8 @@ impl DecryptedMessage { /// It represents messages for the new epoch that arrived before the commit that created it. #[derive(Debug)] pub enum BufferedDecryptedMessage { - /// The decrypted message is a text message. - Text(Text), + /// The decrypted message is an MLS application message. + ApplicationMessage(DecryptedBytes), /// The decrypted message is a commit. Commit(BufferedCommit), /// The decrypted message is a proposal. @@ -112,7 +111,7 @@ pub enum BufferedDecryptedMessage { impl From for BufferedDecryptedMessage { fn from(value: DecryptedMessage) -> Self { match value { - DecryptedMessage::Text(text) => Self::Text(text), + DecryptedMessage::ApplicationMessage(message) => Self::ApplicationMessage(message), DecryptedMessage::Commit(commit) => Self::Commit({ BufferedCommit { is_active: commit.is_active, @@ -127,7 +126,7 @@ impl From for BufferedDecryptedMessage { impl From for DecryptedMessage { fn from(value: BufferedDecryptedMessage) -> Self { match value { - BufferedDecryptedMessage::Text(text) => Self::Text(text), + BufferedDecryptedMessage::ApplicationMessage(message) => Self::ApplicationMessage(message), BufferedDecryptedMessage::Commit(buffered_commit) => Self::Commit(Commit { is_active: buffered_commit.is_active, buffered_messages: None, @@ -269,7 +268,7 @@ impl ConversationMut { "Application message" ); - DecryptedMessage::Text(Text { + DecryptedMessage::ApplicationMessage(DecryptedBytes { plaintext: app_msg.into_bytes(), sender_client_id, identity, @@ -674,7 +673,7 @@ mod tests { .decrypt_message(encrypted) .await .unwrap(); - let dec_msg = &decrypted.as_text().unwrap().plaintext; + let dec_msg = &decrypted.as_application_message().unwrap().plaintext; assert_eq!(dec_msg, msg); assert!(!bob_observer.has_changed().await); alice @@ -685,7 +684,7 @@ mod tests { let encrypted = conversation.guard_of(&bob).await.encrypt_message(msg).await.unwrap(); assert_ne!(&msg[..], &encrypted[..]); let decrypted = conversation.guard().await.decrypt_message(encrypted).await.unwrap(); - let dec_msg = &decrypted.as_text().unwrap().plaintext; + let dec_msg = &decrypted.as_application_message().unwrap().plaintext; assert_eq!(dec_msg, &msg[..]); assert!(!alice_observer.has_changed().await); bob.verify_sender_identity(&case, &bob.initial_credential, &decrypted) @@ -737,7 +736,7 @@ mod tests { let decrypted_commit = decrypted_commit.unwrap().into_commit().unwrap(); let buffered_msg = decrypted_commit.buffered_messages.unwrap().remove(0); let decrypted_msg = crate::DecryptedMessage::from(buffered_msg); - let decrypted_msg = &decrypted_msg.as_text().unwrap().plaintext; + let decrypted_msg = &decrypted_msg.as_application_message().unwrap().plaintext; assert_eq!(&decrypted_msg, &msg); }) .await @@ -765,7 +764,7 @@ mod tests { let decrypt = conversation.guard_of(&bob).await.decrypt_message(encrypted).await; if i < out_of_order_tolerance as usize { let decrypt = decrypt.unwrap(); - let decrypted = &decrypt.as_text().unwrap().plaintext; + let decrypted = &decrypt.as_application_message().unwrap().plaintext; assert_eq!(decrypted, original.as_bytes()); } else { assert!(matches!(decrypt.unwrap_err(), Error::DuplicateMessage)) @@ -792,7 +791,7 @@ mod tests { .await .unwrap(); - let sender_client_id = &decrypted.as_text().unwrap().sender_client_id; + let sender_client_id = &decrypted.as_application_message().unwrap().sender_client_id; assert_eq!(sender_client_id, &alice.get_client_id().await); }) .await @@ -831,7 +830,7 @@ mod tests { .decrypt_message(&bob_message1) .await .unwrap(); - assert_eq!(decrypt.as_text().unwrap().plaintext, b"Hello Bob"); + assert_eq!(decrypt.as_application_message().unwrap().plaintext, b"Hello Bob"); // Moving the epochs once more should cause an error let conversation = conversation.update_notify().await; diff --git a/crypto/src/mls/conversation/mutable/encrypt.rs b/crypto/src/mls/conversation/mutable/encrypt.rs index 303810a6bf..6475ae6960 100644 --- a/crypto/src/mls/conversation/mutable/encrypt.rs +++ b/crypto/src/mls/conversation/mutable/encrypt.rs @@ -70,8 +70,10 @@ mod tests { .await .decrypt_message(encrypted) .await - .unwrap(); - let decrypted = &decrypted.as_text().unwrap().plaintext; + .unwrap() + .into_application_message() + .unwrap() + .plaintext; assert_eq!(&decrypted[..], &msg[..]); }) .await @@ -92,8 +94,10 @@ mod tests { .await .decrypt_message(encrypted) .await - .unwrap(); - let decrypted = &decrypted.as_text().unwrap().plaintext; + .unwrap() + .into_application_message() + .unwrap() + .plaintext; assert_eq!(&decrypted[..], &msg[..]); let msg = b"Hello bob again"; @@ -104,8 +108,10 @@ mod tests { .await .decrypt_message(encrypted) .await - .unwrap(); - let decrypted = &decrypted.as_text().unwrap().plaintext; + .unwrap() + .into_application_message() + .unwrap() + .plaintext; assert_eq!(&decrypted[..], &msg[..]); }) .await diff --git a/crypto/src/mls/conversation/pending.rs b/crypto/src/mls/conversation/pending.rs index 4d43e60e5b..02ec896944 100644 --- a/crypto/src/mls/conversation/pending.rs +++ b/crypto/src/mls/conversation/pending.rs @@ -456,7 +456,7 @@ mod tests { }; for (idx, buffered_message) in restored_messages.into_iter().enumerate() { - let text = DecryptedMessage::from(buffered_message).into_text(); + let text = DecryptedMessage::from(buffered_message).into_application_message(); if idx == 0 { // the only application message let msg = text.unwrap().plaintext; diff --git a/crypto/src/test_utils/test_conversation/mod.rs b/crypto/src/test_utils/test_conversation/mod.rs index 9e76b21dbb..14d8aef8b2 100644 --- a/crypto/src/test_utils/test_conversation/mod.rs +++ b/crypto/src/test_utils/test_conversation/mod.rs @@ -194,10 +194,12 @@ impl<'a> TestConversation<'a> { .await .map_err(RecursiveError::mls_conversation( "decrypting message; receiver <- sender", - ))?; - let plaintext = &decrypted.as_text().ok_or(TestError::ImplementationError)?.plaintext; + ))? + .into_application_message() + .map_err(|_| TestError::ImplementationError)? + .plaintext; - assert_eq!(&msg[..], &plaintext[..]); + assert_eq!(&msg[..], &decrypted[..]); Ok(()) } diff --git a/interop/src/main.rs b/interop/src/main.rs index 9a19910be7..5e05ab9e0f 100644 --- a/interop/src/main.rs +++ b/interop/src/main.rs @@ -259,7 +259,7 @@ async fn run_mls_test(chrome_driver_addr: &std::net::SocketAddr, web_server: &st .unwrap() .decrypt_message(message_to_decrypt) .await? - .into_text() + .into_application_message() .map_err(|_| anyhow!("[MLS] No message received on master client"))? .plaintext; From 8893ed32dd7430f2c49d809dc0988b3b5842fe4d Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Mon, 3 Aug 2026 16:04:54 +0200 Subject: [PATCH 02/25] refactor(crypto-ffi): rename `Text` variant of `DecryptedMessage` to `ApplicationMessage` --- crypto-ffi/src/decrypted_message.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crypto-ffi/src/decrypted_message.rs b/crypto-ffi/src/decrypted_message.rs index 08fe9dcf5e..24023dc716 100644 --- a/crypto-ffi/src/decrypted_message.rs +++ b/crypto-ffi/src/decrypted_message.rs @@ -7,8 +7,8 @@ use crate::{ClientId, WireIdentity}; /// Represents the items a consumer might require after decrypting a message. #[derive(Debug, uniffi::Enum)] pub enum DecryptedMessage { - /// The decrypted message is a text message. - Text { + /// The decrypted message is an MLS application message. + ApplicationMessage { /// Decrypted text message. plaintext: Vec, /// The sender's `ClientId`. @@ -42,10 +42,10 @@ pub enum DecryptedMessage { impl From for DecryptedMessage { fn from(from: CcDecryptedMessage) -> Self { match from { - CcDecryptedMessage::Text(text) => Self::Text { - plaintext: text.plaintext, - sender_client_id: Arc::new(text.sender_client_id.into()), - identity: text.identity.into(), + CcDecryptedMessage::ApplicationMessage(message) => Self::ApplicationMessage { + plaintext: message.plaintext, + sender_client_id: Arc::new(message.sender_client_id.into()), + identity: message.identity.into(), }, CcDecryptedMessage::Commit(commit) => Self::Commit { is_active: commit.is_active, @@ -66,8 +66,8 @@ impl From for DecryptedMessage { /// It represents messages for the new epoch that arrived before the commit that created it. #[derive(Debug, uniffi::Enum)] pub enum BufferedDecryptedMessage { - /// The decrypted message is a text message. - Text { + /// The decrypted message is an MLS application message. + ApplicationMessage { /// Decrypted text message. plaintext: Vec, /// The sender's `ClientId`. @@ -98,10 +98,10 @@ pub enum BufferedDecryptedMessage { impl From for BufferedDecryptedMessage { fn from(from: CcBufferedDecryptedMessage) -> Self { match from { - CcBufferedDecryptedMessage::Text(text) => Self::Text { - plaintext: text.plaintext, - sender_client_id: Arc::new(text.sender_client_id.into()), - identity: text.identity.into(), + CcBufferedDecryptedMessage::ApplicationMessage(message) => Self::ApplicationMessage { + plaintext: message.plaintext, + sender_client_id: Arc::new(message.sender_client_id.into()), + identity: message.identity.into(), }, CcBufferedDecryptedMessage::Commit(commit) => Self::Commit { is_active: commit.is_active, From c04caef61bb7ce36f1908c42d17f9a8b8b635dc2 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Mon, 3 Aug 2026 16:15:24 +0200 Subject: [PATCH 03/25] chore: `DecryptedMessage.Text` -> `DecryptedMessage.ApplicationMessage` in wrappers --- crypto-ffi/bindings/js/shared/shared/utils.ts | 4 ++-- .../shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt | 2 +- .../WireCoreCryptoTests/WireCoreCryptoTests.swift | 2 +- .../InteropClient/InteropClient/InteropClientApp.swift | 2 +- .../main/java/com/wire/androidinterop/InteropActionHandler.kt | 2 +- interop/src/clients/corecrypto/web/mls.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crypto-ffi/bindings/js/shared/shared/utils.ts b/crypto-ffi/bindings/js/shared/shared/utils.ts index 50778ed7f0..6b82f3e2c1 100644 --- a/crypto-ffi/bindings/js/shared/shared/utils.ts +++ b/crypto-ffi/bindings/js/shared/shared/utils.ts @@ -423,10 +423,10 @@ async function setHelpers() { ); if ( - ccModule.DecryptedMessage.Text.instanceOf( + ccModule.DecryptedMessage.ApplicationMessage.instanceOf( decryptedByClient1 ) && - ccModule.DecryptedMessage.Text.instanceOf( + ccModule.DecryptedMessage.ApplicationMessage.instanceOf( decryptedByClient2 ) ) { diff --git a/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt b/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt index a3aad2b829..82e2874eaf 100644 --- a/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt +++ b/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt @@ -222,7 +222,7 @@ class MLSTest { val plaintextMsg = bob.transaction { ctx -> - assertIs(ctx.decryptMessage(groupId, ciphertextMsg)).plaintext + assertIs(ctx.decryptMessage(groupId, ciphertextMsg)).plaintext } assertThat(plaintextMsg).isNotEmpty().isEqualTo(msg) diff --git a/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift b/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift index fca39de637..24434dea2d 100644 --- a/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift +++ b/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift @@ -597,7 +597,7 @@ final class WireCoreCryptoTests: XCTestCase { let decrypted = try await bob.transaction { ctx in try await ctx.decryptMessage(conversationId: conversationId, payload: ciphertext) } - guard case .text(let plaintext, _, _) = decrypted else { + guard case .applicationMessage(let plaintext, _, _) = decrypted else { XCTFail("Expected decrypted text") return } diff --git a/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift b/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift index 0273e67363..2b7d48d0cf 100644 --- a/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift +++ b/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift @@ -214,7 +214,7 @@ struct InteropClientApp: App { } switch decryptedMessage { - case .text(let plaintext, _, _): + case .applicationMessage(let plaintext, _, _): return plaintext.base64EncodedString() case .commit, .proposal: return "decrypted protocol message" diff --git a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt index a69401e37c..f8889ab06f 100644 --- a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt +++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt @@ -73,7 +73,7 @@ class InteropActionHandler(val coreCrypto: CoreCrypto) { context.decryptMessage(ConversationId(bytes = action.conversationId), action.message) } ) { - is DecryptedMessage.Text -> Result.success(Base64.Default.encode(decryptedMessage.plaintext)) + is DecryptedMessage.ApplicationMessage -> Result.success(Base64.Default.encode(decryptedMessage.plaintext)) is DecryptedMessage.Commit, is DecryptedMessage.Proposal -> Result.success("decrypted protocol message") diff --git a/interop/src/clients/corecrypto/web/mls.ts b/interop/src/clients/corecrypto/web/mls.ts index d2fdc1811c..3d464f56dc 100644 --- a/interop/src/clients/corecrypto/web/mls.ts +++ b/interop/src/clients/corecrypto/web/mls.ts @@ -121,7 +121,7 @@ export async function decryptMessage() { const decryptedMessage = await window.cc.transaction((ctx) => ctx.decryptMessage(conversationId, encryptedMessage) ); - if (DecryptedMessage.Text.instanceOf(decryptedMessage)) { + if (DecryptedMessage.ApplicationMessage.instanceOf(decryptedMessage)) { return decryptedMessage.inner.plaintext; } return null; From b907770b73830e8472a28e90b5e4edcbef333714 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Mon, 3 Aug 2026 16:12:16 +0200 Subject: [PATCH 04/25] docs: update migration guide for `DecryptedMessage.ApplicationMessage` --- cc-book/src/unreleased/migration_guide.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cc-book/src/unreleased/migration_guide.md b/cc-book/src/unreleased/migration_guide.md index 3ba4022f7b..970af317df 100644 --- a/cc-book/src/unreleased/migration_guide.md +++ b/cc-book/src/unreleased/migration_guide.md @@ -11,13 +11,14 @@ ## DecryptedMessage -`DecryptedMessage` is now an enum with `Text`, `Commit`, and `Proposal` variants. Data that was previously exposed -through optional properties is now carried by the corresponding variant. Match on the variant before accessing its data: +`DecryptedMessage` is now an enum with `ApplicationMessage`, `Commit`, and `Proposal` variants. Data that was previously +exposed through optional properties is now carried by the corresponding variant. Match on the variant before accessing +its data: ```typescript -if (DecryptedMessage.Text.instanceOf(decryptedMessage)) { +if (DecryptedMessage.ApplicationMessage.instanceOf(decryptedMessage)) { const { plaintext, senderClientId, identity } = decryptedMessage.inner; // Handle the application message. } else if (DecryptedMessage.Commit.instanceOf(decryptedMessage)) { @@ -31,7 +32,7 @@ if (DecryptedMessage.Text.instanceOf(decryptedMessage)) { ```swift switch decryptedMessage { -case let .text(plaintext, senderClientId, identity): +case let .applicationMessage(plaintext, senderClientId, identity): // Handle the application message. case let .commit(isActive, bufferedMessages, identity): // Handle the commit. @@ -42,7 +43,7 @@ case let .proposal(delay, identity): ```kotlin when (decryptedMessage) { - is DecryptedMessage.Text -> { + is DecryptedMessage.ApplicationMessage -> { val (plaintext, senderClientId, identity) = decryptedMessage // Handle the application message. } From 0c5b3d02fa652b9930b2423dd88d5177a3fca17c Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Tue, 18 Aug 2026 14:58:51 +0200 Subject: [PATCH 05/25] feat(crypto-provider): support decrypting via hpke psk mode The inverse of what was implemented in 0277eea653ff39eb67e0b6645d2c1c37ab516798. This enables adding a round-trip test for encrypting-decrypting with some dummy data. --- crypto/src/mls_provider/crypto_provider.rs | 110 +++++++++++++++++++++ crypto/src/mls_provider/mod.rs | 15 +++ crypto/src/mls_provider/tests/crypto.rs | 33 +++++++ 3 files changed, 158 insertions(+) diff --git a/crypto/src/mls_provider/crypto_provider.rs b/crypto/src/mls_provider/crypto_provider.rs index 052c4d41e3..2b38bd366f 100644 --- a/crypto/src/mls_provider/crypto_provider.rs +++ b/crypto/src/mls_provider/crypto_provider.rs @@ -103,6 +103,81 @@ impl RustCrypto { _ => Err(CryptoError::UnsupportedKem), } } + + #[expect(clippy::too_many_arguments)] + pub(crate) fn hpke_open_psk( + &self, + config: HpkeConfig, + input: &HpkeCiphertext, + sk_r: &[u8], + info: &[u8], + aad: &[u8], + psk: &[u8], + psk_id: &[u8], + ) -> Result, CryptoError> { + match config { + HpkeConfig(HpkeKemType::DhKem25519, HpkeKdfType::HkdfSha256, HpkeAeadType::AesGcm128) => { + hpke_core::hpke_open_psk::( + sk_r, + input.kem_output.as_slice(), + info, + aad, + psk, + psk_id, + input.ciphertext.as_slice(), + ) + } + HpkeConfig(HpkeKemType::DhKem25519, HpkeKdfType::HkdfSha256, HpkeAeadType::ChaCha20Poly1305) => { + hpke_core::hpke_open_psk::< + hpke::aead::ChaCha20Poly1305, + hpke::kdf::HkdfSha256, + hpke::kem::X25519HkdfSha256, + >( + sk_r, + input.kem_output.as_slice(), + info, + aad, + psk, + psk_id, + input.ciphertext.as_slice(), + ) + } + HpkeConfig(HpkeKemType::DhKemP256, HpkeKdfType::HkdfSha256, HpkeAeadType::AesGcm128) => { + hpke_core::hpke_open_psk::( + sk_r, + input.kem_output.as_slice(), + info, + aad, + psk, + psk_id, + input.ciphertext.as_slice(), + ) + } + HpkeConfig(HpkeKemType::DhKemP384, HpkeKdfType::HkdfSha384, HpkeAeadType::AesGcm256) => { + hpke_core::hpke_open_psk::( + sk_r, + input.kem_output.as_slice(), + info, + aad, + psk, + psk_id, + input.ciphertext.as_slice(), + ) + } + HpkeConfig(HpkeKemType::DhKemP521, HpkeKdfType::HkdfSha512, HpkeAeadType::AesGcm256) => { + hpke_core::hpke_open_psk::( + sk_r, + input.kem_output.as_slice(), + info, + aad, + psk, + psk_id, + input.ciphertext.as_slice(), + ) + } + _ => Err(CryptoError::UnsupportedKem), + } + } } impl OpenMlsCrypto for RustCrypto { @@ -613,6 +688,41 @@ mod hpke_core { Ok(plaintext) } + pub(crate) fn hpke_open_psk( + private_key: &[u8], + kem_output: &[u8], + info: &[u8], + aad: &[u8], + psk: &[u8], + psk_id: &[u8], + ciphertext: &[u8], + ) -> Result, CryptoError> { + use hpke::{Deserializable as _, Serializable as _}; + let encapped_key = Kem::EncappedKey::from_bytes(kem_output).map_err(|_| CryptoError::HpkeDecryptionError)?; + // Systematically normalize private keys + let sk_len = Kem::PrivateKey::size(); + let mut sk_buf = zeroize::Zeroizing::new(Vec::with_capacity(sk_len)); + if private_key.len() < sk_len { + for _ in 0..(sk_len - private_key.len()) { + sk_buf.push(0x00); + } + } + sk_buf.extend_from_slice(private_key); + let key = Kem::PrivateKey::from_bytes(&sk_buf).map_err(|_| CryptoError::HpkeDecryptionError)?; + let psk_bundle = PskBundle { psk, psk_id }; + let plaintext = hpke::single_shot_open::( + &hpke::OpModeR::Psk(psk_bundle), + &key, + &encapped_key, + info, + ciphertext, + aad, + ) + .map_err(|_| CryptoError::HpkeDecryptionError)?; + + Ok(plaintext) + } + pub(crate) fn hpke_seal( public_key: &[u8], info: &[u8], diff --git a/crypto/src/mls_provider/mod.rs b/crypto/src/mls_provider/mod.rs index 85d3b17947..c48cc00449 100644 --- a/crypto/src/mls_provider/mod.rs +++ b/crypto/src/mls_provider/mod.rs @@ -168,6 +168,21 @@ impl CryptoProvider { ) -> Result { self.crypto.hpke_seal_psk(config, pk_r, info, aad, psk, psk_id, ptxt) } + + /// Decrypt `input` via HPKE PSK mode. + #[expect(clippy::too_many_arguments)] + pub fn hpke_open_psk( + &self, + config: HpkeConfig, + input: &HpkeCiphertext, + sk_r: &[u8], + info: &[u8], + aad: &[u8], + psk: &[u8], + psk_id: &[u8], + ) -> Result, CryptoError> { + self.crypto.hpke_open_psk(config, input, sk_r, info, aad, psk, psk_id) + } } impl openmls_traits::OpenMlsCryptoProvider for CryptoProvider { diff --git a/crypto/src/mls_provider/tests/crypto.rs b/crypto/src/mls_provider/tests/crypto.rs index dd673c6fb6..4110c09d63 100644 --- a/crypto/src/mls_provider/tests/crypto.rs +++ b/crypto/src/mls_provider/tests/crypto.rs @@ -182,6 +182,39 @@ mod tests { assert_eq!(unsealed_secret_message, message); + let psk = backend + .rand() + .random_vec(rand::thread_rng().gen_range(LEN_RANGE)) + .unwrap(); + let psk_id = backend + .rand() + .random_vec(rand::thread_rng().gen_range(LEN_RANGE)) + .unwrap(); + let secret_message = crypto + .hpke_seal_psk( + cipher_suite.hpke_config(), + &alice.public, + &info, + &aad, + &psk, + &psk_id, + &message, + ) + .unwrap(); + let unsealed_secret_message = crypto + .hpke_open_psk( + cipher_suite.hpke_config(), + &secret_message, + &alice.private, + &info, + &aad, + &psk, + &psk_id, + ) + .unwrap(); + + assert_eq!(unsealed_secret_message, message); + let hpke_info = b"MLS 1.0 external init"; let (kem, secret_tx) = crypto From acf3bcc0e876077655bf13f6b9570f2822606441 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Tue, 18 Aug 2026 17:11:35 +0200 Subject: [PATCH 06/25] refactor(conversation/decrypt): add targeted message variants to `DecryptedMessage` --- .../src/mls/conversation/mutable/decrypt/mod.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crypto/src/mls/conversation/mutable/decrypt/mod.rs b/crypto/src/mls/conversation/mutable/decrypt/mod.rs index f71d833429..15ffab4bce 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/mod.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/mod.rs @@ -83,13 +83,19 @@ pub enum DecryptedMessage { Commit(Commit), /// The decrypted message is a proposal. Proposal(Proposal), + /// The decrypted message is a transient targeted message. + TransientTargeted(DecryptedBytes), + /// The decrypted message is a persisted targeted message. + PersistedTargeted(DecryptedBytes), } impl DecryptedMessage { /// Identity claims present in the sender credential pub fn identity(&self) -> &WireIdentity { match self { - DecryptedMessage::ApplicationMessage(application_message) => &application_message.identity, + DecryptedMessage::ApplicationMessage(decrypted_bytes) + | DecryptedMessage::TransientTargeted(decrypted_bytes) + | DecryptedMessage::PersistedTargeted(decrypted_bytes) => &decrypted_bytes.identity, DecryptedMessage::Commit(commit) => &commit.identity, DecryptedMessage::Proposal(proposal) => &proposal.identity, } @@ -106,6 +112,10 @@ pub enum BufferedDecryptedMessage { Commit(BufferedCommit), /// The decrypted message is a proposal. Proposal(Proposal), + /// The decrypted message is a transient targeted message. + TransientTargeted(DecryptedBytes), + /// The decrypted message is a persisted targeted message. + PersistedTargeted(DecryptedBytes), } impl From for BufferedDecryptedMessage { @@ -119,6 +129,8 @@ impl From for BufferedDecryptedMessage { } }), DecryptedMessage::Proposal(proposal) => Self::Proposal(proposal), + DecryptedMessage::TransientTargeted(decrypted_bytes) => Self::TransientTargeted(decrypted_bytes), + DecryptedMessage::PersistedTargeted(decrypted_bytes) => Self::PersistedTargeted(decrypted_bytes), } } } @@ -133,6 +145,8 @@ impl From for DecryptedMessage { identity: buffered_commit.identity, }), BufferedDecryptedMessage::Proposal(proposal) => Self::Proposal(proposal), + BufferedDecryptedMessage::TransientTargeted(decrypted_bytes) => Self::TransientTargeted(decrypted_bytes), + BufferedDecryptedMessage::PersistedTargeted(decrypted_bytes) => Self::PersistedTargeted(decrypted_bytes), } } } From 2ec05f76e906b314fe928dac893468ed6871194f Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 11:54:39 +0200 Subject: [PATCH 07/25] feat(keystore): add `tnt_secrets` table and corresponding struct We need this to enable decrypting targeted messages from past epochs. The table name is chosen more broadly, because we're going to store a secret for transient messages in there also. --- keystore/src/connection/composite-schema.sql | 10 ++ .../migrations/V33__tnt_secrets.sql | 9 ++ keystore/src/entities/mls/mod.rs | 2 + keystore/src/entities/mls/tnt_secret.rs | 118 ++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 keystore/src/connection/migrations/V33__tnt_secrets.sql create mode 100644 keystore/src/entities/mls/tnt_secret.rs diff --git a/keystore/src/connection/composite-schema.sql b/keystore/src/connection/composite-schema.sql index c615c57882..801607ef46 100644 --- a/keystore/src/connection/composite-schema.sql +++ b/keystore/src/connection/composite-schema.sql @@ -96,3 +96,13 @@ CREATE TABLE targeted_message_tx_counters ( PRIMARY KEY (conversation_id, receiver), FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE ); + +CREATE TABLE tnt_secrets ( + conversation_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + hpke_private_key BLOB NOT NULL, + group_context BLOB NOT NULL, + targeted_message_psk BLOB NOT NULL, + PRIMARY KEY (conversation_id, epoch), + FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE +); diff --git a/keystore/src/connection/migrations/V33__tnt_secrets.sql b/keystore/src/connection/migrations/V33__tnt_secrets.sql new file mode 100644 index 0000000000..f430cbf75c --- /dev/null +++ b/keystore/src/connection/migrations/V33__tnt_secrets.sql @@ -0,0 +1,9 @@ +CREATE TABLE tnt_secrets ( + conversation_id BLOB NOT NULL, + epoch INTEGER NOT NULL, + hpke_private_key BLOB NOT NULL, + group_context BLOB NOT NULL, + targeted_message_psk BLOB NOT NULL, + PRIMARY KEY (conversation_id, epoch), + FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE +); diff --git a/keystore/src/entities/mls/mod.rs b/keystore/src/entities/mls/mod.rs index 824b861c6d..faa939cc44 100644 --- a/keystore/src/entities/mls/mod.rs +++ b/keystore/src/entities/mls/mod.rs @@ -11,6 +11,7 @@ mod stored_hpke_private_key; mod stored_keypackage; mod stored_psk_bundle; mod targeted_message_tx_counter; +mod tnt_secret; mod x509_intermediate_cert; mod x509_trust_anchor; @@ -27,5 +28,6 @@ pub use stored_hpke_private_key::StoredHpkePrivateKey; pub use stored_keypackage::StoredKeyPackage; pub use stored_psk_bundle::StoredPskBundle; pub use targeted_message_tx_counter::{TargetedMessageTxCounter, TargetedMessageTxCounterPk}; +pub use tnt_secret::{TntSecret, TntSecretPk}; pub use x509_intermediate_cert::X509IntermediateCert; pub use x509_trust_anchor::X509TrustAnchor; diff --git a/keystore/src/entities/mls/tnt_secret.rs b/keystore/src/entities/mls/tnt_secret.rs new file mode 100644 index 0000000000..b3994c6092 --- /dev/null +++ b/keystore/src/entities/mls/tnt_secret.rs @@ -0,0 +1,118 @@ +use zeroize::Zeroize; + +use crate::{ + entities::{ + ConversationId, + helpers::{count_helper, get_helper_composite_key, load_all_helper}, + }, + traits::{Entity, EntityDatabaseMutation, PrimaryKey, SearchableEntity}, +}; + +#[derive(core_crypto_macros::Debug, Clone, PartialEq, Eq, Zeroize, serde::Serialize, serde::Deserialize)] +#[zeroize(drop)] +pub struct TntSecret { + pub conversation_id: Vec, + pub epoch: u64, + #[sensitive] + pub hpke_private_key: Vec, + pub group_context: Vec, + #[sensitive] + pub targeted_message_psk: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, derive_more::Constructor)] +pub struct TntSecretPk { + conversation_id: Vec, + epoch: u64, +} + +impl TntSecret { + const TABLE_NAME: &str = "tnt_secrets"; + const PRIMARY_KEY_COLUMN_NAMES: [&str; 2] = ["conversation_id", "epoch"]; + + fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + conversation_id: row.get("conversation_id")?, + epoch: row.get("epoch")?, + hpke_private_key: row.get("hpke_private_key")?, + group_context: row.get("group_context")?, + targeted_message_psk: row.get("targeted_message_psk")?, + }) + } +} + +impl PrimaryKey for TntSecret { + type PrimaryKey = TntSecretPk; + + fn primary_key(&self) -> Self::PrimaryKey { + TntSecretPk::new(self.conversation_id.clone(), self.epoch) + } +} + +impl Entity for TntSecret { + const TABLE_NAME: &str = Self::TABLE_NAME; + + fn get(conn: &rusqlite::Connection, key: &TntSecretPk) -> crate::CryptoKeystoreResult> { + get_helper_composite_key( + conn, + &Self::PRIMARY_KEY_COLUMN_NAMES, + rusqlite::params![key.conversation_id, key.epoch], + Self::from_row, + ) + } + + fn count(conn: &rusqlite::Connection) -> crate::CryptoKeystoreResult { + count_helper::(conn) + } + + fn load_all(conn: &rusqlite::Connection) -> crate::CryptoKeystoreResult> { + load_all_helper(conn, Self::from_row) + } +} + +impl EntityDatabaseMutation for TntSecret { + type AutoGeneratedFields = (); + + fn save(&self, tx: &rusqlite::Transaction) -> crate::CryptoKeystoreResult<()> { + let mut stmt = tx.prepare_cached(&format!( + "INSERT OR REPLACE INTO {} + (conversation_id, epoch, hpke_private_key, group_context, targeted_message_psk) + VALUES (?, ?, ?, ?, ?)", + Self::TABLE_NAME + ))?; + stmt.execute(rusqlite::params![ + self.conversation_id, + self.epoch, + self.hpke_private_key, + self.group_context, + self.targeted_message_psk, + ])?; + Ok(()) + } + + fn delete(tx: &rusqlite::Transaction, key: &TntSecretPk) -> crate::CryptoKeystoreResult { + let mut stmt = tx.prepare_cached(&format!( + "DELETE FROM {} WHERE conversation_id = ? AND epoch = ?", + Self::TABLE_NAME + ))?; + stmt.execute(rusqlite::params![key.conversation_id, key.epoch]) + .map(|affected_rows| affected_rows > 0) + .map_err(Into::into) + } +} + +impl SearchableEntity for TntSecret { + fn find_all_matching( + conn: &rusqlite::Connection, + conversation_id: &ConversationId, + ) -> crate::CryptoKeystoreResult> { + let mut stmt = conn.prepare_cached(&format!("SELECT * FROM {} WHERE conversation_id = ?", Self::TABLE_NAME))?; + stmt.query_map([conversation_id.as_ref()], Self::from_row)? + .collect::>() + .map_err(Into::into) + } + + fn matches(&self, conversation_id: &ConversationId) -> bool { + self.conversation_id.as_slice() == conversation_id.as_ref() + } +} From dd8ff50d236ad3412289668fa07933b9367e98c8 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 14:17:46 +0200 Subject: [PATCH 08/25] feat(keystore): add `targeted_message_rx_counters` table and corresponding struct --- keystore/src/connection/composite-schema.sql | 9 ++ ...secrets_tnt_message_receiver_counters.sql} | 9 ++ keystore/src/entities/mls/mod.rs | 2 + .../mls/targeted_message_rx_counter.rs | 130 ++++++++++++++++++ 4 files changed, 150 insertions(+) rename keystore/src/connection/migrations/{V33__tnt_secrets.sql => V33__tnt_secrets_tnt_message_receiver_counters.sql} (51%) create mode 100644 keystore/src/entities/mls/targeted_message_rx_counter.rs diff --git a/keystore/src/connection/composite-schema.sql b/keystore/src/connection/composite-schema.sql index 801607ef46..579d37b2ee 100644 --- a/keystore/src/connection/composite-schema.sql +++ b/keystore/src/connection/composite-schema.sql @@ -106,3 +106,12 @@ CREATE TABLE tnt_secrets ( PRIMARY KEY (conversation_id, epoch), FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE ); + +CREATE TABLE targeted_message_rx_counters ( + conversation_id BLOB NOT NULL, + sender INTEGER NOT NULL, + epoch INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (conversation_id, sender, epoch), + FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE +); diff --git a/keystore/src/connection/migrations/V33__tnt_secrets.sql b/keystore/src/connection/migrations/V33__tnt_secrets_tnt_message_receiver_counters.sql similarity index 51% rename from keystore/src/connection/migrations/V33__tnt_secrets.sql rename to keystore/src/connection/migrations/V33__tnt_secrets_tnt_message_receiver_counters.sql index f430cbf75c..6220f3d0e3 100644 --- a/keystore/src/connection/migrations/V33__tnt_secrets.sql +++ b/keystore/src/connection/migrations/V33__tnt_secrets_tnt_message_receiver_counters.sql @@ -7,3 +7,12 @@ CREATE TABLE tnt_secrets ( PRIMARY KEY (conversation_id, epoch), FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE ); + +CREATE TABLE targeted_message_rx_counters ( + conversation_id BLOB NOT NULL, + sender INTEGER NOT NULL, + epoch INTEGER NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (conversation_id, sender, epoch), + FOREIGN KEY (conversation_id) REFERENCES mls_groups(id) ON DELETE CASCADE +); diff --git a/keystore/src/entities/mls/mod.rs b/keystore/src/entities/mls/mod.rs index faa939cc44..78362f5a29 100644 --- a/keystore/src/entities/mls/mod.rs +++ b/keystore/src/entities/mls/mod.rs @@ -10,6 +10,7 @@ mod stored_epoch_encryption_keypair; mod stored_hpke_private_key; mod stored_keypackage; mod stored_psk_bundle; +mod targeted_message_rx_counter; mod targeted_message_tx_counter; mod tnt_secret; mod x509_intermediate_cert; @@ -27,6 +28,7 @@ pub use stored_epoch_encryption_keypair::StoredEpochEncryptionKeypair; pub use stored_hpke_private_key::StoredHpkePrivateKey; pub use stored_keypackage::StoredKeyPackage; pub use stored_psk_bundle::StoredPskBundle; +pub use targeted_message_rx_counter::{TargetedMessageRxCounter, TargetedMessageRxCounterPk}; pub use targeted_message_tx_counter::{TargetedMessageTxCounter, TargetedMessageTxCounterPk}; pub use tnt_secret::{TntSecret, TntSecretPk}; pub use x509_intermediate_cert::X509IntermediateCert; diff --git a/keystore/src/entities/mls/targeted_message_rx_counter.rs b/keystore/src/entities/mls/targeted_message_rx_counter.rs new file mode 100644 index 0000000000..27a0f3409c --- /dev/null +++ b/keystore/src/entities/mls/targeted_message_rx_counter.rs @@ -0,0 +1,130 @@ +use zeroize::Zeroize; + +use crate::{ + entities::{ + ConversationId, + helpers::{count_helper, get_helper_composite_key, load_all_helper}, + }, + traits::{DeletableBySearchKey, EntityDatabaseMutation, PrimaryKey, SearchableEntity}, +}; + +#[derive(core_crypto_macros::Debug, Clone, PartialEq, Eq, Zeroize, serde::Serialize, serde::Deserialize)] +#[zeroize(drop)] +pub struct TargetedMessageRxCounter { + pub conversation_id: Vec, + pub sender: u32, + pub epoch: u64, + pub count: u32, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, derive_more::Constructor)] +pub struct TargetedMessageRxCounterPk { + conversation_id: Vec, + sender_idx: u32, + epoch: u64, +} + +impl TargetedMessageRxCounter { + const TABLE_NAME: &str = "targeted_message_rx_counters"; + const PRIMARY_KEY_COLUMN_NAMES: [&str; 3] = ["conversation_id", "sender", "epoch"]; + + fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + conversation_id: row.get("conversation_id")?, + sender: row.get("sender")?, + epoch: row.get("epoch")?, + count: row.get("count")?, + }) + } +} + +impl PrimaryKey for TargetedMessageRxCounter { + type PrimaryKey = TargetedMessageRxCounterPk; + + fn primary_key(&self) -> Self::PrimaryKey { + TargetedMessageRxCounterPk::new(self.conversation_id.clone(), self.sender, self.epoch) + } +} + +impl crate::traits::Entity for TargetedMessageRxCounter { + const TABLE_NAME: &'static str = Self::TABLE_NAME; + + fn get(conn: &rusqlite::Connection, key: &TargetedMessageRxCounterPk) -> crate::CryptoKeystoreResult> { + get_helper_composite_key( + conn, + &Self::PRIMARY_KEY_COLUMN_NAMES, + rusqlite::params![key.conversation_id, key.sender_idx, key.epoch], + Self::from_row, + ) + } + + fn count(conn: &rusqlite::Connection) -> crate::CryptoKeystoreResult { + count_helper::(conn) + } + + fn load_all(conn: &rusqlite::Connection) -> crate::CryptoKeystoreResult> { + load_all_helper(conn, Self::from_row) + } +} + +impl EntityDatabaseMutation for TargetedMessageRxCounter { + type AutoGeneratedFields = (); + + fn save(&self, tx: &rusqlite::Transaction) -> crate::CryptoKeystoreResult<()> { + let mut stmt = tx.prepare_cached(&format!( + "INSERT OR REPLACE INTO {} (conversation_id, sender, epoch, count) VALUES (?, ?, ?, ?)", + Self::TABLE_NAME + ))?; + stmt.execute(rusqlite::params![ + self.conversation_id, + self.sender, + self.epoch, + self.count + ])?; + Ok(()) + } + + fn delete( + tx: &rusqlite::Transaction, + primary_key: &TargetedMessageRxCounterPk, + ) -> crate::CryptoKeystoreResult { + let mut stmt = tx.prepare_cached(&format!( + "DELETE FROM {} WHERE conversation_id = ? AND sender = ? AND epoch = ?", + Self::TABLE_NAME + ))?; + stmt.execute(rusqlite::params![ + primary_key.conversation_id, + primary_key.sender_idx, + primary_key.epoch + ]) + .map(|affected_rows| affected_rows > 0) + .map_err(Into::into) + } +} + +impl SearchableEntity for TargetedMessageRxCounter { + fn find_all_matching( + conn: &rusqlite::Connection, + conversation_id: &ConversationId, + ) -> crate::CryptoKeystoreResult> { + let mut stmt = conn.prepare_cached(&format!("SELECT * FROM {} WHERE conversation_id = ?", Self::TABLE_NAME))?; + stmt.query_map([conversation_id.as_ref()], Self::from_row)? + .collect::>() + .map_err(Into::into) + } + + fn matches(&self, conversation_id: &ConversationId) -> bool { + self.conversation_id.as_slice() == conversation_id.as_ref() + } +} + +impl DeletableBySearchKey for TargetedMessageRxCounter { + fn delete_all_matching( + tx: &rusqlite::Transaction, + conversation_id: &ConversationId, + ) -> crate::CryptoKeystoreResult<()> { + let mut stmt = tx.prepare_cached(&format!("DELETE FROM {} WHERE conversation_id = ?", Self::TABLE_NAME))?; + stmt.execute([conversation_id.as_ref()])?; + Ok(()) + } +} From 502a0d495bded24f18561b1ca755c062e7112119 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Mon, 3 Aug 2026 18:22:58 +0200 Subject: [PATCH 09/25] refactor: rename `decrypt_message_inner()` to `decrypt_mls_message()` --- crypto/src/mls/conversation/mutable/decrypt/buffer_commit.rs | 2 +- .../src/mls/conversation/mutable/decrypt/buffer_messages.rs | 2 +- crypto/src/mls/conversation/mutable/decrypt/mod.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crypto/src/mls/conversation/mutable/decrypt/buffer_commit.rs b/crypto/src/mls/conversation/mutable/decrypt/buffer_commit.rs index 73d60e6d00..3f0b27b054 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/buffer_commit.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/buffer_commit.rs @@ -56,7 +56,7 @@ impl ConversationMut { let message = MlsMessageIn::tls_deserialize(&mut commit.as_ref()).map_err(Error::tls_deserialize("mls message in"))?; - Box::pin(self.decrypt_message_inner(message, recursion_policy)).await + Box::pin(self.decrypt_mls_message(message, recursion_policy)).await } /// Remove the buffered commit for this conversation; it has been applied. diff --git a/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs b/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs index a7f537cf06..de9d89164f 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs @@ -101,7 +101,7 @@ impl ConversationMut { let mut decrypted_messages = Vec::with_capacity(pending_messages.len()); for (_, m) in pending_messages { - let decrypted = self.decrypt_message_inner(m, RecursionPolicy::None).await?; + let decrypted = self.decrypt_mls_message(m, RecursionPolicy::None).await?; decrypted_messages.push(decrypted.into()); } diff --git a/crypto/src/mls/conversation/mutable/decrypt/mod.rs b/crypto/src/mls/conversation/mutable/decrypt/mod.rs index 15ffab4bce..809e2461f5 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/mod.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/mod.rs @@ -204,8 +204,8 @@ impl ConversationMut { Ok(decrypt_message) } - /// We need an inner part, because this may be called recursively. - async fn decrypt_message_inner( + /// Decrypt a deserialized [MlsMessageIn]. + async fn decrypt_mls_message( &mut self, message: MlsMessageIn, recursion_policy: RecursionPolicy, From 25fc1a176758d89b7764cc033ea588564b51d3e6 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Tue, 18 Aug 2026 13:29:28 +0200 Subject: [PATCH 10/25] refactor(crypto): `ConversationMut::decrypt_message` delegates to mls or tnt The tnt decrypt function doesn't exist yet, but it's about to be added --- .../mls/conversation/mutable/decrypt/mod.rs | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/crypto/src/mls/conversation/mutable/decrypt/mod.rs b/crypto/src/mls/conversation/mutable/decrypt/mod.rs index 809e2461f5..f6cc851e9e 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/mod.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/mod.rs @@ -30,7 +30,13 @@ use tls_codec::Deserialize as _; use super::{ConversationMut, Result}; use crate::{ ClientId, E2eiConversationState, OpenMlsError, RecursiveError, Session, WireIdentity, - mls::{conversation::Error, credential::ext::CredentialExt as _}, + mls::{ + conversation::{ + Error, + mutable::tnt::{TntMessage, TntWireFormat}, + }, + credential::ext::CredentialExt as _, + }, }; /// Decrypted Bytes @@ -174,12 +180,20 @@ impl ConversationMut { /// If a message has been buffered, this will be indicated by an error. /// Other errors are originating from OpenMls and the KeyStore pub async fn decrypt_message(&mut self, message: impl AsRef<[u8]>) -> Result { - let mls_message_in = - MlsMessageIn::tls_deserialize(&mut message.as_ref()).map_err(Error::tls_deserialize("mls message in"))?; - - let decrypt_message_result = self - .decrypt_message_inner(mls_message_in, RecursionPolicy::AsNecessary) - .await; + let message = message.as_ref(); + // Bytes 0 and 1 are the protocol version, bytes 2 and 3 are the wire format. + let wire_format = + u16::tls_deserialize_exact(&message[2..4]).map_err(Error::tls_deserialize("u16 (wire format)"))?; + + let decrypt_message_result = if TntWireFormat::all().contains(&wire_format) { + let tnt_message = TntMessage::tls_deserialize_exact(message).map_err(Error::tls_serialize("TntMessage"))?; + self.decrypt_tnt_message(tnt_message).await + } else { + let mls_message_in = + MlsMessageIn::tls_deserialize_exact(message).map_err(Error::tls_deserialize("mls message in"))?; + self.decrypt_mls_message(mls_message_in, RecursionPolicy::AsNecessary) + .await + }; // In the inner `decrypt_message` above, we raise the `BufferedCommit` or // `BufferedFutureMessage` errors, but we only handle them here. From f1155fc0051dc003e6cd4fd0ef67d6473ce60fe4 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 12:05:38 +0200 Subject: [PATCH 11/25] chore(conversation): persist new and remove old tnt secrets when epoch is incremented --- .../conversation/mutable/group_mutation.rs | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crypto/src/mls/conversation/mutable/group_mutation.rs b/crypto/src/mls/conversation/mutable/group_mutation.rs index ab673d63d9..5bb8642b04 100644 --- a/crypto/src/mls/conversation/mutable/group_mutation.rs +++ b/crypto/src/mls/conversation/mutable/group_mutation.rs @@ -1,10 +1,14 @@ -use core_crypto_keystore::Transaction; +use core_crypto_keystore::{ + Transaction, + entities::{ConversationId as DbConversationId, TntSecret, TntSecretPk}, + traits::FetchFromDatabase as _, +}; use openmls::group::InnerState; use super::{ConversationMut, Result}; use crate::{ - RecursiveError, - mls::conversation::{Conversation, ConversationIdRef, MlsGroupState}, + KeystoreError, RecursiveError, + mls::conversation::{Conversation, ConversationIdRef, MlsGroupState, config::MAX_PAST_EPOCHS}, }; impl ConversationMut { @@ -36,6 +40,21 @@ impl ConversationMut { let Conversation { group, id, .. } = &*self.inner; let mut group = group.write().await; let epoch_before_operation = group.epoch(); + + // Save the tnt secret. We need to do this exactly once per epoch. + let tnt_secret_key = TntSecretPk::new(id.as_ref().to_vec(), epoch_before_operation.as_u64()); + if tx + .get::(&tnt_secret_key) + .await + .map_err(KeystoreError::wrap("finding tnt secret for current epoch"))? + .is_none() + { + let tnt_secret = self.create_tnt_secret(group.mls_group()).await?; + tx.save(tnt_secret) + .await + .map_err(KeystoreError::wrap("persisting tnt secret for current epoch"))?; + } + let ok_result = operation(tx, &mut *group, id).await?; if group.state_changed() == InnerState::Persisted { @@ -44,6 +63,24 @@ impl ConversationMut { if epoch_before_operation < group.epoch() { group.reset_targeted_message_tx_counters(tx).await; + + let oldest_retained_epoch = group.epoch().as_u64().saturating_sub(MAX_PAST_EPOCHS as u64); + let conversation_id = DbConversationId::from(id.as_ref().to_vec()); + let secrets = tx + .search::(&conversation_id) + .await + .map_err(KeystoreError::wrap("finding tnt secrets to remove"))?; + + // This is not a lot of data: just `MAX_PAST_EPOCHS` records. In practice, we could in most cases just + // delete the single one with the oldest epoch. However, to avoid any assumptions about the data, let's just + // check each of the `MAX_PAST_EPOCHS` records. + for secret in secrets { + if secret.epoch < oldest_retained_epoch { + tx.remove::(&TntSecretPk::new(secret.conversation_id.clone(), secret.epoch)) + .await + .map_err(KeystoreError::wrap("removing old tnt secret"))?; + } + } } group.persist(tx).await?; From 98e2d56788f3deead9e828c4e48d2ef279c9ae6b Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Mon, 3 Aug 2026 18:22:13 +0200 Subject: [PATCH 12/25] chore(tnt): add `TntWireFormat` --- crypto/src/mls/conversation/mutable/tnt/mod.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crypto/src/mls/conversation/mutable/tnt/mod.rs b/crypto/src/mls/conversation/mutable/tnt/mod.rs index f430c3205a..47e383b35c 100644 --- a/crypto/src/mls/conversation/mutable/tnt/mod.rs +++ b/crypto/src/mls/conversation/mutable/tnt/mod.rs @@ -40,6 +40,20 @@ enum TntMessageBody { TransientTargeted(TargetedMessage), } +#[derive(TlsSize, TlsDeserialize)] +pub(crate) struct TntWireFormat(u16); + +impl TntWireFormat { + pub(crate) const TRANSIENT_MESSAGE: u16 = 0xF000; + #[expect(unused)] + pub(crate) const TARGETED_MESSAGE: u16 = 0xF001; + pub(crate) const TRANSIENT_TARGETED_MESSAGE: u16 = 0xF002; + + pub(crate) fn all() -> std::ops::RangeInclusive { + Self::TRANSIENT_MESSAGE..=Self::TRANSIENT_TARGETED_MESSAGE + } +} + /// The to-be-signed [TntMessage]. #[derive(TlsSize, TlsSerialize, TlsDeserialize)] struct TntMessageTBS { From 7506ff5d22317cf943649734aa03646b9810850c Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Tue, 18 Aug 2026 15:30:02 +0200 Subject: [PATCH 13/25] refactor(tnt): extracting hpke context data is shared between encryption and decryption --- .../mutable/tnt/targeted/encrypt.rs | 24 +++-------- .../conversation/mutable/tnt/targeted/mod.rs | 43 ++++++++++++++++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/crypto/src/mls/conversation/mutable/tnt/targeted/encrypt.rs b/crypto/src/mls/conversation/mutable/tnt/targeted/encrypt.rs index 10154a274c..7a33d9631d 100644 --- a/crypto/src/mls/conversation/mutable/tnt/targeted/encrypt.rs +++ b/crypto/src/mls/conversation/mutable/tnt/targeted/encrypt.rs @@ -4,7 +4,7 @@ use core_crypto_keystore::Database; use openmls::prelude::{Ciphersuite, Member, Signable as _}; use tls_codec::{Serialize as _, TlsSerialize, TlsSize, VLBytes}; -use super::{PskId, TargetedMessage, TargetedMessageContext}; +use super::{TargetedMessage, TargetedMessageContext, extract_hpke_context_data}; use crate::{ ClientIdRef, CryptoProvider, OpenMlsError, RecursiveError, mls::conversation::{ @@ -102,30 +102,16 @@ impl ConversationMut { mls_group.own_leaf_index(), recipient.index, ); - let info = context - .tls_serialize_detached() - .map_err(Error::tls_serialize("TargetedMessageContext"))?; - - // We can use an empty context because we're using a unique label. - let psk = mls_group - .export_secret( - crypto_provider, - TargetedMessage::PSK_LABEL, - &[], - cipher_suite.hash_length(), - ) - .map_err(OpenMlsError::wrap("exporting targeted message psk"))?; - let psk_id = PskId::new(mls_group.group_id().clone(), mls_group.epoch()); - let psk_id = psk_id.tls_serialize_detached().map_err(Error::tls_serialize("PskId"))?; + let context_data = extract_hpke_context_data(crypto_provider, cipher_suite, &context, mls_group)?; let message = tls_serialize_padded(message).map_err(Error::tls_serialize("TargetedMessageContent"))?; let payload = crypto_provider .hpke_seal_psk( cipher_suite.hpke_config(), &recipient.encryption_key, - &info, + &context_data.info, &aad, - &psk, - &psk_id, + &context_data.psk, + &context_data.psk_id, &message, ) .map_err(OpenMlsError::wrap("encrypting targeted message"))?; diff --git a/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs b/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs index d93bd5d3c7..6a2b89dd45 100644 --- a/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs +++ b/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs @@ -3,14 +3,15 @@ pub(super) mod encrypt; use const_format::concatcp; use derive_more::Constructor; use openmls::{ - group::{GroupEpoch, GroupId, group_context::GroupContext}, - prelude::{HpkeCiphertext, LeafNodeIndex}, + group::{GroupEpoch, GroupId, MlsGroup, group_context::GroupContext}, + prelude::{Ciphersuite, HpkeCiphertext, LeafNodeIndex}, }; -use tls_codec::{TlsDeserialize, TlsSerialize, TlsSize}; +use openmls_traits::OpenMlsCryptoProvider; +use tls_codec::{Serialize as _, TlsDeserialize, TlsSerialize, TlsSize}; use self::encrypt::TargetedMessagePolicy; -use super::{ProtocolVersion, tnt_message_counter::TntMessageCounter}; -use crate::ConversationConfiguration; +use super::{Error, ProtocolVersion, Result, tnt_message_counter::TntMessageCounter}; +use crate::{ConversationConfiguration, OpenMlsError}; /// Used to parameterize HPKE Seal/Open. /// Not carried with the payload, constructed freshly when decrypting. @@ -77,3 +78,35 @@ impl TargetedMessage { concatcp!("TntMessageTBS-Transient-Targeted v", ProtocolVersion::V1.as_u16()); pub(super) const PSK_LABEL: &str = concatcp!("Tnt TargetedMessage Psk v", ProtocolVersion::V1.as_u16()); } + +/// The data that is extracted from the MLS group when encrypting or decrypting a targeted message. +struct HpkeContextData { + info: Vec, + psk_id: Vec, + psk: Vec, +} + +fn extract_hpke_context_data( + crypto_provider: &impl OpenMlsCryptoProvider, + cipher_suite: &Ciphersuite, + context: &TargetedMessageContext, + mls_group: &MlsGroup, +) -> Result { + let info = context + .tls_serialize_detached() + .map_err(Error::tls_serialize("TargetedMessageContext"))?; + + // We can use an empty context because we're using a unique label. + let psk = mls_group + .export_secret( + crypto_provider, + TargetedMessage::PSK_LABEL, + &[], + cipher_suite.hash_length(), + ) + .map_err(OpenMlsError::wrap("exporting targeted message psk"))?; + let psk_id = PskId::new(mls_group.group_id().clone(), mls_group.epoch()); + let psk_id = psk_id.tls_serialize_detached().map_err(Error::tls_serialize("PskId"))?; + + Ok(HpkeContextData { info, psk_id, psk }) +} From c67cd46e218a85087001e22bb823eb7a474c9175 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 09:53:56 +0200 Subject: [PATCH 14/25] feat(tnt): add `ConversationMut::decrypt_tnt_message()` [WPB-27214] --- .../src/mls/conversation/mutable/tnt/mod.rs | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/crypto/src/mls/conversation/mutable/tnt/mod.rs b/crypto/src/mls/conversation/mutable/tnt/mod.rs index 47e383b35c..c3d2aa6214 100644 --- a/crypto/src/mls/conversation/mutable/tnt/mod.rs +++ b/crypto/src/mls/conversation/mutable/tnt/mod.rs @@ -1,12 +1,18 @@ mod targeted; mod tnt_message_counter; -use openmls::prelude::Signature; +use openmls::prelude::{LeafNodeIndex, OpenMlsSignaturePublicKey, Signature, Verifiable as _}; +use openmls_traits::OpenMlsCryptoProvider; use tls_codec::{Serialize, TlsDeserialize, TlsSerialize, TlsSize}; use self::targeted::TargetedMessage; pub use self::targeted::encrypt::TargetedMessagePolicy; pub(crate) use self::tnt_message_counter::TntMessageCounter; +use super::Result; +use crate::{ + DecryptedMessage, OpenMlsError, + mls::conversation::{ConversationMut, Error}, +}; /// The version of the Transient and Targeted Messages protocol. #[derive(TlsSize, TlsSerialize, TlsDeserialize)] @@ -83,11 +89,20 @@ impl TntMessageTBS { body: TntMessageBody::TransientTargeted(targeted), } } + + pub(super) fn sender_index(&self) -> LeafNodeIndex { + match &self.body { + TntMessageBody::Transient(_) => todo!(), + TntMessageBody::Targeted(targeted_message) | TntMessageBody::TransientTargeted(targeted_message) => { + targeted_message.sender() + } + } + } } /// A message containing [TntMessageBody] with the corresponding signature. #[derive(TlsSize, TlsSerialize, TlsDeserialize)] -struct TntMessage { +pub(super) struct TntMessage { content: TntMessageTBS, signature: Signature, } @@ -116,3 +131,54 @@ impl openmls::prelude::SignedStruct for TntMessage { } } } + +impl openmls::prelude::Verifiable for TntMessage { + fn unsigned_payload(&self) -> Result, tls_codec::Error> { + self.content.tls_serialize_detached() + } + + fn signature(&self) -> &Signature { + &self.signature + } + + fn label(&self) -> &str { + match self.content.body { + TntMessageBody::Transient(_) => todo!(), + TntMessageBody::Targeted(_) => TargetedMessage::SIGN_LABEL_PERSISTED, + TntMessageBody::TransientTargeted(_) => TargetedMessage::SIGN_LABEL_TRANSIENT, + } + } +} + +impl ConversationMut { + /// Verify the [TntMessage] signature, then proceed with decryption. + pub(super) async fn decrypt_tnt_message(&self, message: TntMessage) -> Result { + let crypto_provider = self.crypto_provider().await?; + let signature_algorithm = self.cipher_suite().signature_algorithm(); + let sender = self + .group() + .await + .members() + .find(|member| member.index == message.content.sender_index()) + .ok_or(Error::MlsMessageInvalidState("tnt sender is not a group member"))?; + + let sender_public_key = + OpenMlsSignaturePublicKey::new(sender.signature_key.clone().into(), signature_algorithm) + .map_err(OpenMlsError::wrap("constructing tnt sender signature key"))?; + message + .verify_no_out(crypto_provider.crypto(), &sender_public_key) + .map_err(OpenMlsError::wrap("verifying tnt message signature"))?; + + match message.content.body { + TntMessageBody::Transient(_) => todo!(), + TntMessageBody::Targeted(targeted_message) => { + self.decrypt_targeted(targeted_message, TargetedMessagePolicy::Persisted, &sender) + .await + } + TntMessageBody::TransientTargeted(targeted_message) => { + self.decrypt_targeted(targeted_message, TargetedMessagePolicy::Transient, &sender) + .await + } + } + } +} From b1ae7bfa080a1a755112ce3cc17999d5e9315f64 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Tue, 18 Aug 2026 14:25:20 +0200 Subject: [PATCH 15/25] feat(tnt): add `ConversationMut::decrypt_targeted()` [WPB-27214] --- .../mutable/tnt/targeted/decrypt.rs | 257 ++++++++++++++++++ .../conversation/mutable/tnt/targeted/mod.rs | 26 +- 2 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 crypto/src/mls/conversation/mutable/tnt/targeted/decrypt.rs diff --git a/crypto/src/mls/conversation/mutable/tnt/targeted/decrypt.rs b/crypto/src/mls/conversation/mutable/tnt/targeted/decrypt.rs new file mode 100644 index 0000000000..08a3e5f491 --- /dev/null +++ b/crypto/src/mls/conversation/mutable/tnt/targeted/decrypt.rs @@ -0,0 +1,257 @@ +use core_crypto_keystore::{ + entities::{ + StoredEpochEncryptionKeypair, TargetedMessageRxCounter, TargetedMessageRxCounterPk, TntSecret, TntSecretPk, + }, + traits::FetchFromDatabase as _, +}; +use openmls::{ + group::{MlsGroup, group_context::GroupContext}, + prelude::{CredentialWithKey, HpkePrivateKey, Member}, + treesync::EncryptionKey, +}; +use openmls_traits::{ + OpenMlsCryptoProvider as _, + key_store::{MlsEntity, MlsEntityId}, +}; +use serde::{Deserialize, Serialize}; +use tls_codec::{Deserialize as _, Serialize as _, VLBytes}; + +use super::{ + Error, HpkeContextData, PskId, TargetedMessageContext, derive_targeted_message_psk, extract_hpke_context_data, +}; +use crate::{ + CipherSuite, CryptoProvider, DecryptedMessage, KeystoreError, OpenMlsError, RecursiveError, + mls::{ + conversation::{ + ConversationMut, MlsGroupState, Result, TargetedMessagePolicy, + config::MAX_PAST_EPOCHS, + mutable::{decrypt::DecryptedBytes, tnt::TargetedMessage}, + }, + credential::ext::CredentialExt, + }, +}; + +impl ConversationMut { + pub(in crate::mls::conversation::mutable::tnt) async fn decrypt_targeted( + &self, + message: TargetedMessage, + policy: TargetedMessagePolicy, + sender: &Member, + ) -> Result { + let mls_group = self.group().await; + if message.recipient != mls_group.own_leaf_index() { + return Err(Error::MlsMessageInvalidState( + "TargetedMessage wasn't targeted to this client", + )); + } + + let message_epoch = message.epoch.as_u64(); + let group_epoch = mls_group.epoch().as_u64(); + if message_epoch == group_epoch + 1 { + return Err(Error::BufferedFutureMessage { message_epoch }); + } else if message_epoch > group_epoch + 1 { + return Err(Error::UnbufferedFarFutureMessage); + } + + let database = self.database().await?; + let counter_pk = TargetedMessageRxCounterPk::new(self.id.to_bytes(), message.sender().u32(), group_epoch); + let existing_counter = database + .get::(&counter_pk) + .await + .map_err(KeystoreError::wrap("getting TargetedMessageRxCounter"))? + .map(|counter| counter.count) + .unwrap_or_default(); + if u32::from(message.nonce) <= existing_counter { + return Err(Error::DuplicateMessage); + } + + let crypto_provider = self.crypto_provider().await?; + let cipher_suite = &self.cipher_suite(); + let (context_data, decryption_key) = self + .load_hpke_decryption_data(&mls_group, &crypto_provider, cipher_suite, &message, policy) + .await?; + let aad = message + .nonce + .tls_serialize_detached() + .map_err(Error::tls_serialize("TntMessageCounter"))?; + let padded_plaintext = crypto_provider + .hpke_open_psk( + cipher_suite.hpke_config(), + &message.payload, + &decryption_key, + &context_data.info, + &aad, + &context_data.psk, + &context_data.psk_id, + ) + .map_err(OpenMlsError::wrap("decrypting targeted message"))?; + + // Remove padding from plaintext: VLBytes contains a length prefix which tells the deserializer the plaintext + // length. + let plaintext = VLBytes::tls_deserialize(&mut padded_plaintext.as_slice()) + .map_err(Error::tls_deserialize("TargetedMessageContent"))? + .into(); + + let sender_credential_with_key = CredentialWithKey { + credential: sender.credential.clone(), + signature_key: sender.signature_key.clone().into(), + }; + let pki_env = crypto_provider.authentication_service().pki_env().await; + let identity = sender_credential_with_key + .extract_identity(*cipher_suite, pki_env.as_deref()) + .await + .map_err(RecursiveError::mls_credential("extracting identity"))?; + let sender_client_id = sender + .credential + .identity() + .try_into() + .map_err(RecursiveError::mls_client("client id from credential"))?; + + let tx = self + .tx_context + .inner() + .await + .map_err(RecursiveError::transaction("getting inner context"))?; + let tx = tx.transaction(); + tx.save(TargetedMessageRxCounter { + conversation_id: self.id.to_bytes(), + sender: message.sender().u32(), + epoch: group_epoch, + count: message.nonce.into(), + }) + .await + .map_err(KeystoreError::wrap("persisting TargetedMessageRxCounter"))?; + + let decrypted_bytes = DecryptedBytes { + plaintext, + sender_client_id, + identity, + }; + + match policy { + TargetedMessagePolicy::Transient => Ok(DecryptedMessage::TransientTargeted(decrypted_bytes)), + TargetedMessagePolicy::Persisted => Ok(DecryptedMessage::PersistedTargeted(decrypted_bytes)), + } + } + + /// Get the HPKE context and private key from the live group state or load it from the database if the message comes + /// from a past epoch. + async fn load_hpke_decryption_data( + &self, + mls_group: &MlsGroupState, + crypto_provider: &CryptoProvider, + cipher_suite: &CipherSuite, + message: &TargetedMessage, + policy: TargetedMessagePolicy, + ) -> Result<(HpkeContextData, HpkePrivateKey), Error> { + if message.epoch == mls_group.epoch() { + let context = TargetedMessageContext::new( + policy, + mls_group.export_group_context(), + message.sender(), + message.recipient, + ); + let context_data = extract_hpke_context_data(crypto_provider, cipher_suite, &context, mls_group)?; + let decryption_key = self.load_decryption_key(mls_group).await?; + return Ok((context_data, decryption_key)); + } + + let epoch_distance = mls_group.epoch().as_u64() - message.epoch.as_u64(); + if epoch_distance > MAX_PAST_EPOCHS as u64 { + return Err(Error::MessageEpochTooOld); + } + + let database = self.database().await?; + let key = TntSecretPk::new(mls_group.group_id().to_vec(), message.epoch.as_u64()); + let secret = database + .get::(&key) + .await + .map_err(KeystoreError::wrap("loading tnt secret"))? + .ok_or(Error::MlsGroupInvalidState("tnt secret is missing"))?; + let group_context = GroupContext::tls_deserialize(&mut secret.group_context.as_slice()) + .map_err(Error::tls_deserialize("TntSecret GroupContext"))?; + let context = TargetedMessageContext::new(policy, &group_context, message.sender(), message.recipient); + let info = context + .tls_serialize_detached() + .map_err(Error::tls_serialize("TargetedMessageContext"))?; + let psk_id = PskId::new(mls_group.group_id().clone(), message.epoch) + .tls_serialize_detached() + .map_err(Error::tls_serialize("PskId"))?; + let decryption_key = core_crypto_keystore::deser(&secret.hpke_private_key) + .map_err(KeystoreError::wrap("deserializing tnt hpke private key"))?; + let context_data = HpkeContextData { + info, + psk_id, + psk: secret.targeted_message_psk.clone(), + }; + Ok((context_data, decryption_key)) + } + + pub(in crate::mls::conversation) async fn create_tnt_secret(&self, mls_group: &MlsGroup) -> Result { + let crypto_provider = self.crypto_provider().await?; + let hpke_private_key = self.load_decryption_key(mls_group).await?; + let targeted_message_psk = derive_targeted_message_psk(&crypto_provider, &self.cipher_suite(), mls_group)?; + + Ok(TntSecret { + conversation_id: mls_group.group_id().to_vec(), + epoch: mls_group.epoch().as_u64(), + hpke_private_key: core_crypto_keystore::ser(&hpke_private_key) + .map_err(KeystoreError::wrap("serializing tnt hpke private key"))?, + group_context: mls_group + .export_group_context() + .tls_serialize_detached() + .map_err(Error::tls_serialize("TntSecret GroupContext"))?, + targeted_message_psk, + }) + } + + // Ideally, openmls would offer this API for us, but it doesn't. So we load the data we need from the database. + async fn load_decryption_key(&self, mls_group: &MlsGroup) -> Result { + let epoch_keypair_id = [ + mls_group.group_id().as_slice(), + &mls_group.own_leaf_index().u32().to_be_bytes(), + &mls_group.epoch().as_u64().to_be_bytes(), + ] + .concat(); + + let database = self.database().await?; + + let stored_keypairs = database + .get_borrowed::(&epoch_keypair_id) + .await + .map_err(KeystoreError::wrap("loading epoch encryption keypairs"))? + .ok_or(Error::MlsGroupInvalidState("epoch encryption keypairs are missing"))?; + + let keypairs = core_crypto_keystore::deser::(&stored_keypairs.keypairs) + .map_err(KeystoreError::wrap("deserializing epoch encryption key"))?; + let own_encryption_key = mls_group + .own_leaf_node() + .ok_or(Error::MlsGroupInvalidState("own leaf node is missing"))? + .encryption_key(); + + keypairs + .0 + .into_iter() + .find(|keypair| keypair.public_key.as_slice() == own_encryption_key.as_slice()) + .map(|keypair| keypair.private_key.key) + .ok_or(Error::MlsGroupInvalidState("own leaf encryption keypair is missing")) + } +} + +#[derive(Serialize, Deserialize)] +struct PersistedEpochEncryptionKeypairs(Vec); + +impl MlsEntity for PersistedEpochEncryptionKeypairs { + const ID: MlsEntityId = MlsEntityId::EpochEncryptionKeyPair; +} + +#[derive(Serialize, Deserialize)] +struct PersistedEncryptionKeypair { + public_key: EncryptionKey, + private_key: PersistedEncryptionPrivateKey, +} + +#[derive(Serialize, Deserialize)] +struct PersistedEncryptionPrivateKey { + key: HpkePrivateKey, +} diff --git a/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs b/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs index 6a2b89dd45..b6f7f44a13 100644 --- a/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs +++ b/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs @@ -1,3 +1,4 @@ +mod decrypt; pub(super) mod encrypt; use const_format::concatcp; @@ -77,6 +78,10 @@ impl TargetedMessage { pub(super) const SIGN_LABEL_TRANSIENT: &str = concatcp!("TntMessageTBS-Transient-Targeted v", ProtocolVersion::V1.as_u16()); pub(super) const PSK_LABEL: &str = concatcp!("Tnt TargetedMessage Psk v", ProtocolVersion::V1.as_u16()); + + pub(super) fn sender(&self) -> LeafNodeIndex { + self.sender + } } /// The data that is extracted from the MLS group when encrypting or decrypting a targeted message. @@ -96,17 +101,26 @@ fn extract_hpke_context_data( .tls_serialize_detached() .map_err(Error::tls_serialize("TargetedMessageContext"))?; + let psk = derive_targeted_message_psk(crypto_provider, cipher_suite, mls_group)?; + let psk_id = PskId::new(mls_group.group_id().clone(), mls_group.epoch()); + let psk_id = psk_id.tls_serialize_detached().map_err(Error::tls_serialize("PskId"))?; + + Ok(HpkeContextData { info, psk_id, psk }) +} + +fn derive_targeted_message_psk( + crypto_provider: &impl OpenMlsCryptoProvider, + cipher_suite: &Ciphersuite, + mls_group: &MlsGroup, +) -> Result> { // We can use an empty context because we're using a unique label. - let psk = mls_group + mls_group .export_secret( crypto_provider, TargetedMessage::PSK_LABEL, &[], cipher_suite.hash_length(), ) - .map_err(OpenMlsError::wrap("exporting targeted message psk"))?; - let psk_id = PskId::new(mls_group.group_id().clone(), mls_group.epoch()); - let psk_id = psk_id.tls_serialize_detached().map_err(Error::tls_serialize("PskId"))?; - - Ok(HpkeContextData { info, psk_id, psk }) + .map_err(OpenMlsError::wrap("exporting targeted message psk")) + .map_err(Into::into) } From 9283ed29a04355c438d80b761168be5173f2f32f Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 15:26:53 +0200 Subject: [PATCH 16/25] feat(tnt): enable buffering targeted messages --- .../mutable/decrypt/buffer_messages.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs b/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs index de9d89164f..fcc65dc5fe 100644 --- a/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs +++ b/crypto/src/mls/conversation/mutable/decrypt/buffer_messages.rs @@ -5,15 +5,23 @@ use core_crypto_keystore::entities::MlsPendingMessage; use log::{error, info}; -use openmls::framing::{MlsMessageIn, MlsMessageInBody}; +use openmls::{ + framing::{MlsMessageIn, MlsMessageInBody}, + prelude::ContentType, +}; use tls_codec::Deserialize; use super::{RecursionPolicy, Result}; use crate::{ BufferedDecryptedMessage, KeystoreError, RecursiveError, - mls::conversation::{ConversationMut, Error}, + mls::conversation::{ConversationMut, Error, mutable::tnt::TntWireFormat}, }; +enum PendingMessage { + Mls(Box), + Tnt(Vec), +} + #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum MessageRestorePolicy { /// Retrieve and decrypt pending messages, then clear them from the keystore. @@ -82,6 +90,15 @@ impl ConversationMut { .map_err(KeystoreError::wrap("finding all mls pending messages"))? .into_iter() .map(|m| -> Result<_> { + let wire_format = u16::tls_deserialize_exact(&m.message[2..4]) + .map_err(Error::tls_deserialize("u16 (wire format)"))?; + if TntWireFormat::all().contains(&wire_format) { + // We want to restore tnt messages with the same priority as application messages, i.e., + // before proposals or commits, which is why we're using the value equal to that content type + // here. + return Ok((ContentType::Application as u8, PendingMessage::Tnt(m.message.clone()))); + } + let message = MlsMessageIn::tls_deserialize(&mut m.message.as_slice()) .map_err(Error::tls_deserialize("mls message in"))?; let content_type = match message.body_as_ref() { @@ -89,7 +106,7 @@ impl ConversationMut { MlsMessageInBody::PrivateMessage(m) => m.content_type(), _ => return Err(Error::InappropriateMessageBodyType), }; - Ok((content_type as u8, message)) + Ok((content_type as u8, PendingMessage::Mls(Box::new(message)))) }) .collect::>>()?; @@ -101,7 +118,12 @@ impl ConversationMut { let mut decrypted_messages = Vec::with_capacity(pending_messages.len()); for (_, m) in pending_messages { - let decrypted = self.decrypt_mls_message(m, RecursionPolicy::None).await?; + let decrypted = match m { + PendingMessage::Mls(m) => self.decrypt_mls_message(*m, RecursionPolicy::None).await?, + // We can recurse safely into the outer decrypt_message() with a tnt message, because their + // decryption path doesn't recurse further. + PendingMessage::Tnt(m) => self.decrypt_message(m).await?, + }; decrypted_messages.push(decrypted.into()); } From 3ec0236cd5924c3a586fc6e731119dc05cfe84eb Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 10:10:30 +0200 Subject: [PATCH 17/25] test(tnt): test decryption of targeted messages This tests - basic encryption/decryption roundtrip - decrypting messages as far back as `MAX_PAST_EPOCHS`, and failure beyond - protection from replays via `DuplicateMessageError` - buffering of targeted messages from epochs one step in the future --- .../conversation/mutable/tnt/targeted/mod.rs | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) diff --git a/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs b/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs index b6f7f44a13..9088f1cac5 100644 --- a/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs +++ b/crypto/src/mls/conversation/mutable/tnt/targeted/mod.rs @@ -124,3 +124,210 @@ fn derive_targeted_message_psk( .map_err(OpenMlsError::wrap("exporting targeted message psk")) .map_err(Into::into) } + +#[cfg(test)] +mod tests { + use super::TargetedMessagePolicy; + use crate::test_utils::*; + + #[apply(all_cred_cipher)] + async fn can_decrypt_targeted_message(case: TestContext) { + let [alice, bob] = case.sessions().await; + let conversation = case.create_conversation([&alice, &bob]).await; + + let message = b"This persisted message targets Bob"; + let recipient = bob.get_client_id().await; + let encrypted = conversation + .guard() + .await + .encrypt_targeted(&recipient, TargetedMessagePolicy::Persisted, message.to_vec()) + .await + .unwrap(); + assert_ne!(&message, &encrypted.as_slice()); + + let decrypted = conversation + .guard_of(&bob) + .await + .decrypt_message(encrypted) + .await + .unwrap() + .into_persisted_targeted() + .unwrap() + .plaintext; + + assert_eq!(&decrypted, &message); + + let message = b"This transient message targets Bob"; + let recipient = bob.get_client_id().await; + let encrypted = conversation + .guard() + .await + .encrypt_targeted(&recipient, TargetedMessagePolicy::Transient, message.to_vec()) + .await + .unwrap(); + assert_ne!(&message, &encrypted.as_slice()); + + let decrypted = conversation + .guard_of(&bob) + .await + .decrypt_message(encrypted) + .await + .unwrap() + .into_transient_targeted() + .unwrap() + .plaintext; + + assert_eq!(&decrypted, &message); + } + + #[apply(all_cred_cipher)] + async fn can_decrypt_targeted_message_from_past_epoch(case: TestContext) { + let [alice, bob] = case.sessions().await; + let mut conversation = case.create_conversation([&alice, &bob]).await; + let recipient = bob.get_client_id().await; + + // Bob will decrypt this message before advancing more than MAX_PAST_EPOCHS + let within_retention = conversation + .guard() + .await + .encrypt_targeted( + &recipient, + TargetedMessagePolicy::Persisted, + b"within retention".to_vec(), + ) + .await + .unwrap(); + + // Bob will fail to decrypt this message after advancing MAX_PAST_EPOCHS + 1 + let beyond_retention = conversation + .guard() + .await + .encrypt_targeted( + &recipient, + TargetedMessagePolicy::Persisted, + b"beyond retention".to_vec(), + ) + .await + .unwrap(); + + for _ in 0..crate::mls::conversation::config::MAX_PAST_EPOCHS { + conversation = conversation.acting_as(&bob).await.update_notify().await; + } + + let decrypted = conversation + .guard_of(&bob) + .await + .decrypt_message(within_retention) + .await + .unwrap() + .into_persisted_targeted() + .unwrap(); + assert_eq!(decrypted.plaintext, b"within retention"); + + // MAX_PAST_EPOCHS + 1 + conversation = conversation.acting_as(&bob).await.update_notify().await; + + let error = conversation + .guard_of(&bob) + .await + .decrypt_message(beyond_retention) + .await + .unwrap_err(); + assert!(matches!(error, crate::mls::conversation::Error::MessageEpochTooOld)); + } + + #[apply(all_cred_cipher)] + async fn cant_decrypt_same_targeted_message_twice(case: TestContext) { + let [alice, bob] = case.sessions().await; + let conversation = case.create_conversation([&alice, &bob]).await; + + let message = b"This persisted message targets Bob"; + let recipient = bob.get_client_id().await; + let encrypted = conversation + .guard() + .await + .encrypt_targeted(&recipient, TargetedMessagePolicy::Persisted, message.to_vec()) + .await + .unwrap(); + assert_ne!(&message, &encrypted.as_slice()); + + let decrypted = conversation + .guard_of(&bob) + .await + .decrypt_message(&encrypted) + .await + .unwrap() + .into_persisted_targeted() + .unwrap() + .plaintext; + + assert_eq!(&decrypted, &message); + + let error = conversation + .guard_of(&bob) + .await + .decrypt_message(encrypted) + .await + .unwrap_err(); + assert!(matches!(error, crate::mls::conversation::Error::DuplicateMessage)); + } + + #[apply(all_cred_cipher)] + async fn can_buffer_targeted_message(case: TestContext) { + let [alice, bob] = case.sessions().await; + let conversation = case.create_conversation([&alice, &bob]).await; + + let epoch_1 = b"This persisted message targets Bob in epoch 1"; + let recipient = bob.get_client_id().await; + let encrypted_epoch_1 = conversation + .guard() + .await + .encrypt_targeted(&recipient, TargetedMessagePolicy::Persisted, epoch_1.to_vec()) + .await + .unwrap(); + assert_ne!(&epoch_1, &encrypted_epoch_1.as_slice()); + + let commit = conversation.update().await; + + let epoch_2 = b"This persisted message targets Bob in epoch 2"; + let conversation = commit.conversation(); + let encrypted_epoch_2 = conversation + .guard() + .await + .encrypt_targeted(&recipient, TargetedMessagePolicy::Persisted, epoch_2.to_vec()) + .await + .unwrap(); + assert_ne!(&epoch_2, &encrypted_epoch_2.as_slice()); + + let decrypted = conversation + .guard_of(&bob) + .await + .decrypt_message(encrypted_epoch_1) + .await + .unwrap() + .into_persisted_targeted() + .unwrap() + .plaintext; + assert_eq!(&decrypted, &epoch_1); + + let error = conversation + .guard_of(&bob) + .await + .decrypt_message(encrypted_epoch_2) + .await + .unwrap_err(); + assert!(matches!( + error, + crate::mls::conversation::Error::BufferedFutureMessage { message_epoch: 2 } + )); + + let (_, decrypted_commit) = commit.notify_member_fallible(&bob).await; + let decrypted_commit = decrypted_commit.unwrap().into_commit().unwrap(); + let buffered_message = decrypted_commit.buffered_messages.unwrap().remove(0); + let decrypted = crate::DecryptedMessage::from(buffered_message) + .into_persisted_targeted() + .unwrap() + .plaintext; + assert_eq!(&decrypted, &epoch_2); + } +} From 24f023a916c12f45c9440f85091cbd2ad3d79022 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 09:18:09 +0200 Subject: [PATCH 18/25] refactor(crypto-ffi): add targeted message variants to `DecryptedMessage` --- crypto-ffi/src/decrypted_message.rs | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crypto-ffi/src/decrypted_message.rs b/crypto-ffi/src/decrypted_message.rs index 24023dc716..4bd58a9191 100644 --- a/crypto-ffi/src/decrypted_message.rs +++ b/crypto-ffi/src/decrypted_message.rs @@ -37,6 +37,24 @@ pub enum DecryptedMessage { /// Identity claims present in the sender credential. identity: WireIdentity, }, + /// The decrypted message is a transient targeted message. + TransientTargeted { + /// Decrypted text message. + plaintext: Vec, + /// The sender's `ClientId`. + sender_client_id: Arc, + /// Identity claims present in the sender credential. + identity: WireIdentity, + }, + /// The decrypted message is a persisted targeted message. + PersistedTargeted { + /// Decrypted text message. + plaintext: Vec, + /// The sender's `ClientId`. + sender_client_id: Arc, + /// Identity claims present in the sender credential. + identity: WireIdentity, + }, } impl From for DecryptedMessage { @@ -58,6 +76,16 @@ impl From for DecryptedMessage { delay: proposal.delay, identity: proposal.identity.into(), }, + CcDecryptedMessage::TransientTargeted(message) => Self::TransientTargeted { + plaintext: message.plaintext, + sender_client_id: Arc::new(message.sender_client_id.into()), + identity: message.identity.into(), + }, + CcDecryptedMessage::PersistedTargeted(message) => Self::PersistedTargeted { + plaintext: message.plaintext, + sender_client_id: Arc::new(message.sender_client_id.into()), + identity: message.identity.into(), + }, } } } @@ -93,6 +121,24 @@ pub enum BufferedDecryptedMessage { /// Identity claims present in the sender credential. identity: WireIdentity, }, + /// The decrypted message is a transient targeted message. + TransientTargeted { + /// Decrypted text message. + plaintext: Vec, + /// The sender's `ClientId`. + sender_client_id: Arc, + /// Identity claims present in the sender credential. + identity: WireIdentity, + }, + /// The decrypted message is a persisted targeted message. + PersistedTargeted { + /// Decrypted text message. + plaintext: Vec, + /// The sender's `ClientId`. + sender_client_id: Arc, + /// Identity claims present in the sender credential. + identity: WireIdentity, + }, } impl From for BufferedDecryptedMessage { @@ -111,6 +157,16 @@ impl From for BufferedDecryptedMessage { delay: proposal.delay, identity: proposal.identity.into(), }, + CcBufferedDecryptedMessage::TransientTargeted(message) => Self::TransientTargeted { + plaintext: message.plaintext, + sender_client_id: Arc::new(message.sender_client_id.into()), + identity: message.identity.into(), + }, + CcBufferedDecryptedMessage::PersistedTargeted(message) => Self::PersistedTargeted { + plaintext: message.plaintext, + sender_client_id: Arc::new(message.sender_client_id.into()), + identity: message.identity.into(), + }, } } } From 34c7860fde5fedcf7a57a1e08bf4b45649352997 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:01:34 +0200 Subject: [PATCH 19/25] chore(ffi/ts): add ts test for targeted messages --- .../bindings/js/shared/src/CoreCrypto.ts | 1 + .../js/shared/test/conversation.test.ts | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crypto-ffi/bindings/js/shared/src/CoreCrypto.ts b/crypto-ffi/bindings/js/shared/src/CoreCrypto.ts index bb45279a92..bb4e1fb4c0 100644 --- a/crypto-ffi/bindings/js/shared/src/CoreCrypto.ts +++ b/crypto-ffi/bindings/js/shared/src/CoreCrypto.ts @@ -85,5 +85,6 @@ export { HttpMethod, X509CredentialAcquisitionConfiguration, type Timestamp, + TargetedMessagePolicy, type MlsTransport, } from "#core-crypto-ffi"; diff --git a/crypto-ffi/bindings/js/shared/test/conversation.test.ts b/crypto-ffi/bindings/js/shared/test/conversation.test.ts index f3e04bd032..be6a9c2df9 100644 --- a/crypto-ffi/bindings/js/shared/test/conversation.test.ts +++ b/crypto-ffi/bindings/js/shared/test/conversation.test.ts @@ -45,4 +45,66 @@ describe("conversation", () => { expect(decryptedByAlice).to.equal(messageText); expect(decryptedByBob).to.equal(messageText); }); + + it("should allow decrypting targeted messages", async () => { + const results = await runOnPlatform(async () => { + const alice = await helpers.ccInit(); + const bobId = helpers.newClientId(); + const bob = await helpers.ccInit({ clientId: bobId }); + const conversationId = await helpers.createConversation(alice); + await helpers.invite(alice, bob, conversationId); + + const persistedMessage = new TextEncoder().encode( + "This persisted message targets Bob" + ); + const persistedCiphertext = await alice.transaction((ctx) => + ctx.encryptTargetedMessage( + conversationId, + bobId, + ccModule.TargetedMessagePolicy.Persisted, + persistedMessage + ) + ); + const persistedDecrypted = await bob.transaction((ctx) => + ctx.decryptMessage(conversationId, persistedCiphertext) + ); + + const transientMessage = new TextEncoder().encode( + "This transient message targets Bob" + ); + const transientCiphertext = await alice.transaction((ctx) => + ctx.encryptTargetedMessage( + conversationId, + bobId, + ccModule.TargetedMessagePolicy.Transient, + transientMessage + ) + ); + const transientDecrypted = await bob.transaction((ctx) => + ctx.decryptMessage(conversationId, transientCiphertext) + ); + + const decoder = new TextDecoder(); + + return { + persistedMessage: decoder.decode(persistedMessage), + persistedPlaintext: + ccModule.DecryptedMessage.PersistedTargeted.instanceOf( + persistedDecrypted + ) + ? decoder.decode(persistedDecrypted.inner.plaintext) + : "wrong decrypted variant", + transientMessage: decoder.decode(transientMessage), + transientPlaintext: + ccModule.DecryptedMessage.TransientTargeted.instanceOf( + transientDecrypted + ) + ? decoder.decode(transientDecrypted.inner.plaintext) + : "wrong decrypted variant", + }; + }); + + expect(results.persistedPlaintext).to.equal(results.persistedMessage); + expect(results.transientPlaintext).to.equal(results.transientMessage); + }); }); From 7a56ca5e615465a4bcff571cf99b8ee45af52d6c Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:01:52 +0200 Subject: [PATCH 20/25] chore(ffi/kotlin): add kotlin test for targeted messages --- .../kotlin/com/wire/crypto/MLSTest.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt b/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt index 82e2874eaf..e843a3a997 100644 --- a/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt +++ b/crypto-ffi/bindings/shared/src/commonTest/kotlin/com/wire/crypto/MLSTest.kt @@ -233,6 +233,52 @@ class MLSTest { assertIs(expectedException.mlsError) } + @Test + fun encryptTargetedMessage_should_encrypt_then_receiver_should_decrypt() = runTest { + val alice = ccInit() + val bobId = genClientId() + val bob = ccInit(CcInitOptions(clientId = bobId)) + + val conversationId = createConversation(alice) + invite(alice, bob, conversationId) + + val persistedMessage = "This persisted message targets Bob".toByteArray() + val persistedCiphertext = alice.transaction { ctx -> + ctx.encryptTargetedMessage( + conversationId, + bobId, + TargetedMessagePolicy.PERSISTED, + persistedMessage + ) + } + assertThat(persistedCiphertext).isNotEqualTo(persistedMessage) + + val persistedPlaintext = bob.transaction { ctx -> + assertIs( + ctx.decryptMessage(conversationId, persistedCiphertext) + ).plaintext + } + assertThat(persistedPlaintext).isEqualTo(persistedMessage) + + val transientMessage = "This transient message targets Bob".toByteArray() + val transientCiphertext = alice.transaction { ctx -> + ctx.encryptTargetedMessage( + conversationId, + bobId, + TargetedMessagePolicy.TRANSIENT, + transientMessage + ) + } + assertThat(transientCiphertext).isNotEqualTo(transientMessage) + + val transientPlaintext = bob.transaction { ctx -> + assertIs( + ctx.decryptMessage(conversationId, transientCiphertext) + ).plaintext + } + assertThat(transientPlaintext).isEqualTo(transientMessage) + } + @Test fun addClientsToConversation_should_add_members_to_the_MLS_group() = runTest { val aliceId = genClientId() From 09eb4dce4e2b675c8036874be4e19b775cfd4a04 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:02:03 +0200 Subject: [PATCH 21/25] chore(ffi/swift): add swift test for targeted messages --- .../WireCoreCryptoTests.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift b/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift index 24434dea2d..1f27854ffc 100644 --- a/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift +++ b/crypto-ffi/bindings/swift/WireCoreCrypto/WireCoreCryptoTests/WireCoreCryptoTests.swift @@ -612,6 +612,61 @@ final class WireCoreCryptoTests: XCTestCase { } } + func testTargetedMessagesCanBeDecryptedByReceiver() async throws { + let alice = try await ccInit() + let bobId = genClientId() + let bob = try await ccInit(options: CcInitOptions(clientId: bobId)) + + let conversationId = try await createConversation(coreCrypto: alice) + _ = try await invite(cc1: alice, cc2: bob, conversationId: conversationId) + + let persistedMessage = Data("This persisted message targets Bob".utf8) + let persistedCiphertext = try await alice.transaction { ctx in + try await ctx.encryptTargetedMessage( + conversationId: conversationId, + recipient: bobId, + policy: .persisted, + message: persistedMessage + ) + } + XCTAssertNotEqual(persistedCiphertext, persistedMessage) + + let persistedDecrypted = try await bob.transaction { ctx in + try await ctx.decryptMessage( + conversationId: conversationId, + payload: persistedCiphertext + ) + } + guard case .persistedTargeted(let plaintext, _, _) = persistedDecrypted else { + XCTFail("Expected a decrypted persisted targeted message") + return + } + XCTAssertEqual(plaintext, persistedMessage) + + let transientMessage = Data("This transient message targets Bob".utf8) + let transientCiphertext = try await alice.transaction { ctx in + try await ctx.encryptTargetedMessage( + conversationId: conversationId, + recipient: bobId, + policy: .transient, + message: transientMessage + ) + } + XCTAssertNotEqual(transientCiphertext, transientMessage) + + let transientDecrypted = try await bob.transaction { ctx in + try await ctx.decryptMessage( + conversationId: conversationId, + payload: transientCiphertext + ) + } + guard case .transientTargeted(let plaintext, _, _) = transientDecrypted else { + XCTFail("Expected a decrypted transient targeted message") + return + } + XCTAssertEqual(plaintext, transientMessage) + } + func testRegisterEpochObserverShouldNotifyObserverOnNewEpoch() async throws { struct Epoch: Equatable { let conversationId: ConversationId From 839364da0aa24126e646eba5866eeeeee0ad1567 Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:38:23 +0200 Subject: [PATCH 22/25] chore(interop): resolve compile errors caused by new decrypted message variants in clients --- .../clients/InteropClient/InteropClient/InteropClientApp.swift | 2 ++ .../main/java/com/wire/androidinterop/InteropActionHandler.kt | 3 +++ 2 files changed, 5 insertions(+) diff --git a/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift b/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift index 2b7d48d0cf..7519e2cc50 100644 --- a/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift +++ b/interop/src/clients/InteropClient/InteropClient/InteropClientApp.swift @@ -218,6 +218,8 @@ struct InteropClientApp: App { return plaintext.base64EncodedString() case .commit, .proposal: return "decrypted protocol message" + case .persistedTargeted, .transientTargeted: + return "decrypted transient message (currently unused in interop)" } case .initProteus: diff --git a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt index f8889ab06f..41f1d8096b 100644 --- a/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt +++ b/interop/src/clients/android-interop/src/main/java/com/wire/androidinterop/InteropActionHandler.kt @@ -77,6 +77,9 @@ class InteropActionHandler(val coreCrypto: CoreCrypto) { is DecryptedMessage.Commit, is DecryptedMessage.Proposal -> Result.success("decrypted protocol message") + is DecryptedMessage.PersistedTargeted, + is DecryptedMessage.TransientTargeted -> Result.success + ("decrypted transient message (currently unused in interop)") } } From b4d95c985a342eabfe15a4c21c3281e74dec2c9c Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:37:41 +0200 Subject: [PATCH 23/25] fixup! feat(keystore): add `tnt_secrets` table and corresponding struct --- keystore/src/entities/mls/tnt_secret.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/keystore/src/entities/mls/tnt_secret.rs b/keystore/src/entities/mls/tnt_secret.rs index b3994c6092..dcdcc5ef6c 100644 --- a/keystore/src/entities/mls/tnt_secret.rs +++ b/keystore/src/entities/mls/tnt_secret.rs @@ -77,16 +77,16 @@ impl EntityDatabaseMutation for TntSecret { let mut stmt = tx.prepare_cached(&format!( "INSERT OR REPLACE INTO {} (conversation_id, epoch, hpke_private_key, group_context, targeted_message_psk) - VALUES (?, ?, ?, ?, ?)", + VALUES (:conversation_id, :epoch, :hpke_private_key, :group_context, :targeted_message_psk)", Self::TABLE_NAME ))?; - stmt.execute(rusqlite::params![ - self.conversation_id, - self.epoch, - self.hpke_private_key, - self.group_context, - self.targeted_message_psk, - ])?; + stmt.execute(rusqlite::named_params! { + ":conversation_id": self.conversation_id, + ":epoch": self.epoch, + ":hpke_private_key": self.hpke_private_key, + ":group_context": self.group_context, + ":targeted_message_psk": self.targeted_message_psk, + })?; Ok(()) } From db89d3797294ee2312b5c04c82099e5a0042f72c Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:43:35 +0200 Subject: [PATCH 24/25] fixup! feat(keystore): add `targeted_message_rx_counters` table and corresponding struct --- .../entities/mls/targeted_message_rx_counter.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/keystore/src/entities/mls/targeted_message_rx_counter.rs b/keystore/src/entities/mls/targeted_message_rx_counter.rs index 27a0f3409c..f826cd101a 100644 --- a/keystore/src/entities/mls/targeted_message_rx_counter.rs +++ b/keystore/src/entities/mls/targeted_message_rx_counter.rs @@ -72,15 +72,16 @@ impl EntityDatabaseMutation for TargetedMessageRxCounter { fn save(&self, tx: &rusqlite::Transaction) -> crate::CryptoKeystoreResult<()> { let mut stmt = tx.prepare_cached(&format!( - "INSERT OR REPLACE INTO {} (conversation_id, sender, epoch, count) VALUES (?, ?, ?, ?)", + "INSERT OR REPLACE INTO {} (conversation_id, sender, epoch, count) \ + VALUES (:conversation_id, :sender, :epoch, :count)", Self::TABLE_NAME ))?; - stmt.execute(rusqlite::params![ - self.conversation_id, - self.sender, - self.epoch, - self.count - ])?; + stmt.execute(rusqlite::named_params! { + ":conversation_id": self.conversation_id, + ":sender": self.sender, + ":epoch": self.epoch, + ":count": self.count, + })?; Ok(()) } From ffa32fa28df049c66960097adf077c593c73d24b Mon Sep 17 00:00:00 2001 From: SimonThormeyer Date: Wed, 19 Aug 2026 16:50:13 +0200 Subject: [PATCH 25/25] wip pk ref. todo: impl EntityGetBorrowed --- keystore/src/entities/mls/tnt_secret.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/keystore/src/entities/mls/tnt_secret.rs b/keystore/src/entities/mls/tnt_secret.rs index dcdcc5ef6c..8f37e7af4e 100644 --- a/keystore/src/entities/mls/tnt_secret.rs +++ b/keystore/src/entities/mls/tnt_secret.rs @@ -26,6 +26,11 @@ pub struct TntSecretPk { epoch: u64, } +pub struct TntSecretPkRef<'a> { + conversation_id: &'a [u8], + epoch: u64, +} + impl TntSecret { const TABLE_NAME: &str = "tnt_secrets"; const PRIMARY_KEY_COLUMN_NAMES: [&str; 2] = ["conversation_id", "epoch"];