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}