Skip to main content

huddle_protocol/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::crypto::pqc::{self, PqKeypair, SS_LEN};
34use crate::error::{ProtocolError, Result};
35
36/// Compute the classical X25519 shared secret half of a DM: our Ed25519 seed
37/// against the partner's Ed25519 pubkey, with the 1.1.4 small-order
38/// (contributory) check. Returned zeroizing so the raw secret is wiped after
39/// it has been fed into a KDF. Shared by the classical and hybrid paths so the
40/// contributory check has a single source of truth.
41fn x25519_shared(
42    our_ed25519_seed: &[u8; 32],
43    partner_ed25519_pubkey: &[u8; 32],
44) -> Result<Zeroizing<[u8; 32]>> {
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(ProtocolError::Session(
56            "DM key agreement rejected: partner X25519 pubkey is non-contributory \
57             (small-order point)"
58                .into(),
59        ));
60    }
61    Ok(Zeroizing::new(*shared.as_bytes()))
62}
63
64/// Derive the symmetric DM room key from one side's Ed25519 secret seed
65/// and the other side's Ed25519 public key, plus the canonical DM
66/// room_id (which binds the key to this specific 1-1 channel).
67///
68/// Both peers, swapping seed ↔ pubkey, derive identical output. This is the
69/// **classical** (pre-quantum) derivation, kept as the backward-compatible
70/// fallback when a peer has not published an ML-KEM key. See
71/// `derive_dm_key_hybrid_initiator` / `_responder` for the post-quantum path.
72pub fn derive_dm_key(
73    our_ed25519_seed: &[u8; 32],
74    partner_ed25519_pubkey: &[u8; 32],
75    canonical_room_id: &str,
76) -> Result<[u8; KEY_LEN]> {
77    let shared = x25519_shared(our_ed25519_seed, partner_ed25519_pubkey)?;
78    // HKDF-SHA256: a fixed v1 salt (versioned for future rotation) and
79    // the canonical room_id as `info` so two different DMs between the
80    // same identities (impossible by construction, but defended in
81    // depth) can't share keys.
82    let salt = b"huddle-dm-key-v1\0";
83    let h = Hkdf::<Sha256>::new(Some(salt), shared.as_slice());
84    let mut out = [0u8; KEY_LEN];
85    h.expand(canonical_room_id.as_bytes(), &mut out)
86        .map_err(|e| ProtocolError::Session(format!("hkdf expand: {e}")))?;
87    Ok(out)
88}
89
90/// HKDF label for the deterministic ML-KEM encapsulation message.
91const DM_ENCAPS_LABEL: &[u8] = b"huddle-dm-mlkem-encaps-v1";
92
93/// Deterministic 32-byte ML-KEM encapsulation message for a DM, derived from
94/// the **initiator's** Ed25519 seed bound to the partner's ML-KEM ek and the
95/// canonical room id. This lets the initiator reproduce the exact ciphertext +
96/// shared secret with no stored per-DM state, while `m` stays secret to anyone
97/// without the initiator's seed — so a Shor attacker who recovers the X25519
98/// secret still cannot reconstruct the ML-KEM half. (See `crypto::pqc`.)
99fn derive_encaps_message(
100    initiator_ed25519_seed: &[u8; 32],
101    partner_mlkem_ek: &[u8],
102    canonical_room_id: &str,
103) -> Zeroizing<[u8; SS_LEN]> {
104    let hk = Hkdf::<Sha256>::new(Some(DM_ENCAPS_LABEL), initiator_ed25519_seed);
105    let mut info = Vec::with_capacity(partner_mlkem_ek.len() + canonical_room_id.len());
106    info.extend_from_slice(partner_mlkem_ek);
107    info.extend_from_slice(canonical_room_id.as_bytes());
108    let mut m = Zeroizing::new([0u8; SS_LEN]);
109    hk.expand(&info, m.as_mut_slice())
110        .expect("HKDF expand to 32 bytes is within SHA-256's output limit");
111    m
112}
113
114/// huddle 1.3: **initiator** side of the hybrid (X25519 + ML-KEM-768) DM key
115/// agreement. The initiator — by convention the peer whose fingerprint sorts
116/// lower — encapsulates a fresh ML-KEM secret to the partner's published
117/// encapsulation key, mixes it with the classical X25519 secret, and gets the
118/// DM wrap key plus the KEM ciphertext to transmit to the partner.
119///
120/// Returns `(hybrid_dm_key, kem_ciphertext)`. The ciphertext is **public** wire
121/// data (carried in `MemberAnnounce.mlkem_ciphertext`); the responder needs it
122/// to recover the same key via `derive_dm_key_hybrid_responder`.
123pub fn derive_dm_key_hybrid_initiator(
124    our_ed25519_seed: &[u8; 32],
125    partner_ed25519_pubkey: &[u8; 32],
126    partner_mlkem_ek: &[u8],
127    canonical_room_id: &str,
128) -> Result<([u8; KEY_LEN], Vec<u8>)> {
129    let ss_x = x25519_shared(our_ed25519_seed, partner_ed25519_pubkey)?;
130    let m = derive_encaps_message(our_ed25519_seed, partner_mlkem_ek, canonical_room_id);
131    let (ct, ss_pq) = pqc::encapsulate_deterministic(partner_mlkem_ek, &m)?;
132    let key = pqc::combine_hybrid(&ss_x, &ss_pq, &ct, canonical_room_id.as_bytes());
133    Ok((*key, ct))
134}
135
136/// huddle 1.3: **responder** side of the hybrid DM key agreement. The responder
137/// — the higher-fingerprint peer — decapsulates the initiator's KEM ciphertext
138/// with its own ML-KEM keypair, mixes the recovered secret with the same
139/// classical X25519 secret, and arrives at the identical DM wrap key.
140pub fn derive_dm_key_hybrid_responder(
141    our_pq: &PqKeypair,
142    our_ed25519_seed: &[u8; 32],
143    partner_ed25519_pubkey: &[u8; 32],
144    kem_ciphertext: &[u8],
145    canonical_room_id: &str,
146) -> Result<[u8; KEY_LEN]> {
147    let ss_x = x25519_shared(our_ed25519_seed, partner_ed25519_pubkey)?;
148    let ss_pq = our_pq.decapsulate(kem_ciphertext)?;
149    let key = pqc::combine_hybrid(&ss_x, &ss_pq, kem_ciphertext, canonical_room_id.as_bytes());
150    Ok(*key)
151}
152
153/// huddle 2.0: downgrade guard for the DM **classical** (X25519-only) fallback.
154///
155/// Returns `true` when a classical DM key MUST be refused because the peer is
156/// known to be post-quantum capable yet no ML-KEM encapsulation key is
157/// available to build the hybrid key from — the fingerprint of a relay that
158/// stripped the partner's ML-KEM pubkey to force a quantum-unsafe downgrade.
159///
160/// `peer_known_pq_capable` is the OR of every capability anchor the app holds:
161/// an ML-KEM key on the current signed `MemberAnnounce`, the durable
162/// `room_members.mlkem_pubkey` pin, **or** the out-of-band
163/// `verified_peers.pq_capable` flag set when the peer SAS-verified with the F1
164/// capability binding (`crypto::sas::derive_sas_code` with the partner's ek). The
165/// verified-peer anchor is the strongest of the three: it survives a relay
166/// dropping both the live announce key and the pin, so a peer we once confirmed
167/// PQ-capable can never be silently re-keyed classical.
168///
169/// `have_mlkem_ek` is whether we currently hold a usable ek (announce or pin) to
170/// derive the hybrid key. When the peer is known capable but `have_mlkem_ek` is
171/// `false`, the caller should derive **no** key and instead wait for / request a
172/// genuine hybrid announce rather than locking in a classical key.
173///
174/// This is a pure predicate so the security-critical downgrade policy is unit
175/// testable without an `AppHandle`; the full key-derivation decision (initiator
176/// vs responder, one-way classical→hybrid upgrade) lives in `app::plan_dm_key`,
177/// which folds this guard in via its `partner_pq_capable` input.
178pub fn must_refuse_classical_fallback(peer_known_pq_capable: bool, have_mlkem_ek: bool) -> bool {
179    peer_known_pq_capable && !have_mlkem_ek
180}
181
182fn ed25519_seed_to_x25519_secret(seed: &[u8; 32]) -> StaticSecret {
183    // SHA-512(seed)[..32] is the canonical conversion. X25519's
184    // `StaticSecret::from` applies the required RFC 7748 clamping
185    // (clear low 3 bits, set bit 254, clear bit 255) so we don't need
186    // to do it manually.
187    //
188    // huddle 1.1.4: the SHA-512 digest and the extracted scalar are both
189    // secret X25519 key material. The scalar lives in `Zeroizing`; the digest
190    // (whose first 32 bytes ARE the scalar) is explicitly zeroized before it
191    // drops so no un-wiped copy lingers. `StaticSecret` zeroizes on drop too.
192    let mut h = Sha512::digest(seed);
193    let mut bytes = Zeroizing::new([0u8; 32]);
194    bytes.copy_from_slice(&h[..32]);
195    h.as_mut_slice().zeroize();
196    StaticSecret::from(*bytes)
197}
198
199fn ed25519_pubkey_to_x25519(pubkey_bytes: &[u8; 32]) -> Result<PublicKey> {
200    let vk = VerifyingKey::from_bytes(pubkey_bytes)
201        .map_err(|e| ProtocolError::Session(format!("bad ed25519 pubkey: {e}")))?;
202    Ok(PublicKey::from(vk.to_montgomery().to_bytes()))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::identity::IdentityKeys;
209
210    #[test]
211    fn dm_key_is_commutative() {
212        let alice = IdentityKeys::generate().unwrap();
213        let bob = IdentityKeys::generate().unwrap();
214        let room_id = "deadbeefcafef00d1234567890abcdef";
215        let k_a = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
216        let k_b = derive_dm_key(&bob.secret_bytes(), &alice.public_bytes(), room_id).unwrap();
217        assert_eq!(k_a, k_b, "both peers must derive the same DM key");
218    }
219
220    #[test]
221    fn dm_key_is_deterministic() {
222        let alice = IdentityKeys::generate().unwrap();
223        let bob = IdentityKeys::generate().unwrap();
224        let room_id = "room-1";
225        let k1 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
226        let k2 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room_id).unwrap();
227        assert_eq!(k1, k2);
228    }
229
230    #[test]
231    fn dm_key_binds_to_room_id() {
232        let alice = IdentityKeys::generate().unwrap();
233        let bob = IdentityKeys::generate().unwrap();
234        let k1 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), "room-1").unwrap();
235        let k2 = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), "room-2").unwrap();
236        assert_ne!(
237            k1, k2,
238            "different room_ids must produce different keys (HKDF info parameter)"
239        );
240    }
241
242    #[test]
243    fn dm_key_differs_per_pair() {
244        let alice = IdentityKeys::generate().unwrap();
245        let bob = IdentityKeys::generate().unwrap();
246        let carol = IdentityKeys::generate().unwrap();
247        let room = "room";
248        let k_ab = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room).unwrap();
249        let k_ac = derive_dm_key(&alice.secret_bytes(), &carol.public_bytes(), room).unwrap();
250        assert_ne!(k_ab, k_ac);
251    }
252
253    #[test]
254    fn rejects_invalid_ed25519_pubkey() {
255        let alice = IdentityKeys::generate().unwrap();
256        // 32 bytes that aren't a valid Edwards point.
257        let mut bad = [0u8; 32];
258        bad[31] = 0xff;
259        let r = derive_dm_key(&alice.secret_bytes(), &bad, "room");
260        // VerifyingKey::from_bytes accepts the low-order points but
261        // rejects truly malformed inputs. This particular test exercises
262        // the error path on a non-canonical encoding.
263        let _ = r; // success or err — both fine for sanity of the call path
264    }
265
266    #[test]
267    fn rejects_small_order_partner_pubkey() {
268        // The Ed25519 identity point (y = 1, encoded 0x01 0x00…) maps to a
269        // small-order Montgomery point, so the ECDH is non-contributory.
270        // The contributory check must reject it (either VerifyingKey decode
271        // fails or was_contributory() is false — both surface as Err).
272        let alice = IdentityKeys::generate().unwrap();
273        let mut id_point = [0u8; 32];
274        id_point[0] = 1;
275        let r = derive_dm_key(&alice.secret_bytes(), &id_point, "room");
276        assert!(r.is_err(), "small-order partner pubkey must be rejected");
277    }
278
279    // ---- huddle 1.3: hybrid X25519 + ML-KEM-768 DM key agreement ----
280
281    #[test]
282    fn hybrid_initiator_and_responder_agree() {
283        // alice = initiator (encapsulates to bob's ek), bob = responder.
284        let alice = IdentityKeys::generate().unwrap();
285        let bob = IdentityKeys::generate().unwrap();
286        let room = "deadbeefcafef00d1234567890abcdef";
287
288        let (k_init, ct) = derive_dm_key_hybrid_initiator(
289            &alice.secret_bytes(),
290            &bob.public_bytes(),
291            &bob.mlkem_public_bytes(),
292            room,
293        )
294        .unwrap();
295
296        let k_resp = derive_dm_key_hybrid_responder(
297            &bob.pq_keypair(),
298            &bob.secret_bytes(),
299            &alice.public_bytes(),
300            &ct,
301            room,
302        )
303        .unwrap();
304
305        assert_eq!(
306            k_init, k_resp,
307            "both peers must derive the same hybrid DM key"
308        );
309    }
310
311    #[test]
312    fn hybrid_key_differs_from_classical() {
313        let alice = IdentityKeys::generate().unwrap();
314        let bob = IdentityKeys::generate().unwrap();
315        let room = "room-x";
316
317        let classical = derive_dm_key(&alice.secret_bytes(), &bob.public_bytes(), room).unwrap();
318        let (hybrid, _ct) = derive_dm_key_hybrid_initiator(
319            &alice.secret_bytes(),
320            &bob.public_bytes(),
321            &bob.mlkem_public_bytes(),
322            room,
323        )
324        .unwrap();
325        assert_ne!(
326            classical, hybrid,
327            "hybrid key must mix in the ML-KEM secret, so it differs from classical"
328        );
329    }
330
331    #[test]
332    fn hybrid_is_reproducible_by_initiator() {
333        // Deterministic encapsulation: the initiator re-derives the identical
334        // key + ciphertext with no stored per-DM state (survives a restart).
335        let alice = IdentityKeys::generate().unwrap();
336        let bob = IdentityKeys::generate().unwrap();
337        let room = "room-determinism";
338        let (k1, ct1) = derive_dm_key_hybrid_initiator(
339            &alice.secret_bytes(),
340            &bob.public_bytes(),
341            &bob.mlkem_public_bytes(),
342            room,
343        )
344        .unwrap();
345        let (k2, ct2) = derive_dm_key_hybrid_initiator(
346            &alice.secret_bytes(),
347            &bob.public_bytes(),
348            &bob.mlkem_public_bytes(),
349            room,
350        )
351        .unwrap();
352        assert_eq!(k1, k2);
353        assert_eq!(ct1, ct2);
354    }
355
356    #[test]
357    fn hybrid_binds_to_room_id() {
358        let alice = IdentityKeys::generate().unwrap();
359        let bob = IdentityKeys::generate().unwrap();
360        let (k1, _) = derive_dm_key_hybrid_initiator(
361            &alice.secret_bytes(),
362            &bob.public_bytes(),
363            &bob.mlkem_public_bytes(),
364            "room-1",
365        )
366        .unwrap();
367        let (k2, _) = derive_dm_key_hybrid_initiator(
368            &alice.secret_bytes(),
369            &bob.public_bytes(),
370            &bob.mlkem_public_bytes(),
371            "room-2",
372        )
373        .unwrap();
374        assert_ne!(k1, k2, "different rooms must yield different hybrid keys");
375    }
376
377    #[test]
378    fn hybrid_responder_rejects_tampered_ciphertext() {
379        // A flipped ciphertext bit decapsulates to a different ML-KEM secret
380        // (implicit rejection), so the responder derives a DIFFERENT key than
381        // the initiator — the wrapped session key then fails to unwrap, which
382        // is the desired fail-closed behaviour.
383        let alice = IdentityKeys::generate().unwrap();
384        let bob = IdentityKeys::generate().unwrap();
385        let room = "room-tamper";
386        let (k_init, mut ct) = derive_dm_key_hybrid_initiator(
387            &alice.secret_bytes(),
388            &bob.public_bytes(),
389            &bob.mlkem_public_bytes(),
390            room,
391        )
392        .unwrap();
393        ct[0] ^= 0x01;
394        let k_resp = derive_dm_key_hybrid_responder(
395            &bob.pq_keypair(),
396            &bob.secret_bytes(),
397            &alice.public_bytes(),
398            &ct,
399            room,
400        )
401        .unwrap();
402        assert_ne!(k_init, k_resp);
403    }
404
405    #[test]
406    fn hybrid_initiator_rejects_bad_ek_length() {
407        let alice = IdentityKeys::generate().unwrap();
408        let bob = IdentityKeys::generate().unwrap();
409        let r = derive_dm_key_hybrid_initiator(
410            &alice.secret_bytes(),
411            &bob.public_bytes(),
412            &[0u8; 16], // wrong ek length
413            "room",
414        );
415        assert!(r.is_err());
416    }
417
418    // ---- huddle 2.0: classical-fallback downgrade guard ----
419
420    #[test]
421    fn refuses_classical_only_for_known_capable_peer_without_ek() {
422        // The single dangerous combination: peer is known PQ-capable but no
423        // ML-KEM key is currently available to build the hybrid key from.
424        assert!(must_refuse_classical_fallback(true, false));
425    }
426
427    #[test]
428    fn allows_classical_when_peer_not_known_capable() {
429        // A genuine pre-1.3 / classical-only peer: classical is the correct key.
430        assert!(!must_refuse_classical_fallback(false, false));
431        assert!(!must_refuse_classical_fallback(false, true));
432    }
433
434    #[test]
435    fn does_not_refuse_when_ek_is_available() {
436        // Capable peer *with* an ek isn't refused here — the caller will derive
437        // the hybrid key instead; this guard only blocks the classical fallback.
438        assert!(!must_refuse_classical_fallback(true, true));
439    }
440}