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}