Skip to main content

huddle_protocol/crypto/
mod.rs

1pub mod dm;
2pub mod mnemonic;
3pub mod passphrase;
4pub mod pqc;
5pub mod sas;
6
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use base64::engine::general_purpose::STANDARD as B64;
10use base64::Engine;
11use ed25519_dalek::{Signature, VerifyingKey};
12
13use crate::error::{ProtocolError, Result};
14use crate::identity::compute_fingerprint;
15use crate::protocol::{RoomMessage, SignedRoomMessage};
16
17/// huddle 0.7.11: max accepted skew between `signed_at_ms` on a signed
18/// envelope and the receiver's wall clock. Anything outside the window
19/// is rejected as a replay (or as a clock that's drifted too far).
20pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
21
22/// Verify a `SignedRoomMessage` envelope:
23/// 1. The asserted `fingerprint` must equal the fingerprint derived from
24///    `ed25519_pubkey_b64` — closes the "claim someone else's fingerprint
25///    but sign with your own key" attack.
26/// 2. The Ed25519 signature must `verify_strict` over the decoded
27///    `payload_b64` (strict rejects low-order / mixed-order pubkeys).
28/// 3. The payload must deserialize as a `RoomMessage`.
29/// 4. huddle 0.7.11: `signed_at_ms` must be non-zero and within
30///    `SIGNED_ENVELOPE_WINDOW_MS` of the receiver's wall clock — closes
31///    indefinite replay of captured signed messages.
32///
33/// Returns the inner message and the (verified) sender fingerprint on
34/// success. Caller should still check that the fingerprint is one they
35/// expect for this context (e.g. an owner for `BanMember`).
36pub fn verify_signed(env: &SignedRoomMessage) -> Result<(RoomMessage, String)> {
37    let now_ms = now_unix_ms();
38    verify_signed_at(env, now_ms, SIGNED_ENVELOPE_WINDOW_MS)
39}
40
41/// Same as `verify_signed` but with an explicit clock and window —
42/// kept public for tests that want to exercise the replay-window logic
43/// deterministically without a SystemTime detour.
44pub fn verify_signed_at(
45    env: &SignedRoomMessage,
46    now_ms: i64,
47    window_ms: i64,
48) -> Result<(RoomMessage, String)> {
49    if env.signed_at_ms == 0 {
50        return Err(ProtocolError::Session(
51            "signed envelope is missing signed_at_ms — pre-0.7.11 sender or forgery".into(),
52        ));
53    }
54    // huddle 1.2: the replay-window check moved to AFTER cryptographic
55    // verification (below) so we know the message TYPE and can exempt store-
56    // and-forward control messages from the wall-clock window.
57
58    let pubkey_bytes = B64
59        .decode(&env.ed25519_pubkey_b64)
60        .map_err(|e| ProtocolError::Session(format!("bad pubkey_b64: {e}")))?;
61    if pubkey_bytes.len() != 32 {
62        return Err(ProtocolError::Session(format!(
63            "pubkey is {} bytes, expected 32",
64            pubkey_bytes.len()
65        )));
66    }
67    let mut pk_arr = [0u8; 32];
68    pk_arr.copy_from_slice(&pubkey_bytes);
69
70    let derived_fp = compute_fingerprint(&pk_arr);
71    if derived_fp != env.fingerprint {
72        return Err(ProtocolError::Session(format!(
73            "fingerprint mismatch: envelope claims {}, key derives {}",
74            env.fingerprint, derived_fp
75        )));
76    }
77
78    let payload = B64
79        .decode(&env.payload_b64)
80        .map_err(|e| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
81    let sig_bytes = B64
82        .decode(&env.signature_b64)
83        .map_err(|e| ProtocolError::Session(format!("bad signature_b64: {e}")))?;
84    if sig_bytes.len() != 64 {
85        return Err(ProtocolError::Session(format!(
86            "signature is {} bytes, expected 64",
87            sig_bytes.len()
88        )));
89    }
90    let mut sig_arr = [0u8; 64];
91    sig_arr.copy_from_slice(&sig_bytes);
92    let signature = Signature::from_bytes(&sig_arr);
93
94    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
95        .map_err(|e| ProtocolError::Session(format!("bad verifying key: {e}")))?;
96    // huddle 0.7.11: verify_strict rejects mixed-order / low-order
97    // pubkeys and is the recommended call per ed25519_dalek's docs.
98    // The signature is also bound to the `signed_at_ms` timestamp via
99    // the payload bytes (the payload deserializes to a RoomMessage,
100    // but the signature was over the raw payload + timestamp, so any
101    // tampering of either field invalidates verification).
102    verifying_key
103        .verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
104        .map_err(|e| ProtocolError::Session(format!("signature verify failed: {e}")))?;
105
106    let msg: RoomMessage = serde_json::from_slice(&payload)
107        .map_err(|e| ProtocolError::Session(format!("bad payload json: {e}")))?;
108
109    // huddle 1.2: apply the wall-clock replay window SELECTIVELY. Store-and-
110    // forward, idempotent control messages — ContactRequest, MemberAnnounce,
111    // SessionKeyRequest — are EXEMPT: they ride the relay's offline mailbox,
112    // which can hold them for hours or days until the recipient reconnects, so
113    // a ±5-minute window would silently drop legitimate first-contact requests
114    // and first key-exchange announces (precisely the "I added them but no
115    // chat ever appears" failure). Their replay protection is idempotency
116    // (re-applying them is a no-op) plus the signature, which still proves the
117    // sender's identity and is verified above regardless of type. Every other
118    // signed type keeps the strict window.
119    if window_applies(&msg) && (now_ms - env.signed_at_ms).abs() > window_ms {
120        return Err(ProtocolError::Session(format!(
121            "signed envelope timestamp {} is outside the ±{}ms window vs now {}",
122            env.signed_at_ms, window_ms, now_ms
123        )));
124    }
125    Ok((msg, derived_fp))
126}
127
128/// huddle 1.2: whether the wall-clock replay window applies to a signed
129/// message of this type. Store-and-forward, idempotent control messages are
130/// exempt because they legitimately arrive long after they were signed, via
131/// the relay's offline mailbox. All other signed types keep the strict window.
132fn window_applies(msg: &RoomMessage) -> bool {
133    !matches!(
134        msg,
135        RoomMessage::ContactRequest { .. }
136            | RoomMessage::MemberAnnounce { .. }
137            | RoomMessage::SessionKeyRequest { .. }
138    )
139}
140
141/// Wrap a `RoomMessage` into a `SignedRoomMessage` using the given identity's
142/// signing key. Mirror of `verify_signed`; symmetric helper so phase B/F/G/etc.
143/// don't each open-code the base64 dance.
144///
145/// huddle 0.7.11: also populates `signed_at_ms` with the current epoch in
146/// milliseconds, and signs over (payload || signed_at_ms) so the receiver's
147/// replay-window check is signature-bound.
148pub fn sign_message(
149    identity: &crate::identity::IdentityKeys,
150    msg: &RoomMessage,
151) -> Result<SignedRoomMessage> {
152    sign_message_at(identity, msg, now_unix_ms())
153}
154
155/// Same as `sign_message` but with an explicit timestamp — used by the
156/// replay-window unit tests so the clock isn't a hidden dependency.
157pub fn sign_message_at(
158    identity: &crate::identity::IdentityKeys,
159    msg: &RoomMessage,
160    signed_at_ms: i64,
161) -> Result<SignedRoomMessage> {
162    let payload = serde_json::to_vec(msg)
163        .map_err(|e| ProtocolError::Session(format!("encode payload: {e}")))?;
164    let sig = identity.sign(&signed_bytes(&payload, signed_at_ms));
165    Ok(SignedRoomMessage {
166        fingerprint: identity.fingerprint().to_string(),
167        ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
168        payload_b64: B64.encode(&payload),
169        signature_b64: B64.encode(sig),
170        signed_at_ms,
171    })
172}
173
174/// Canonical bytes the signature commits to: the raw RoomMessage JSON followed
175/// by a domain-separator and the big-endian timestamp. Putting the timestamp
176/// inside the signed bytes means a replayer can't change it without
177/// invalidating the signature.
178fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
179    let mut out = Vec::with_capacity(payload.len() + 24);
180    out.extend_from_slice(payload);
181    out.extend_from_slice(b"|huddle-signed-v1|");
182    out.extend_from_slice(&signed_at_ms.to_be_bytes());
183    out
184}
185
186fn now_unix_ms() -> i64 {
187    SystemTime::now()
188        .duration_since(UNIX_EPOCH)
189        .map(|d| d.as_millis() as i64)
190        .unwrap_or(0)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::identity::IdentityKeys;
197
198    fn sample_msg() -> RoomMessage {
199        RoomMessage::MemberLeave {
200            sender_fingerprint: "test-fp".into(),
201            room_id: None,
202        }
203    }
204
205    #[test]
206    fn sign_verify_round_trip() {
207        let id = IdentityKeys::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 = IdentityKeys::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 = IdentityKeys::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 = IdentityKeys::generate().unwrap();
243        let bob = IdentityKeys::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 = IdentityKeys::generate().unwrap();
252        let bob = IdentityKeys::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 = IdentityKeys::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 = IdentityKeys::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 = IdentityKeys::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            sender_mlkem_pubkey: None,
312            mlkem_ciphertext: None,
313        };
314        let env = sign_message_at(&id, &announce, signed_at).unwrap();
315        assert!(
316            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
317            "a mailboxed MemberAnnounce (carries the session key) must verify regardless of age"
318        );
319
320        // A non-exempt type (MemberLeave) still honors the window.
321        let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
322        assert!(
323            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
324            "non-store-and-forward types must still be window-checked"
325        );
326
327        // Tampering still fails for exempt types — signature is verified
328        // regardless of the window exemption.
329        let mut bad = sign_message_at(&id, &contact, signed_at).unwrap();
330        bad.signature_b64 = B64.encode([0u8; 64]);
331        assert!(
332            verify_signed_at(&bad, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
333            "an exempt type with a bad signature must still be rejected"
334        );
335    }
336}