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 argon2::{Algorithm, Argon2, Version};
12use hkdf::Hkdf;
13use sha2::{Digest, Sha256};
14use x25519_dalek::{PublicKey, StaticSecret};
15
16use crate::crypto::passphrase::{argon2id_params, KEY_LEN};
17use crate::error::{ProtocolError, Result};
18
19/// HKDF info tag for the code-join wrap key. Part of the wire contract — both
20/// peers must use the same tag or the joiner can't unwrap.
21const CODE_JOIN_INFO: &[u8] = b"huddle-code-join-v1";
22
23/// Derive the 32-byte wrap key both sides compute: `HKDF-SHA256` over the raw
24/// X25519 ECDH shared secret of `our_secret` and `their_pub`. The owner uses it
25/// to `passphrase::wrap` the session key; the joiner uses it to `unwrap`.
26pub fn derive_wrap_key(our_secret: &StaticSecret, their_pub: &PublicKey) -> Result<[u8; KEY_LEN]> {
27    let shared = our_secret.diffie_hellman(their_pub);
28    // huddle 2.1.2 (audit CR-1): reject a non-contributory (small-order) peer
29    // pubkey, the same defense-in-depth check the DM (`dm.rs`) and SAS
30    // (`sas.rs`) ECDH paths already perform. Two honest peers always produce a
31    // contributory secret, so this never rejects a real code-join.
32    if !shared.was_contributory() {
33        return Err(ProtocolError::Session(
34            "code-join key agreement rejected: peer X25519 pubkey is non-contributory \
35             (small-order point)"
36                .into(),
37        ));
38    }
39    let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
40    let mut wrap_key = [0u8; KEY_LEN];
41    hk.expand(CODE_JOIN_INFO, &mut wrap_key)
42        .expect("32 bytes is within HKDF-SHA256's output limit");
43    Ok(wrap_key)
44}
45
46/// Domain tag for the code-join proof-of-knowledge salt. Part of the wire
47/// contract (huddle 2.2 / audit PA-1): both sides derive the salt the same way.
48const CODE_PROOF_INFO: &[u8] = b"huddle-code-join-proof-v2";
49
50/// huddle 2.2 (audit PA-1): the marker a v2 owner prepends to every join code it
51/// issues. This is the **out-of-band capability anchor** that defeats a relay
52/// downgrade: the owner hands the joiner the code over a channel the relay does
53/// not control (Signal, in person, a QR), so the relay cannot strip this marker
54/// the way it can strip a `capabilities` field from a network announcement. A
55/// joiner that sees the marker sends the proof form **unconditionally** — it
56/// never consults the relay-mediated capability for the code-join decision — so
57/// the relay can no longer force the cleartext fallback. The marker begins with
58/// a lowercase letter, which the join-code alphabet (`ABCDEFGHJKMNPQRSTUVWXYZ23456789`,
59/// uppercase-only) never produces, so a legacy code can never be mistaken for a
60/// v2 one. The proof is computed over the FULL code string, marker included.
61pub const CODE_JOIN_V2_PREFIX: &str = "v2-";
62
63/// huddle 2.2 (audit PA-1): derive a memory-hard *proof of knowledge* of the
64/// join `code`, bound to the room and the joiner's ephemeral X25519 public key.
65///
66/// This replaces putting the cleartext bearer code on the (relay-readable) room
67/// topic. A malicious relay that captures a proof cannot rebind it to its own
68/// forged ephemeral key — that would require recomputing `Argon2id(code, …new
69/// pubkey…)`, i.e. knowing the code — and cannot brute-force the ~40-bit code
70/// out of the proof: the salt is unique per (room, ephemeral) so there is no
71/// precomputation, and a single 64 MiB Argon2id guess over a 10-minute,
72/// single-use code window is infeasible at any plausible attacker bandwidth.
73///
74/// Both the joiner (to build the request) and the owner (to verify it) call
75/// this with the SAME `joiner_x25519_pub` — the 32 raw bytes of the ephemeral
76/// key the joiner put in the request.
77pub fn derive_code_proof(
78    code: &str,
79    room_id: &str,
80    joiner_x25519_pub: &[u8; 32],
81) -> Result<[u8; 32]> {
82    // Salt = SHA256(domain || room_id || joiner_pubkey)[..16]. Binding the
83    // ephemeral pubkey is what stops the relay's key-substitution; binding
84    // room_id stops cross-room proof replay. Argon2id requires salt >= 8 bytes.
85    let mut h = Sha256::new();
86    h.update(CODE_PROOF_INFO);
87    h.update(room_id.as_bytes());
88    h.update(joiner_x25519_pub);
89    let digest = h.finalize();
90    let salt = &digest[..16];
91
92    let params = argon2id_params(32)?;
93    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
94    let mut out = [0u8; 32];
95    argon
96        .hash_password_into(code.as_bytes(), salt, &mut out)
97        .map_err(|e| ProtocolError::Session(format!("code-proof argon2: {e}")))?;
98    Ok(out)
99}
100
101/// huddle 2.2 (audit PA-1): constant-time check that `proof` is the proof for
102/// `expected_code` under this (`room_id`, `joiner_x25519_pub`). The owner calls
103/// this for each unexpired issued code; the comparison is constant-time so a
104/// timing side-channel can't leak how many proof bytes matched.
105pub fn verify_code_proof(
106    expected_code: &str,
107    room_id: &str,
108    joiner_x25519_pub: &[u8; 32],
109    proof: &[u8; 32],
110) -> Result<bool> {
111    let recomputed = derive_code_proof(expected_code, room_id, joiner_x25519_pub)?;
112    Ok(ct_eq_32(&recomputed, proof))
113}
114
115/// Constant-time equality for two 32-byte arrays — no early exit, so the time
116/// taken doesn't depend on where the first differing byte is. (Local helper to
117/// avoid pulling `subtle` as a direct dependency of this runtime-free crate.)
118#[inline]
119fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
120    let mut diff = 0u8;
121    for i in 0..32 {
122        diff |= a[i] ^ b[i];
123    }
124    diff == 0
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use rand::rngs::OsRng;
131
132    #[test]
133    fn both_sides_derive_the_same_wrap_key() {
134        let owner = StaticSecret::random_from_rng(OsRng);
135        let joiner = StaticSecret::random_from_rng(OsRng);
136        let owner_pub = PublicKey::from(&owner);
137        let joiner_pub = PublicKey::from(&joiner);
138        // Owner derives against the joiner's pubkey; joiner against the owner's.
139        let k_owner = derive_wrap_key(&owner, &joiner_pub).unwrap();
140        let k_joiner = derive_wrap_key(&joiner, &owner_pub).unwrap();
141        assert_eq!(k_owner, k_joiner, "ECDH is commutative -> same wrap key");
142    }
143
144    #[test]
145    fn different_peers_derive_different_keys() {
146        let owner = StaticSecret::random_from_rng(OsRng);
147        let a = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
148        let b = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
149        assert_ne!(
150            derive_wrap_key(&owner, &a).unwrap(),
151            derive_wrap_key(&owner, &b).unwrap()
152        );
153    }
154
155    #[test]
156    fn code_proof_round_trips_for_the_right_code_and_pubkey() {
157        let joiner = StaticSecret::random_from_rng(OsRng);
158        let joiner_pub = PublicKey::from(&joiner);
159        let proof = derive_code_proof("ABCD-2345", "room-x", joiner_pub.as_bytes()).unwrap();
160        assert!(
161            verify_code_proof("ABCD-2345", "room-x", joiner_pub.as_bytes(), &proof).unwrap(),
162            "the issuing owner re-derives the same proof from the code it handed out"
163        );
164    }
165
166    #[test]
167    fn code_proof_rejects_wrong_code() {
168        let joiner_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
169        let proof = derive_code_proof("ABCD-2345", "room-x", joiner_pub.as_bytes()).unwrap();
170        assert!(!verify_code_proof("WXYZ-9876", "room-x", joiner_pub.as_bytes(), &proof).unwrap());
171    }
172
173    #[test]
174    fn code_proof_is_bound_to_the_joiner_ephemeral_key() {
175        // The PA-1 attack: a relay tries to rebind a captured proof to its OWN
176        // ephemeral pubkey. The proof must not verify under a different pubkey.
177        let real_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
178        let relay_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
179        let proof = derive_code_proof("ABCD-2345", "room-x", real_pub.as_bytes()).unwrap();
180        assert!(
181            !verify_code_proof("ABCD-2345", "room-x", relay_pub.as_bytes(), &proof).unwrap(),
182            "a proof for the real joiner's key must fail under the relay's substituted key"
183        );
184    }
185
186    #[test]
187    fn v2_prefix_cannot_collide_with_a_legacy_code() {
188        // The OOB downgrade-defense relies on a legacy code never being mistaken
189        // for a v2 one. Legacy codes come from the uppercase-only alphabet
190        // `ABCDEFGHJKMNPQRSTUVWXYZ23456789` with '-' only at index 4; the marker
191        // leads with a lowercase letter that alphabet never emits.
192        const LEGACY_ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
193        let first = CODE_JOIN_V2_PREFIX.as_bytes()[0];
194        assert!((first as char).is_ascii_lowercase());
195        assert!(!LEGACY_ALPHABET.contains(&first));
196        assert!(!"ABCD-2345".starts_with(CODE_JOIN_V2_PREFIX));
197        assert!("v2-ABCD-2345".starts_with(CODE_JOIN_V2_PREFIX));
198    }
199
200    #[test]
201    fn code_proof_is_bound_to_the_room() {
202        let joiner_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
203        let proof = derive_code_proof("ABCD-2345", "room-a", joiner_pub.as_bytes()).unwrap();
204        assert!(
205            !verify_code_proof("ABCD-2345", "room-b", joiner_pub.as_bytes(), &proof).unwrap(),
206            "no cross-room proof replay"
207        );
208    }
209
210    #[test]
211    fn rejects_small_order_peer_pubkey() {
212        // huddle 2.1.2 (audit CR-1): a small-order Montgomery point yields a
213        // non-contributory shared secret and must be rejected.
214        let owner = StaticSecret::random_from_rng(OsRng);
215        // All-zero is the canonical small-order (identity) X25519 point.
216        let small_order = PublicKey::from([0u8; 32]);
217        assert!(derive_wrap_key(&owner, &small_order).is_err());
218    }
219}