Skip to main content

huddle_protocol/crypto/
mod.rs

1pub mod code_join;
2pub mod dm;
3pub mod mldsa;
4pub mod mnemonic;
5pub mod passphrase;
6pub mod pqc;
7pub mod sas;
8
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use base64::engine::general_purpose::STANDARD as B64;
12use base64::Engine;
13use ed25519_dalek::{Signature, VerifyingKey};
14
15use crate::error::{ProtocolError, Result};
16use crate::identity::compute_fingerprint;
17use crate::protocol::{RoomMessage, SignedRoomMessage};
18
19/// huddle 0.7.11: max accepted skew between `signed_at_ms` on a signed
20/// envelope and the receiver's wall clock. Anything outside the window
21/// is rejected as a replay (or as a clock that's drifted too far).
22pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
23
24/// Verify a `SignedRoomMessage` envelope:
25/// 1. The asserted `fingerprint` must equal the fingerprint derived from
26///    `ed25519_pubkey_b64` — closes the "claim someone else's fingerprint
27///    but sign with your own key" attack.
28/// 2. The Ed25519 signature must `verify_strict` over the decoded
29///    `payload_b64` (strict rejects low-order / mixed-order pubkeys).
30/// 3. The payload must deserialize as a `RoomMessage`.
31/// 4. huddle 0.7.11: `signed_at_ms` must be non-zero and within
32///    `SIGNED_ENVELOPE_WINDOW_MS` of the receiver's wall clock — closes
33///    indefinite replay of captured signed messages.
34///
35/// Returns the inner message and the (verified) sender fingerprint on
36/// success. Caller should still check that the fingerprint is one they
37/// expect for this context (e.g. an owner for `BanMember`).
38pub fn verify_signed(env: &SignedRoomMessage) -> Result<(RoomMessage, String)> {
39    let now_ms = now_unix_ms();
40    verify_signed_at(env, now_ms, SIGNED_ENVELOPE_WINDOW_MS)
41}
42
43/// Same as `verify_signed` but with an explicit clock and window —
44/// kept public for tests that want to exercise the replay-window logic
45/// deterministically without a SystemTime detour.
46pub fn verify_signed_at(
47    env: &SignedRoomMessage,
48    now_ms: i64,
49    window_ms: i64,
50) -> Result<(RoomMessage, String)> {
51    if env.signed_at_ms == 0 {
52        return Err(ProtocolError::Session(
53            "signed envelope is missing signed_at_ms — pre-0.7.11 sender or forgery".into(),
54        ));
55    }
56    // huddle 1.2: the replay-window check moved to AFTER cryptographic
57    // verification (below) so we know the message TYPE and can exempt store-
58    // and-forward control messages from the wall-clock window.
59
60    let pubkey_bytes = B64
61        .decode(&env.ed25519_pubkey_b64)
62        .map_err(|e| ProtocolError::Session(format!("bad pubkey_b64: {e}")))?;
63    if pubkey_bytes.len() != 32 {
64        return Err(ProtocolError::Session(format!(
65            "pubkey is {} bytes, expected 32",
66            pubkey_bytes.len()
67        )));
68    }
69    let mut pk_arr = [0u8; 32];
70    pk_arr.copy_from_slice(&pubkey_bytes);
71
72    let derived_fp = compute_fingerprint(&pk_arr);
73    if derived_fp != env.fingerprint {
74        return Err(ProtocolError::Session(format!(
75            "fingerprint mismatch: envelope claims {}, key derives {}",
76            env.fingerprint, derived_fp
77        )));
78    }
79
80    let payload = B64
81        .decode(&env.payload_b64)
82        .map_err(|e| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
83    let sig_bytes = B64
84        .decode(&env.signature_b64)
85        .map_err(|e| ProtocolError::Session(format!("bad signature_b64: {e}")))?;
86    if sig_bytes.len() != 64 {
87        return Err(ProtocolError::Session(format!(
88            "signature is {} bytes, expected 64",
89            sig_bytes.len()
90        )));
91    }
92    let mut sig_arr = [0u8; 64];
93    sig_arr.copy_from_slice(&sig_bytes);
94    let signature = Signature::from_bytes(&sig_arr);
95
96    let verifying_key = VerifyingKey::from_bytes(&pk_arr)
97        .map_err(|e| ProtocolError::Session(format!("bad verifying key: {e}")))?;
98    // huddle 0.7.11: verify_strict rejects mixed-order / low-order
99    // pubkeys and is the recommended call per ed25519_dalek's docs.
100    // The signature is also bound to the `signed_at_ms` timestamp via
101    // the payload bytes (the payload deserializes to a RoomMessage,
102    // but the signature was over the raw payload + timestamp, so any
103    // tampering of either field invalidates verification).
104    verifying_key
105        .verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
106        .map_err(|e| ProtocolError::Session(format!("signature verify failed: {e}")))?;
107
108    let msg: RoomMessage = serde_json::from_slice(&payload)
109        .map_err(|e| ProtocolError::Session(format!("bad payload json: {e}")))?;
110
111    // huddle 1.2: apply the wall-clock replay window SELECTIVELY. Store-and-
112    // forward, idempotent control messages — ContactRequest, MemberAnnounce,
113    // SessionKeyRequest — are EXEMPT: they ride the relay's offline mailbox,
114    // which can hold them for hours or days until the recipient reconnects, so
115    // a ±5-minute window would silently drop legitimate first-contact requests
116    // and first key-exchange announces (precisely the "I added them but no
117    // chat ever appears" failure). Their replay protection is idempotency
118    // (re-applying them is a no-op) plus the signature, which still proves the
119    // sender's identity and is verified above regardless of type. Every other
120    // signed type keeps the strict window.
121    if window_applies(&msg) && (now_ms - env.signed_at_ms).abs() > window_ms {
122        return Err(ProtocolError::Session(format!(
123            "signed envelope timestamp {} is outside the ±{}ms window vs now {}",
124            env.signed_at_ms, window_ms, now_ms
125        )));
126    }
127    Ok((msg, derived_fp))
128}
129
130/// huddle 1.2: whether the wall-clock replay window applies to a signed
131/// message of this type. Store-and-forward, idempotent control messages are
132/// exempt because they legitimately arrive long after they were signed, via
133/// the relay's offline mailbox. All other signed types keep the strict window.
134fn window_applies(msg: &RoomMessage) -> bool {
135    !matches!(
136        msg,
137        RoomMessage::ContactRequest { .. }
138            | RoomMessage::MemberAnnounce { .. }
139            | RoomMessage::SessionKeyRequest { .. }
140    )
141}
142
143/// Wrap a `RoomMessage` into a `SignedRoomMessage` using the given identity's
144/// signing key. Mirror of `verify_signed`; symmetric helper so phase B/F/G/etc.
145/// don't each open-code the base64 dance.
146///
147/// huddle 0.7.11: also populates `signed_at_ms` with the current epoch in
148/// milliseconds, and signs over (payload || signed_at_ms) so the receiver's
149/// replay-window check is signature-bound.
150pub fn sign_message(
151    identity: &crate::identity::IdentityKeys,
152    msg: &RoomMessage,
153) -> Result<SignedRoomMessage> {
154    sign_message_at(identity, msg, now_unix_ms())
155}
156
157/// Same as `sign_message` but with an explicit timestamp — used by the
158/// replay-window unit tests so the clock isn't a hidden dependency.
159pub fn sign_message_at(
160    identity: &crate::identity::IdentityKeys,
161    msg: &RoomMessage,
162    signed_at_ms: i64,
163) -> Result<SignedRoomMessage> {
164    let payload = serde_json::to_vec(msg)
165        .map_err(|e| ProtocolError::Session(format!("encode payload: {e}")))?;
166    let sig = identity.sign(&signed_bytes(&payload, signed_at_ms));
167    Ok(SignedRoomMessage {
168        fingerprint: identity.fingerprint().to_string(),
169        ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
170        payload_b64: B64.encode(&payload),
171        signature_b64: B64.encode(sig),
172        signed_at_ms,
173        mldsa_pubkey_b64: None,
174        mldsa_signature_b64: None,
175    })
176}
177
178/// huddle 2.0.6 (WS2-a): like `sign_message`, but ALSO attaches a composite
179/// ML-DSA-65 post-quantum signature over the same `signed_bytes`, plus the
180/// sender's ML-DSA public key. For **low-frequency identity/authority**
181/// envelopes (announces, owner/ban grants, invites) — the ML-DSA signature is
182/// ~3.3 KB, so it is not put on every chat line. Backward-compatible: a peer
183/// that doesn't pin the sender's ML-DSA key simply ignores the extra fields and
184/// verifies classically.
185pub fn sign_message_hybrid_pq(
186    identity: &crate::identity::IdentityKeys,
187    msg: &RoomMessage,
188) -> Result<SignedRoomMessage> {
189    sign_message_hybrid_pq_at(identity, msg, now_unix_ms())
190}
191
192/// Deterministic-timestamp variant of [`sign_message_hybrid_pq`] for tests.
193pub fn sign_message_hybrid_pq_at(
194    identity: &crate::identity::IdentityKeys,
195    msg: &RoomMessage,
196    signed_at_ms: i64,
197) -> Result<SignedRoomMessage> {
198    let mut env = sign_message_at(identity, msg, signed_at_ms)?;
199    // Sign the exact bytes the Ed25519 layer committed to, so both signatures
200    // cover the same payload + timestamp.
201    let payload = B64
202        .decode(&env.payload_b64)
203        .map_err(|e| ProtocolError::Session(format!("re-decode payload: {e}")))?;
204    let mldsa_sig = identity.mldsa_sign(&signed_bytes(&payload, signed_at_ms));
205    env.mldsa_pubkey_b64 = Some(B64.encode(identity.mldsa_public_bytes()));
206    env.mldsa_signature_b64 = Some(B64.encode(mldsa_sig));
207    Ok(env)
208}
209
210/// huddle 2.0.6 (WS2-a): verify an envelope's composite ML-DSA-65 signature
211/// against a **pinned** ML-DSA public key (the caller's durable record of this
212/// signer's PQ-auth key, learned from a prior signed announce). The Ed25519
213/// layer is checked separately by [`verify_signed`]; this is the additional
214/// post-quantum check, gated on having pinned the signer's key.
215///
216/// - `Ok(true)`  — a valid ML-DSA signature by the pinned key (PQ-auth confirmed).
217/// - `Ok(false)` — no ML-DSA signature present (a classical-only envelope).
218/// - `Err(..)`   — the envelope claims a **different** ML-DSA key than pinned, or
219///   carries a malformed/invalid ML-DSA signature: a downgrade/forgery the
220///   caller MUST reject. (A caller that has pinned this signer should also treat
221///   `Ok(false)` — a stripped signature — as a downgrade and reject it.)
222pub fn verify_signed_mldsa(env: &SignedRoomMessage, pinned_mldsa_pubkey: &[u8]) -> Result<bool> {
223    let (pk_b64, sig_b64) = match (&env.mldsa_pubkey_b64, &env.mldsa_signature_b64) {
224        (Some(p), Some(s)) => (p, s),
225        _ => return Ok(false),
226    };
227    let pk = B64
228        .decode(pk_b64)
229        .map_err(|e| ProtocolError::Session(format!("bad mldsa_pubkey_b64: {e}")))?;
230    if pk.as_slice() != pinned_mldsa_pubkey {
231        return Err(ProtocolError::Session(
232            "ML-DSA key in envelope does not match the pinned key for this signer \
233             (post-quantum downgrade or forgery) — rejecting"
234                .into(),
235        ));
236    }
237    let sig = B64
238        .decode(sig_b64)
239        .map_err(|e| ProtocolError::Session(format!("bad mldsa_signature_b64: {e}")))?;
240    let payload = B64
241        .decode(&env.payload_b64)
242        .map_err(|e| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
243    if crate::crypto::mldsa::verify(&pk, &signed_bytes(&payload, env.signed_at_ms), &sig) {
244        Ok(true)
245    } else {
246        Err(ProtocolError::Session(
247            "ML-DSA signature failed to verify over the envelope's signed bytes".into(),
248        ))
249    }
250}
251
252/// Canonical bytes the signature commits to: the raw RoomMessage JSON followed
253/// by a domain-separator and the big-endian timestamp. Putting the timestamp
254/// inside the signed bytes means a replayer can't change it without
255/// invalidating the signature.
256fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
257    let mut out = Vec::with_capacity(payload.len() + 24);
258    out.extend_from_slice(payload);
259    out.extend_from_slice(b"|huddle-signed-v1|");
260    out.extend_from_slice(&signed_at_ms.to_be_bytes());
261    out
262}
263
264fn now_unix_ms() -> i64 {
265    SystemTime::now()
266        .duration_since(UNIX_EPOCH)
267        .map(|d| d.as_millis() as i64)
268        .unwrap_or(0)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::identity::IdentityKeys;
275
276    fn sample_msg() -> RoomMessage {
277        RoomMessage::MemberLeave {
278            sender_fingerprint: "test-fp".into(),
279            room_id: None,
280        }
281    }
282
283    #[test]
284    fn sign_verify_round_trip() {
285        let id = IdentityKeys::generate().unwrap();
286        let env = sign_message(&id, &sample_msg()).unwrap();
287        let (msg, fp) = verify_signed(&env).unwrap();
288        assert_eq!(fp, id.fingerprint());
289        assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
290    }
291
292    #[test]
293    fn tampered_payload_fails() {
294        let id = IdentityKeys::generate().unwrap();
295        let mut env = sign_message(&id, &sample_msg()).unwrap();
296        let other = serde_json::to_vec(&RoomMessage::Typing {
297            sender_fingerprint: "evil-fp".into(),
298        })
299        .unwrap();
300        env.payload_b64 = B64.encode(&other);
301        assert!(verify_signed(&env).is_err());
302    }
303
304    #[test]
305    fn tampered_timestamp_fails_signature() {
306        // huddle 0.7.11: the signed bytes commit to signed_at_ms, so a
307        // replayer who rewrites the timestamp to bring it back inside
308        // the window invalidates the signature.
309        let id = IdentityKeys::generate().unwrap();
310        let now_ms = 1_700_000_000_000_i64;
311        let mut env = sign_message_at(&id, &sample_msg(), now_ms).unwrap();
312        env.signed_at_ms = now_ms + 1;
313        let err = verify_signed_at(&env, now_ms, SIGNED_ENVELOPE_WINDOW_MS).unwrap_err();
314        let s = format!("{err}");
315        assert!(s.contains("signature verify failed"), "got: {s}");
316    }
317
318    #[test]
319    fn fingerprint_pubkey_mismatch_fails() {
320        let alice = IdentityKeys::generate().unwrap();
321        let bob = IdentityKeys::generate().unwrap();
322        let mut env = sign_message(&alice, &sample_msg()).unwrap();
323        env.fingerprint = bob.fingerprint().to_string();
324        assert!(verify_signed(&env).is_err());
325    }
326
327    #[test]
328    fn swapped_pubkey_fails_signature() {
329        let alice = IdentityKeys::generate().unwrap();
330        let bob = IdentityKeys::generate().unwrap();
331        let mut env = sign_message(&alice, &sample_msg()).unwrap();
332        env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
333        env.fingerprint = bob.fingerprint().to_string();
334        assert!(verify_signed(&env).is_err());
335    }
336
337    #[test]
338    fn missing_timestamp_rejected() {
339        // huddle 0.7.11: signed_at_ms == 0 is the serde default, which
340        // we use as the "legacy pre-replay-protection" sentinel and
341        // reject outright.
342        let id = IdentityKeys::generate().unwrap();
343        let mut env = sign_message(&id, &sample_msg()).unwrap();
344        env.signed_at_ms = 0;
345        assert!(verify_signed(&env).is_err());
346    }
347
348    #[test]
349    fn outside_window_rejected() {
350        let id = IdentityKeys::generate().unwrap();
351        let signed_at = 1_700_000_000_000_i64;
352        let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
353        // 6 minutes later — outside the default 5 min window.
354        let now = signed_at + 6 * 60 * 1000;
355        assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
356        // Inside the window: ok.
357        let now = signed_at + 4 * 60 * 1000;
358        assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok());
359    }
360
361    #[test]
362    fn hybrid_pq_sign_verify_round_trip() {
363        let id = IdentityKeys::generate().unwrap();
364        // A classical envelope carries no composite signature.
365        let env = sign_message(&id, &sample_msg()).unwrap();
366        assert!(!verify_signed_mldsa(&env, &id.mldsa_public_bytes()).unwrap());
367        // A hybrid envelope still verifies classically (backward-compat) AND its
368        // composite ML-DSA signature verifies against the pinned key.
369        let henv = sign_message_hybrid_pq(&id, &sample_msg()).unwrap();
370        assert!(verify_signed(&henv).is_ok());
371        assert!(verify_signed_mldsa(&henv, &id.mldsa_public_bytes()).unwrap());
372    }
373
374    #[test]
375    fn hybrid_pq_downgrade_and_forgery_rejected() {
376        let id = IdentityKeys::generate().unwrap();
377        let other = IdentityKeys::generate().unwrap();
378        let henv = sign_message_hybrid_pq(&id, &sample_msg()).unwrap();
379        // A different pinned key (substitution / wrong signer) is rejected.
380        assert!(verify_signed_mldsa(&henv, &other.mldsa_public_bytes()).is_err());
381        // A tampered ML-DSA signature is rejected.
382        let mut bad = henv.clone();
383        let mut sig = B64
384            .decode(bad.mldsa_signature_b64.as_ref().unwrap())
385            .unwrap();
386        sig[0] ^= 1;
387        bad.mldsa_signature_b64 = Some(B64.encode(&sig));
388        assert!(verify_signed_mldsa(&bad, &id.mldsa_public_bytes()).is_err());
389    }
390
391    #[test]
392    fn store_and_forward_types_exempt_from_window() {
393        // huddle 1.2: ContactRequest and MemberAnnounce ride the relay's
394        // offline mailbox and may legitimately arrive days later. They MUST
395        // verify even far outside the wall-clock window (signature still
396        // proves identity); otherwise offline first-contact + first key
397        // exchange silently fail and "no chat ever appears".
398        let id = IdentityKeys::generate().unwrap();
399        let signed_at = 1_700_000_000_000_i64;
400        let now = signed_at + 30 * 24 * 60 * 60 * 1000; // 30 days later
401
402        let contact = RoomMessage::ContactRequest {
403            requester_fingerprint: id.fingerprint().to_string(),
404            display_name: Some("late arrival".into()),
405            note: None,
406            sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
407        };
408        let env = sign_message_at(&id, &contact, signed_at).unwrap();
409        assert!(
410            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
411            "a mailboxed ContactRequest must verify regardless of age"
412        );
413
414        let announce = RoomMessage::MemberAnnounce {
415            sender_fingerprint: id.fingerprint().to_string(),
416            wrapped_session_key: Some("d2VsbA==".into()),
417            display_name: None,
418            sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
419            sender_mlkem_pubkey: None,
420            mlkem_ciphertext: None,
421        };
422        let env = sign_message_at(&id, &announce, signed_at).unwrap();
423        assert!(
424            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
425            "a mailboxed MemberAnnounce (carries the session key) must verify regardless of age"
426        );
427
428        // A non-exempt type (MemberLeave) still honors the window.
429        let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
430        assert!(
431            verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
432            "non-store-and-forward types must still be window-checked"
433        );
434
435        // Tampering still fails for exempt types — signature is verified
436        // regardless of the window exemption.
437        let mut bad = sign_message_at(&id, &contact, signed_at).unwrap();
438        bad.signature_b64 = B64.encode([0u8; 64]);
439        assert!(
440            verify_signed_at(&bad, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
441            "an exempt type with a bad signature must still be rejected"
442        );
443    }
444}