Skip to main content

huddle_core/crypto/
mod.rs

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