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    // huddle 1.2: the replay-window check moved to AFTER cryptographic
56    // verification (below) so we know the message TYPE and can exempt store-
57    // and-forward control messages from the wall-clock window.
58
59    let pubkey_bytes = B64
60        .decode(&env.ed25519_pubkey_b64)
61        .map_err(|e| HuddleError::Session(format!("bad pubkey_b64: {e}")))?;
62    if pubkey_bytes.len() != 32 {
63        return Err(HuddleError::Session(format!(
64            "pubkey is {} bytes, expected 32",
65            pubkey_bytes.len()
66        )));
67    }
68    let mut pk_arr = [0u8; 32];
69    pk_arr.copy_from_slice(&pubkey_bytes);
70
71    let derived_fp = compute_fingerprint(&pk_arr);
72    if derived_fp != env.fingerprint {
73        return Err(HuddleError::Session(format!(
74            "fingerprint mismatch: envelope claims {}, key derives {}",
75            env.fingerprint, derived_fp
76        )));
77    }
78
79    let payload = B64
80        .decode(&env.payload_b64)
81        .map_err(|e| HuddleError::Session(format!("bad payload_b64: {e}")))?;
82    let sig_bytes = B64
83        .decode(&env.signature_b64)
84        .map_err(|e| HuddleError::Session(format!("bad signature_b64: {e}")))?;
85    if sig_bytes.len() != 64 {
86        return Err(HuddleError::Session(format!(
87            "signature is {} bytes, expected 64",
88            sig_bytes.len()
89        )));
90    }
91    let mut sig_arr = [0u8; 64];
92    sig_arr.copy_from_slice(&sig_bytes);
93    let signature = Signature::from_bytes(&sig_arr);
94
95    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
96        .map_err(|e| HuddleError::Session(format!("bad verifying key: {e}")))?;
97    // huddle 0.7.11: verify_strict rejects mixed-order / low-order
98    // pubkeys and is the recommended call per ed25519_dalek's docs.
99    // The signature is also bound to the `signed_at_ms` timestamp via
100    // the payload bytes (the payload deserializes to a RoomMessage,
101    // but the signature was over the raw payload + timestamp, so any
102    // tampering of either field invalidates verification).
103    verifying_key
104        .verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
105        .map_err(|e| HuddleError::Session(format!("signature verify failed: {e}")))?;
106
107    let msg: RoomMessage = serde_json::from_slice(&payload)
108        .map_err(|e| HuddleError::Session(format!("bad payload json: {e}")))?;
109
110    // huddle 1.2: apply the wall-clock replay window SELECTIVELY. Store-and-
111    // forward, idempotent control messages — ContactRequest, MemberAnnounce,
112    // SessionKeyRequest — are EXEMPT: they ride the relay's offline mailbox,
113    // which can hold them for hours or days until the recipient reconnects, so
114    // a ±5-minute window would silently drop legitimate first-contact requests
115    // and first key-exchange announces (precisely the "I added them but no
116    // chat ever appears" failure). Their replay protection is idempotency
117    // (re-applying them is a no-op) plus the signature, which still proves the
118    // sender's identity and is verified above regardless of type. Every other
119    // signed type keeps the strict window.
120    if window_applies(&msg) && (now_ms - env.signed_at_ms).abs() > window_ms {
121        return Err(HuddleError::Session(format!(
122            "signed envelope timestamp {} is outside the ±{}ms window vs now {}",
123            env.signed_at_ms, window_ms, now_ms
124        )));
125    }
126    Ok((msg, derived_fp))
127}
128
129/// huddle 1.2: whether the wall-clock replay window applies to a signed
130/// message of this type. Store-and-forward, idempotent control messages are
131/// exempt because they legitimately arrive long after they were signed, via
132/// the relay's offline mailbox. All other signed types keep the strict window.
133fn window_applies(msg: &RoomMessage) -> bool {
134    !matches!(
135        msg,
136        RoomMessage::ContactRequest { .. }
137            | RoomMessage::MemberAnnounce { .. }
138            | RoomMessage::SessionKeyRequest { .. }
139    )
140}
141
142/// Wrap a `RoomMessage` into a `SignedRoomMessage` using the given
143/// identity's signing key. Mirror of `verify_signed`; symmetric helper
144/// so phase B/F/G/etc. don't each open-code the base64 dance.
145///
146/// huddle 0.7.11: also populates `signed_at_ms` with the current epoch
147/// in milliseconds, and signs over (payload || signed_at_ms) so the
148/// receiver's replay-window check is signature-bound.
149pub fn sign_message(
150    identity: &crate::identity::Identity,
151    msg: &RoomMessage,
152) -> Result<SignedRoomMessage> {
153    sign_message_at(identity, msg, now_unix_ms())
154}
155
156/// Same as `sign_message` but with an explicit timestamp — used by the
157/// replay-window unit tests so the clock isn't a hidden dependency.
158pub fn sign_message_at(
159    identity: &crate::identity::Identity,
160    msg: &RoomMessage,
161    signed_at_ms: i64,
162) -> Result<SignedRoomMessage> {
163    let payload = serde_json::to_vec(msg)
164        .map_err(|e| HuddleError::Session(format!("encode payload: {e}")))?;
165    let sig = identity.sign(&signed_bytes(&payload, signed_at_ms));
166    Ok(SignedRoomMessage {
167        fingerprint: identity.fingerprint().to_string(),
168        ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
169        payload_b64: B64.encode(&payload),
170        signature_b64: B64.encode(sig),
171        signed_at_ms,
172    })
173}
174
175/// Canonical bytes the signature commits to: the raw RoomMessage JSON
176/// followed by a domain-separator and the big-endian timestamp. Putting
177/// the timestamp inside the signed bytes means a replayer can't change
178/// it without invalidating the signature.
179fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
180    let mut out = Vec::with_capacity(payload.len() + 24);
181    out.extend_from_slice(payload);
182    out.extend_from_slice(b"|huddle-signed-v1|");
183    out.extend_from_slice(&signed_at_ms.to_be_bytes());
184    out
185}
186
187fn now_unix_ms() -> i64 {
188    SystemTime::now()
189        .duration_since(UNIX_EPOCH)
190        .map(|d| d.as_millis() as i64)
191        .unwrap_or(0)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::identity::Identity;
198
199    fn sample_msg() -> RoomMessage {
200        RoomMessage::MemberLeave {
201            sender_fingerprint: "test-fp".into(),
202        }
203    }
204
205    #[test]
206    fn sign_verify_round_trip() {
207        let id = Identity::generate().unwrap();
208        let env = sign_message(&id, &sample_msg()).unwrap();
209        let (msg, fp) = verify_signed(&env).unwrap();
210        assert_eq!(fp, id.fingerprint());
211        assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
212    }
213
214    #[test]
215    fn tampered_payload_fails() {
216        let id = Identity::generate().unwrap();
217        let mut env = sign_message(&id, &sample_msg()).unwrap();
218        let other = serde_json::to_vec(&RoomMessage::Typing {
219            sender_fingerprint: "evil-fp".into(),
220        })
221        .unwrap();
222        env.payload_b64 = B64.encode(&other);
223        assert!(verify_signed(&env).is_err());
224    }
225
226    #[test]
227    fn tampered_timestamp_fails_signature() {
228        // huddle 0.7.11: the signed bytes commit to signed_at_ms, so a
229        // replayer who rewrites the timestamp to bring it back inside
230        // the window invalidates the signature.
231        let id = Identity::generate().unwrap();
232        let now_ms = 1_700_000_000_000_i64;
233        let mut env = sign_message_at(&id, &sample_msg(), now_ms).unwrap();
234        env.signed_at_ms = now_ms + 1;
235        let err = verify_signed_at(&env, now_ms, SIGNED_ENVELOPE_WINDOW_MS).unwrap_err();
236        let s = format!("{err}");
237        assert!(s.contains("signature verify failed"), "got: {s}");
238    }
239
240    #[test]
241    fn fingerprint_pubkey_mismatch_fails() {
242        let alice = Identity::generate().unwrap();
243        let bob = Identity::generate().unwrap();
244        let mut env = sign_message(&alice, &sample_msg()).unwrap();
245        env.fingerprint = bob.fingerprint().to_string();
246        assert!(verify_signed(&env).is_err());
247    }
248
249    #[test]
250    fn swapped_pubkey_fails_signature() {
251        let alice = Identity::generate().unwrap();
252        let bob = Identity::generate().unwrap();
253        let mut env = sign_message(&alice, &sample_msg()).unwrap();
254        env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
255        env.fingerprint = bob.fingerprint().to_string();
256        assert!(verify_signed(&env).is_err());
257    }
258
259    #[test]
260    fn missing_timestamp_rejected() {
261        // huddle 0.7.11: signed_at_ms == 0 is the serde default, which
262        // we use as the "legacy pre-replay-protection" sentinel and
263        // reject outright.
264        let id = Identity::generate().unwrap();
265        let mut env = sign_message(&id, &sample_msg()).unwrap();
266        env.signed_at_ms = 0;
267        assert!(verify_signed(&env).is_err());
268    }
269
270    #[test]
271    fn outside_window_rejected() {
272        let id = Identity::generate().unwrap();
273        let signed_at = 1_700_000_000_000_i64;
274        let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
275        // 6 minutes later — outside the default 5 min window.
276        let now = signed_at + 6 * 60 * 1000;
277        assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
278        // Inside the window: ok.
279        let now = signed_at + 4 * 60 * 1000;
280        assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok());
281    }
282
283    #[test]
284    fn store_and_forward_types_exempt_from_window() {
285        // huddle 1.2: ContactRequest and MemberAnnounce ride the relay's
286        // offline mailbox and may legitimately arrive days later. They MUST
287        // verify even far outside the wall-clock window (signature still
288        // proves identity); otherwise offline first-contact + first key
289        // exchange silently fail and "no chat ever appears".
290        let id = Identity::generate().unwrap();
291        let signed_at = 1_700_000_000_000_i64;
292        let now = signed_at + 30 * 24 * 60 * 60 * 1000; // 30 days later
293
294        let contact = RoomMessage::ContactRequest {
295            requester_fingerprint: id.fingerprint().to_string(),
296            display_name: Some("late arrival".into()),
297            note: None,
298            sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
299        };
300        let env = sign_message_at(&id, &contact, signed_at).unwrap();
301        assert!(
302            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
303            "a mailboxed ContactRequest must verify regardless of age"
304        );
305
306        let announce = RoomMessage::MemberAnnounce {
307            sender_fingerprint: id.fingerprint().to_string(),
308            wrapped_session_key: Some("d2VsbA==".into()),
309            display_name: None,
310            sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
311        };
312        let env = sign_message_at(&id, &announce, signed_at).unwrap();
313        assert!(
314            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
315            "a mailboxed MemberAnnounce (carries the session key) must verify regardless of age"
316        );
317
318        // A non-exempt type (MemberLeave) still honors the window.
319        let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
320        assert!(
321            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
322            "non-store-and-forward types must still be window-checked"
323        );
324
325        // Tampering still fails for exempt types — signature is verified
326        // regardless of the window exemption.
327        let mut bad = sign_message_at(&id, &contact, signed_at).unwrap();
328        bad.signature_b64 = B64.encode([0u8; 64]);
329        assert!(
330            verify_signed_at(&bad, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
331            "an exempt type with a bad signature must still be rejected"
332        );
333    }
334}