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 std::time::{SystemTime, UNIX_EPOCH};
9
10use base64::engine::general_purpose::STANDARD as B64;
11use base64::Engine;
12use ed25519_dalek::{Signature, VerifyingKey};
13
14use crate::error::{HuddleError, Result};
15use crate::identity::compute_fingerprint;
16use crate::network::protocol::{RoomMessage, SignedRoomMessage};
17
18/// huddle 0.7.11: max accepted skew between `signed_at_ms` on a signed
19/// envelope and the receiver's wall clock. Anything outside the window
20/// is rejected as a replay (or as a clock that's drifted too far).
21pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
22
23/// Verify a `SignedRoomMessage` envelope:
24/// 1. The asserted `fingerprint` must equal the fingerprint derived from
25///    `ed25519_pubkey_b64` — closes the "claim someone else's fingerprint
26///    but sign with your own key" attack.
27/// 2. The Ed25519 signature must `verify_strict` over the decoded
28///    `payload_b64` (strict rejects low-order / mixed-order pubkeys).
29/// 3. The payload must deserialize as a `RoomMessage`.
30/// 4. huddle 0.7.11: `signed_at_ms` must be non-zero and within
31///    `SIGNED_ENVELOPE_WINDOW_MS` of the receiver's wall clock — closes
32///    indefinite replay of captured signed messages.
33///
34/// Returns the inner message and the (verified) sender fingerprint on
35/// success. Caller should still check that the fingerprint is one they
36/// expect for this context (e.g. an owner for `BanMember`).
37pub fn verify_signed(env: &SignedRoomMessage) -> Result<(RoomMessage, String)> {
38    let now_ms = now_unix_ms();
39    verify_signed_at(env, now_ms, SIGNED_ENVELOPE_WINDOW_MS)
40}
41
42/// Same as `verify_signed` but with an explicit clock and window —
43/// kept public for tests that want to exercise the replay-window logic
44/// deterministically without a SystemTime detour.
45pub fn verify_signed_at(
46    env: &SignedRoomMessage,
47    now_ms: i64,
48    window_ms: i64,
49) -> Result<(RoomMessage, String)> {
50    if env.signed_at_ms == 0 {
51        return Err(HuddleError::Session(
52            "signed envelope is missing signed_at_ms — pre-0.7.11 sender or forgery".into(),
53        ));
54    }
55    if (now_ms - env.signed_at_ms).abs() > window_ms {
56        return Err(HuddleError::Session(format!(
57            "signed envelope timestamp {} is outside the ±{}ms window vs now {}",
58            env.signed_at_ms, window_ms, now_ms
59        )));
60    }
61
62    let pubkey_bytes = B64
63        .decode(&env.ed25519_pubkey_b64)
64        .map_err(|e| HuddleError::Session(format!("bad pubkey_b64: {e}")))?;
65    if pubkey_bytes.len() != 32 {
66        return Err(HuddleError::Session(format!(
67            "pubkey is {} bytes, expected 32",
68            pubkey_bytes.len()
69        )));
70    }
71    let mut pk_arr = [0u8; 32];
72    pk_arr.copy_from_slice(&pubkey_bytes);
73
74    let derived_fp = compute_fingerprint(&pk_arr);
75    if derived_fp != env.fingerprint {
76        return Err(HuddleError::Session(format!(
77            "fingerprint mismatch: envelope claims {}, key derives {}",
78            env.fingerprint, derived_fp
79        )));
80    }
81
82    let payload = B64
83        .decode(&env.payload_b64)
84        .map_err(|e| HuddleError::Session(format!("bad payload_b64: {e}")))?;
85    let sig_bytes = B64
86        .decode(&env.signature_b64)
87        .map_err(|e| HuddleError::Session(format!("bad signature_b64: {e}")))?;
88    if sig_bytes.len() != 64 {
89        return Err(HuddleError::Session(format!(
90            "signature is {} bytes, expected 64",
91            sig_bytes.len()
92        )));
93    }
94    let mut sig_arr = [0u8; 64];
95    sig_arr.copy_from_slice(&sig_bytes);
96    let signature = Signature::from_bytes(&sig_arr);
97
98    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
99        .map_err(|e| HuddleError::Session(format!("bad verifying key: {e}")))?;
100    // huddle 0.7.11: verify_strict rejects mixed-order / low-order
101    // pubkeys and is the recommended call per ed25519_dalek's docs.
102    // The signature is also bound to the `signed_at_ms` timestamp via
103    // the payload bytes (the payload deserializes to a RoomMessage,
104    // but the signature was over the raw payload + timestamp, so any
105    // tampering of either field invalidates verification).
106    verifying_key
107        .verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
108        .map_err(|e| HuddleError::Session(format!("signature verify failed: {e}")))?;
109
110    let msg: RoomMessage = serde_json::from_slice(&payload)
111        .map_err(|e| HuddleError::Session(format!("bad payload json: {e}")))?;
112    Ok((msg, derived_fp))
113}
114
115/// Wrap a `RoomMessage` into a `SignedRoomMessage` using the given
116/// identity's signing key. Mirror of `verify_signed`; symmetric helper
117/// so phase B/F/G/etc. don't each open-code the base64 dance.
118///
119/// huddle 0.7.11: also populates `signed_at_ms` with the current epoch
120/// in milliseconds, and signs over (payload || signed_at_ms) so the
121/// receiver's replay-window check is signature-bound.
122pub fn sign_message(
123    identity: &crate::identity::Identity,
124    msg: &RoomMessage,
125) -> Result<SignedRoomMessage> {
126    sign_message_at(identity, msg, now_unix_ms())
127}
128
129/// Same as `sign_message` but with an explicit timestamp — used by the
130/// replay-window unit tests so the clock isn't a hidden dependency.
131pub fn sign_message_at(
132    identity: &crate::identity::Identity,
133    msg: &RoomMessage,
134    signed_at_ms: i64,
135) -> Result<SignedRoomMessage> {
136    let payload = serde_json::to_vec(msg)
137        .map_err(|e| HuddleError::Session(format!("encode payload: {e}")))?;
138    let sig = identity.sign(&signed_bytes(&payload, signed_at_ms));
139    Ok(SignedRoomMessage {
140        fingerprint: identity.fingerprint().to_string(),
141        ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
142        payload_b64: B64.encode(&payload),
143        signature_b64: B64.encode(sig),
144        signed_at_ms,
145    })
146}
147
148/// Canonical bytes the signature commits to: the raw RoomMessage JSON
149/// followed by a domain-separator and the big-endian timestamp. Putting
150/// the timestamp inside the signed bytes means a replayer can't change
151/// it without invalidating the signature.
152fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
153    let mut out = Vec::with_capacity(payload.len() + 24);
154    out.extend_from_slice(payload);
155    out.extend_from_slice(b"|huddle-signed-v1|");
156    out.extend_from_slice(&signed_at_ms.to_be_bytes());
157    out
158}
159
160fn now_unix_ms() -> i64 {
161    SystemTime::now()
162        .duration_since(UNIX_EPOCH)
163        .map(|d| d.as_millis() as i64)
164        .unwrap_or(0)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::identity::Identity;
171
172    fn sample_msg() -> RoomMessage {
173        RoomMessage::MemberLeave {
174            sender_fingerprint: "test-fp".into(),
175        }
176    }
177
178    #[test]
179    fn sign_verify_round_trip() {
180        let id = Identity::generate().unwrap();
181        let env = sign_message(&id, &sample_msg()).unwrap();
182        let (msg, fp) = verify_signed(&env).unwrap();
183        assert_eq!(fp, id.fingerprint());
184        assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
185    }
186
187    #[test]
188    fn tampered_payload_fails() {
189        let id = Identity::generate().unwrap();
190        let mut env = sign_message(&id, &sample_msg()).unwrap();
191        let other = serde_json::to_vec(&RoomMessage::Typing {
192            sender_fingerprint: "evil-fp".into(),
193        })
194        .unwrap();
195        env.payload_b64 = B64.encode(&other);
196        assert!(verify_signed(&env).is_err());
197    }
198
199    #[test]
200    fn tampered_timestamp_fails_signature() {
201        // huddle 0.7.11: the signed bytes commit to signed_at_ms, so a
202        // replayer who rewrites the timestamp to bring it back inside
203        // the window invalidates the signature.
204        let id = Identity::generate().unwrap();
205        let now_ms = 1_700_000_000_000_i64;
206        let mut env = sign_message_at(&id, &sample_msg(), now_ms).unwrap();
207        env.signed_at_ms = now_ms + 1;
208        let err = verify_signed_at(&env, now_ms, SIGNED_ENVELOPE_WINDOW_MS).unwrap_err();
209        let s = format!("{err}");
210        assert!(s.contains("signature verify failed"), "got: {s}");
211    }
212
213    #[test]
214    fn fingerprint_pubkey_mismatch_fails() {
215        let alice = Identity::generate().unwrap();
216        let bob = Identity::generate().unwrap();
217        let mut env = sign_message(&alice, &sample_msg()).unwrap();
218        env.fingerprint = bob.fingerprint().to_string();
219        assert!(verify_signed(&env).is_err());
220    }
221
222    #[test]
223    fn swapped_pubkey_fails_signature() {
224        let alice = Identity::generate().unwrap();
225        let bob = Identity::generate().unwrap();
226        let mut env = sign_message(&alice, &sample_msg()).unwrap();
227        env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
228        env.fingerprint = bob.fingerprint().to_string();
229        assert!(verify_signed(&env).is_err());
230    }
231
232    #[test]
233    fn missing_timestamp_rejected() {
234        // huddle 0.7.11: signed_at_ms == 0 is the serde default, which
235        // we use as the "legacy pre-replay-protection" sentinel and
236        // reject outright.
237        let id = Identity::generate().unwrap();
238        let mut env = sign_message(&id, &sample_msg()).unwrap();
239        env.signed_at_ms = 0;
240        assert!(verify_signed(&env).is_err());
241    }
242
243    #[test]
244    fn outside_window_rejected() {
245        let id = Identity::generate().unwrap();
246        let signed_at = 1_700_000_000_000_i64;
247        let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
248        // 6 minutes later — outside the default 5 min window.
249        let now = signed_at + 6 * 60 * 1000;
250        assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
251        // Inside the window: ok.
252        let now = signed_at + 4 * 60 * 1000;
253        assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok());
254    }
255}