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
19pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
23
24pub 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
43pub 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 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 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 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
130fn window_applies(msg: &RoomMessage) -> bool {
135 !matches!(
136 msg,
137 RoomMessage::ContactRequest { .. }
138 | RoomMessage::MemberAnnounce { .. }
139 | RoomMessage::SessionKeyRequest { .. }
140 )
141}
142
143pub 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
157pub 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
178pub 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
192pub 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 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
210pub 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
252fn 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 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 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 let now = signed_at + 6 * 60 * 1000;
355 assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
356 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 let env = sign_message(&id, &sample_msg()).unwrap();
366 assert!(!verify_signed_mldsa(&env, &id.mldsa_public_bytes()).unwrap());
367 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 assert!(verify_signed_mldsa(&henv, &other.mldsa_public_bytes()).is_err());
381 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 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; 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 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 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}