Skip to main content

huddle_core/crypto/
dm.rs

1//! huddle 0.7.1: End-to-end DM key derivation via Ed25519→X25519 ECDH.
2//!
3//! Both peers in a 1-1 DM derive the same 32-byte room key from their
4//! long-term Ed25519 identity keys — no shared passphrase, no central
5//! key agreement, no extra round-trip beyond `MemberAnnounce` for the
6//! partner's pubkey.
7//!
8//! Steps:
9//!   1. Ed25519 seed → X25519 secret. We hash the seed with SHA-512 and
10//!      take the first 32 bytes; `StaticSecret::from(bytes)` performs
11//!      the canonical X25519 clamping. This is the same conversion
12//!      libsodium uses in `crypto_sign_ed25519_sk_to_curve25519`.
13//!   2. Ed25519 pubkey → X25519 pubkey via the birational
14//!      Edwards-to-Montgomery map (`VerifyingKey::to_montgomery`).
15//!      Matches `crypto_sign_ed25519_pk_to_curve25519`.
16//!   3. X25519 Diffie-Hellman gives a 32-byte shared secret.
17//!   4. HKDF-SHA256 expands it to the room key, binding the result to
18//!      the canonical DM room_id via the `info` parameter so this DM's
19//!      key can never collide with any other context.
20//!
21//! The output replaces the Argon2id-derived `passphrase_key` in the
22//! existing encrypted-room flow. The wrap / unwrap helpers in
23//! `crypto::passphrase` accept any `[u8; 32]`, so no other changes are
24//! needed downstream — DMs and group rooms share the Megolm path.
25
26use ed25519_dalek::VerifyingKey;
27use hkdf::Hkdf;
28use sha2::{Digest, Sha256, Sha512};
29use x25519_dalek::{PublicKey, StaticSecret};
30use zeroize::{Zeroize, Zeroizing};
31
32use crate::crypto::passphrase::KEY_LEN;
33use crate::error::{HuddleError, Result};
34
35/// Derive the symmetric DM room key from one side's Ed25519 secret seed
36/// and the other side's Ed25519 public key, plus the canonical DM
37/// room_id (which binds the key to this specific 1-1 channel).
38///
39/// Both peers, swapping seed ↔ pubkey, derive identical output.
40pub fn derive_dm_key(
41    our_ed25519_seed: &[u8; 32],
42    partner_ed25519_pubkey: &[u8; 32],
43    canonical_room_id: &str,
44) -> Result<[u8; KEY_LEN]> {
45    let our_x = ed25519_seed_to_x25519_secret(our_ed25519_seed);
46    let partner_x = ed25519_pubkey_to_x25519(partner_ed25519_pubkey)?;
47    let shared = our_x.diffie_hellman(&partner_x);
48    // huddle 1.1.4: defense-in-depth small-order check. A non-contributory
49    // partner pubkey (one of the eight small-order Montgomery points, which
50    // an Ed25519 small-order point maps to) forces a predictable low-order
51    // shared secret regardless of our secret — so an attacker who injects
52    // such a "pubkey" could derive the room key. Two honest peers always
53    // produce a contributory secret, so this never rejects a real DM.
54    if !shared.was_contributory() {
55        return Err(HuddleError::Session(
56            "DM key agreement rejected: partner X25519 pubkey is non-contributory \
57             (small-order point)"
58                .into(),
59        ));
60    }
61    // HKDF-SHA256: a fixed v1 salt (versioned for future rotation) and
62    // the canonical room_id as `info` so two different DMs between the
63    // same identities (impossible by construction, but defended in
64    // depth) can't share keys.
65    let salt = b"huddle-dm-key-v1\0";
66    let h = Hkdf::<Sha256>::new(Some(salt), shared.as_bytes());
67    let mut out = [0u8; KEY_LEN];
68    h.expand(canonical_room_id.as_bytes(), &mut out)
69        .map_err(|e| HuddleError::Session(format!("hkdf expand: {e}")))?;
70    Ok(out)
71}
72
73fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> StaticSecret {
74    // SHA-512(seed)[..32] is the canonical conversion. X25519's
75    // `StaticSecret::from` applies the required RFC 7748 clamping
76    // (clear low 3 bits, set bit 254, clear bit 255) so we don't need
77    // to do it manually.
78    //
79    // huddle 1.1.4: the SHA-512 digest and the extracted scalar are both
80    // secret X25519 key material. The scalar lives in `Zeroizing`; the digest
81    // (whose first 32 bytes ARE the scalar) is explicitly zeroized before it
82    // drops so no un-wiped copy lingers. `StaticSecret` zeroizes on drop too.
83    let mut h = Sha512::digest(seed);
84    let mut bytes = Zeroizing::new([0u8; 32]);
85    bytes.copy_from_slice(&h[..32]);
86    h.as_mut_slice().zeroize();
87    StaticSecret::from(*bytes)
88}
89
90fn ed25519_pubkey_to_x25519(pubkey_bytes: &[u8; 32]) -> Result<PublicKey> {
91    let vk = VerifyingKey::from_bytes(pubkey_bytes)
92        .map_err(|e| HuddleError::Session(format!("bad ed25519 pubkey: {e}")))?;
93    Ok(PublicKey::from(vk.to_montgomery().to_bytes()))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::identity::Identity;
100
101    #[test]
102    fn dm_key_is_commutative() {
103        let alice = Identity::generate().unwrap();
104        let bob = Identity::generate().unwrap();
105        let room_id = "deadbeefcafef00d1234567890abcdef";
106        let k_a = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
107        let k_b = derive_dm_key(&bob.secret_bytes(), &alice.public_bytes(), room_id).unwrap();
108        assert_eq!(k_a, k_b, "both peers must derive the same DM key");
109    }
110
111    #[test]
112    fn dm_key_is_deterministic() {
113        let alice = Identity::generate().unwrap();
114        let bob = Identity::generate().unwrap();
115        let room_id = "room-1";
116        let k1 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
117        let k2 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
118        assert_eq!(k1, k2);
119    }
120
121    #[test]
122    fn dm_key_binds_to_room_id() {
123        let alice = Identity::generate().unwrap();
124        let bob = Identity::generate().unwrap();
125        let k1 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), "room-1").unwrap();
126        let k2 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), "room-2").unwrap();
127        assert_ne!(
128            k1, k2,
129            "different room_ids must produce different keys (HKDF info parameter)"
130        );
131    }
132
133    #[test]
134    fn dm_key_differs_per_pair() {
135        let alice = Identity::generate().unwrap();
136        let bob = Identity::generate().unwrap();
137        let carol = Identity::generate().unwrap();
138        let room = "room";
139        let k_ab = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room).unwrap();
140        let k_ac = derive_dm_key(&alice.secret_bytes(), &carol.public_bytes(), room).unwrap();
141        assert_ne!(k_ab, k_ac);
142    }
143
144    #[test]
145    fn rejects_invalid_ed25519_pubkey() {
146        let alice = Identity::generate().unwrap();
147        // 32 bytes that aren't a valid Edwards point.
148        let mut bad = [0u8; 32];
149        bad[31] = 0xff;
150        let r = derive_dm_key(&alice.secret_bytes(), &bad, "room");
151        // VerifyingKey::from_bytes accepts the low-order points but
152        // rejects truly malformed inputs. This particular test exercises
153        // the error path on a non-canonical encoding.
154        let _ = r; // success or err — both fine for sanity of the call path
155    }
156
157    #[test]
158    fn rejects_small_order_partner_pubkey() {
159        // The Ed25519 identity point (y = 1, encoded 0x01 0x00…) maps to a
160        // small-order Montgomery point, so the ECDH is non-contributory.
161        // The contributory check must reject it (either VerifyingKey decode
162        // fails or was_contributory() is false — both surface as Err).
163        let alice = Identity::generate().unwrap();
164        let mut id_point = [0u8; 32];
165        id_point[0] = 1;
166        let r = derive_dm_key(&alice.secret_bytes(), &id_point, "room");
167        assert!(r.is_err(), "small-order partner pubkey must be rejected");
168    }
169}