Skip to main content

huddle_core/crypto/
mod.rs

1pub mod megolm;
2pub mod passphrase;
3pub mod sas;
4
5pub use megolm::RoomCrypto;
6
7use base64::engine::general_purpose::STANDARD as B64;
8use base64::Engine;
9use ed25519_dalek::{Signature, Verifier, VerifyingKey};
10
11use crate::error::{HuddleError, Result};
12use crate::identity::compute_fingerprint;
13use crate::network::protocol::{RoomMessage, SignedRoomMessage};
14
15/// Verify a `SignedRoomMessage` envelope:
16/// 1. The asserted `fingerprint` must equal the fingerprint derived from
17///    `ed25519_pubkey_b64` — closes the "claim someone else's fingerprint
18///    but sign with your own key" attack.
19/// 2. The Ed25519 signature must verify over the decoded `payload_b64`.
20/// 3. The payload must deserialize as a `RoomMessage`.
21///
22/// Returns the inner message and the (verified) sender fingerprint on
23/// success. Caller should still check that the fingerprint is one they
24/// expect for this context (e.g. an owner for `BanMember`).
25pub fn verify_signed(env: &SignedRoomMessage) -> Result<(RoomMessage, String)> {
26    let pubkey_bytes = B64
27        .decode(&env.ed25519_pubkey_b64)
28        .map_err(|e| HuddleError::Session(format!("bad pubkey_b64: {e}")))?;
29    if pubkey_bytes.len() != 32 {
30        return Err(HuddleError::Session(format!(
31            "pubkey is {} bytes, expected 32",
32            pubkey_bytes.len()
33        )));
34    }
35    let mut pk_arr = [0u8; 32];
36    pk_arr.copy_from_slice(&pubkey_bytes);
37
38    let derived_fp = compute_fingerprint(&pk_arr);
39    if derived_fp != env.fingerprint {
40        return Err(HuddleError::Session(format!(
41            "fingerprint mismatch: envelope claims {}, key derives {}",
42            env.fingerprint, derived_fp
43        )));
44    }
45
46    let payload = B64
47        .decode(&env.payload_b64)
48        .map_err(|e| HuddleError::Session(format!("bad payload_b64: {e}")))?;
49    let sig_bytes = B64
50        .decode(&env.signature_b64)
51        .map_err(|e| HuddleError::Session(format!("bad signature_b64: {e}")))?;
52    if sig_bytes.len() != 64 {
53        return Err(HuddleError::Session(format!(
54            "signature is {} bytes, expected 64",
55            sig_bytes.len()
56        )));
57    }
58    let mut sig_arr = [0u8; 64];
59    sig_arr.copy_from_slice(&sig_bytes);
60    let signature = Signature::from_bytes(&sig_arr);
61
62    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
63        .map_err(|e| HuddleError::Session(format!("bad verifying key: {e}")))?;
64    verifying_key
65        .verify(&payload, &signature)
66        .map_err(|e| HuddleError::Session(format!("signature verify failed: {e}")))?;
67
68    let msg: RoomMessage = serde_json::from_slice(&payload)
69        .map_err(|e| HuddleError::Session(format!("bad payload json: {e}")))?;
70    Ok((msg, derived_fp))
71}
72
73/// Wrap a `RoomMessage` into a `SignedRoomMessage` using the given
74/// identity's signing key. Mirror of `verify_signed`; symmetric helper
75/// so phase B/F/G/etc. don't each open-code the base64 dance.
76pub fn sign_message(
77    identity: &crate::identity::Identity,
78    msg: &RoomMessage,
79) -> Result<SignedRoomMessage> {
80    let payload = serde_json::to_vec(msg)
81        .map_err(|e| HuddleError::Session(format!("encode payload: {e}")))?;
82    let sig = identity.sign(&payload);
83    Ok(SignedRoomMessage {
84        fingerprint: identity.fingerprint().to_string(),
85        ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
86        payload_b64: B64.encode(&payload),
87        signature_b64: B64.encode(sig),
88    })
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::identity::Identity;
95
96    fn sample_msg() -> RoomMessage {
97        RoomMessage::MemberLeave {
98            sender_fingerprint: "test-fp".into(),
99        }
100    }
101
102    #[test]
103    fn sign_verify_round_trip() {
104        let id = Identity::generate().unwrap();
105        let env = sign_message(&id, &sample_msg()).unwrap();
106        let (msg, fp) = verify_signed(&env).unwrap();
107        assert_eq!(fp, id.fingerprint());
108        assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
109    }
110
111    #[test]
112    fn tampered_payload_fails() {
113        let id = Identity::generate().unwrap();
114        let mut env = sign_message(&id, &sample_msg()).unwrap();
115        // Re-encode a different message; signature was over the original.
116        let other = serde_json::to_vec(&RoomMessage::Typing {
117            sender_fingerprint: "evil-fp".into(),
118        })
119        .unwrap();
120        env.payload_b64 = B64.encode(&other);
121        assert!(verify_signed(&env).is_err());
122    }
123
124    #[test]
125    fn fingerprint_pubkey_mismatch_fails() {
126        let alice = Identity::generate().unwrap();
127        let bob = Identity::generate().unwrap();
128        let mut env = sign_message(&alice, &sample_msg()).unwrap();
129        // Substitute bob's fingerprint — derived fp from alice's pubkey
130        // won't match, so this must reject before the signature check.
131        env.fingerprint = bob.fingerprint().to_string();
132        assert!(verify_signed(&env).is_err());
133    }
134
135    #[test]
136    fn swapped_pubkey_fails_signature() {
137        let alice = Identity::generate().unwrap();
138        let bob = Identity::generate().unwrap();
139        let mut env = sign_message(&alice, &sample_msg()).unwrap();
140        // Substitute bob's pubkey + fingerprint together: derived check
141        // passes, but the signature was made with alice's key and won't
142        // verify under bob's pubkey.
143        env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
144        env.fingerprint = bob.fingerprint().to_string();
145        assert!(verify_signed(&env).is_err());
146    }
147}