Skip to main content

huddle_protocol/crypto/
code_join.rs

1//! Code-join wrap-key derivation.
2//!
3//! A single-use join code lets an owner hand a room's Megolm session key to a
4//! read-only joiner without the passphrase: the joiner sends an ephemeral
5//! X25519 public key, the owner ECDHs against it and wraps the session key under
6//! an HKDF-derived key, and the joiner derives the same key to unwrap. Both
7//! sides compute the **identical** wrap key from `derive_wrap_key`; previously
8//! this ECDH+HKDF was open-coded twice in `AppHandle` (the `CodeJoinRequest` and
9//! `CodeJoinResponse` handlers), so it lives here as one tested function.
10
11use hkdf::Hkdf;
12use sha2::Sha256;
13use x25519_dalek::{PublicKey, StaticSecret};
14
15use crate::crypto::passphrase::KEY_LEN;
16use crate::error::{ProtocolError, Result};
17
18/// HKDF info tag for the code-join wrap key. Part of the wire contract — both
19/// peers must use the same tag or the joiner can't unwrap.
20const CODE_JOIN_INFO: &[u8] = b"huddle-code-join-v1";
21
22/// Derive the 32-byte wrap key both sides compute: `HKDF-SHA256` over the raw
23/// X25519 ECDH shared secret of `our_secret` and `their_pub`. The owner uses it
24/// to `passphrase::wrap` the session key; the joiner uses it to `unwrap`.
25pub fn derive_wrap_key(our_secret: &StaticSecret, their_pub: &PublicKey) -> Result<[u8; KEY_LEN]> {
26    let shared = our_secret.diffie_hellman(their_pub);
27    // huddle 2.1.2 (audit CR-1): reject a non-contributory (small-order) peer
28    // pubkey, the same defense-in-depth check the DM (`dm.rs`) and SAS
29    // (`sas.rs`) ECDH paths already perform. Two honest peers always produce a
30    // contributory secret, so this never rejects a real code-join.
31    if !shared.was_contributory() {
32        return Err(ProtocolError::Session(
33            "code-join key agreement rejected: peer X25519 pubkey is non-contributory \
34             (small-order point)"
35                .into(),
36        ));
37    }
38    let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
39    let mut wrap_key = [0u8; KEY_LEN];
40    hk.expand(CODE_JOIN_INFO, &mut wrap_key)
41        .expect("32 bytes is within HKDF-SHA256's output limit");
42    Ok(wrap_key)
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use rand::rngs::OsRng;
49
50    #[test]
51    fn both_sides_derive_the_same_wrap_key() {
52        let owner = StaticSecret::random_from_rng(OsRng);
53        let joiner = StaticSecret::random_from_rng(OsRng);
54        let owner_pub = PublicKey::from(&owner);
55        let joiner_pub = PublicKey::from(&joiner);
56        // Owner derives against the joiner's pubkey; joiner against the owner's.
57        let k_owner = derive_wrap_key(&owner, &joiner_pub).unwrap();
58        let k_joiner = derive_wrap_key(&joiner, &owner_pub).unwrap();
59        assert_eq!(k_owner, k_joiner, "ECDH is commutative -> same wrap key");
60    }
61
62    #[test]
63    fn different_peers_derive_different_keys() {
64        let owner = StaticSecret::random_from_rng(OsRng);
65        let a = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
66        let b = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
67        assert_ne!(
68            derive_wrap_key(&owner, &a).unwrap(),
69            derive_wrap_key(&owner, &b).unwrap()
70        );
71    }
72
73    #[test]
74    fn rejects_small_order_peer_pubkey() {
75        // huddle 2.1.2 (audit CR-1): a small-order Montgomery point yields a
76        // non-contributory shared secret and must be rejected.
77        let owner = StaticSecret::random_from_rng(OsRng);
78        // All-zero is the canonical small-order (identity) X25519 point.
79        let small_order = PublicKey::from([0u8; 32]);
80        assert!(derive_wrap_key(&owner, &small_order).is_err());
81    }
82}