1pub mod dm;
2pub mod mldsa;
3pub mod mnemonic;
4pub mod passphrase;
5pub mod pqc;
6pub mod sas;
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::{ProtocolError, Result};
15use crate::identity::compute_fingerprint;
16use crate::protocol::{RoomMessage, SignedRoomMessage};
17
18pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
22
23pub 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
42pub 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(ProtocolError::Session(
52 "signed envelope is missing signed_at_ms — pre-0.7.11 sender or forgery".into(),
53 ));
54 }
55 let pubkey_bytes = B64
60 .decode(&env.ed25519_pubkey_b64)
61 .map_err(|e| ProtocolError::Session(format!("bad pubkey_b64: {e}")))?;
62 if pubkey_bytes.len() != 32 {
63 return Err(ProtocolError::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(ProtocolError::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| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
82 let sig_bytes = B64
83 .decode(&env.signature_b64)
84 .map_err(|e| ProtocolError::Session(format!("bad signature_b64: {e}")))?;
85 if sig_bytes.len() != 64 {
86 return Err(ProtocolError::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| ProtocolError::Session(format!("bad verifying key: {e}")))?;
97 verifying_key
104 .verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
105 .map_err(|e| ProtocolError::Session(format!("signature verify failed: {e}")))?;
106
107 let msg: RoomMessage = serde_json::from_slice(&payload)
108 .map_err(|e| ProtocolError::Session(format!("bad payload json: {e}")))?;
109
110 if window_applies(&msg) && (now_ms - env.signed_at_ms).abs() > window_ms {
121 return Err(ProtocolError::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
129fn window_applies(msg: &RoomMessage) -> bool {
134 !matches!(
135 msg,
136 RoomMessage::ContactRequest { .. }
137 | RoomMessage::MemberAnnounce { .. }
138 | RoomMessage::SessionKeyRequest { .. }
139 )
140}
141
142pub fn sign_message(
150 identity: &crate::identity::IdentityKeys,
151 msg: &RoomMessage,
152) -> Result<SignedRoomMessage> {
153 sign_message_at(identity, msg, now_unix_ms())
154}
155
156pub fn sign_message_at(
159 identity: &crate::identity::IdentityKeys,
160 msg: &RoomMessage,
161 signed_at_ms: i64,
162) -> Result<SignedRoomMessage> {
163 let payload = serde_json::to_vec(msg)
164 .map_err(|e| ProtocolError::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 mldsa_pubkey_b64: None,
173 mldsa_signature_b64: None,
174 })
175}
176
177pub fn sign_message_hybrid_pq(
185 identity: &crate::identity::IdentityKeys,
186 msg: &RoomMessage,
187) -> Result<SignedRoomMessage> {
188 sign_message_hybrid_pq_at(identity, msg, now_unix_ms())
189}
190
191pub fn sign_message_hybrid_pq_at(
193 identity: &crate::identity::IdentityKeys,
194 msg: &RoomMessage,
195 signed_at_ms: i64,
196) -> Result<SignedRoomMessage> {
197 let mut env = sign_message_at(identity, msg, signed_at_ms)?;
198 let payload = B64
201 .decode(&env.payload_b64)
202 .map_err(|e| ProtocolError::Session(format!("re-decode payload: {e}")))?;
203 let mldsa_sig = identity.mldsa_sign(&signed_bytes(&payload, signed_at_ms));
204 env.mldsa_pubkey_b64 = Some(B64.encode(identity.mldsa_public_bytes()));
205 env.mldsa_signature_b64 = Some(B64.encode(mldsa_sig));
206 Ok(env)
207}
208
209pub fn verify_signed_mldsa(env: &SignedRoomMessage, pinned_mldsa_pubkey: &[u8]) -> Result<bool> {
222 let (pk_b64, sig_b64) = match (&env.mldsa_pubkey_b64, &env.mldsa_signature_b64) {
223 (Some(p), Some(s)) => (p, s),
224 _ => return Ok(false),
225 };
226 let pk = B64
227 .decode(pk_b64)
228 .map_err(|e| ProtocolError::Session(format!("bad mldsa_pubkey_b64: {e}")))?;
229 if pk.as_slice() != pinned_mldsa_pubkey {
230 return Err(ProtocolError::Session(
231 "ML-DSA key in envelope does not match the pinned key for this signer \
232 (post-quantum downgrade or forgery) — rejecting"
233 .into(),
234 ));
235 }
236 let sig = B64
237 .decode(sig_b64)
238 .map_err(|e| ProtocolError::Session(format!("bad mldsa_signature_b64: {e}")))?;
239 let payload = B64
240 .decode(&env.payload_b64)
241 .map_err(|e| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
242 if crate::crypto::mldsa::verify(&pk, &signed_bytes(&payload, env.signed_at_ms), &sig) {
243 Ok(true)
244 } else {
245 Err(ProtocolError::Session(
246 "ML-DSA signature failed to verify over the envelope's signed bytes".into(),
247 ))
248 }
249}
250
251fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
256 let mut out = Vec::with_capacity(payload.len() + 24);
257 out.extend_from_slice(payload);
258 out.extend_from_slice(b"|huddle-signed-v1|");
259 out.extend_from_slice(&signed_at_ms.to_be_bytes());
260 out
261}
262
263fn now_unix_ms() -> i64 {
264 SystemTime::now()
265 .duration_since(UNIX_EPOCH)
266 .map(|d| d.as_millis() as i64)
267 .unwrap_or(0)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::identity::IdentityKeys;
274
275 fn sample_msg() -> RoomMessage {
276 RoomMessage::MemberLeave {
277 sender_fingerprint: "test-fp".into(),
278 room_id: None,
279 }
280 }
281
282 #[test]
283 fn sign_verify_round_trip() {
284 let id = IdentityKeys::generate().unwrap();
285 let env = sign_message(&id, &sample_msg()).unwrap();
286 let (msg, fp) = verify_signed(&env).unwrap();
287 assert_eq!(fp, id.fingerprint());
288 assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
289 }
290
291 #[test]
292 fn tampered_payload_fails() {
293 let id = IdentityKeys::generate().unwrap();
294 let mut env = sign_message(&id, &sample_msg()).unwrap();
295 let other = serde_json::to_vec(&RoomMessage::Typing {
296 sender_fingerprint: "evil-fp".into(),
297 })
298 .unwrap();
299 env.payload_b64 = B64.encode(&other);
300 assert!(verify_signed(&env).is_err());
301 }
302
303 #[test]
304 fn tampered_timestamp_fails_signature() {
305 let id = IdentityKeys::generate().unwrap();
309 let now_ms = 1_700_000_000_000_i64;
310 let mut env = sign_message_at(&id, &sample_msg(), now_ms).unwrap();
311 env.signed_at_ms = now_ms + 1;
312 let err = verify_signed_at(&env, now_ms, SIGNED_ENVELOPE_WINDOW_MS).unwrap_err();
313 let s = format!("{err}");
314 assert!(s.contains("signature verify failed"), "got: {s}");
315 }
316
317 #[test]
318 fn fingerprint_pubkey_mismatch_fails() {
319 let alice = IdentityKeys::generate().unwrap();
320 let bob = IdentityKeys::generate().unwrap();
321 let mut env = sign_message(&alice, &sample_msg()).unwrap();
322 env.fingerprint = bob.fingerprint().to_string();
323 assert!(verify_signed(&env).is_err());
324 }
325
326 #[test]
327 fn swapped_pubkey_fails_signature() {
328 let alice = IdentityKeys::generate().unwrap();
329 let bob = IdentityKeys::generate().unwrap();
330 let mut env = sign_message(&alice, &sample_msg()).unwrap();
331 env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
332 env.fingerprint = bob.fingerprint().to_string();
333 assert!(verify_signed(&env).is_err());
334 }
335
336 #[test]
337 fn missing_timestamp_rejected() {
338 let id = IdentityKeys::generate().unwrap();
342 let mut env = sign_message(&id, &sample_msg()).unwrap();
343 env.signed_at_ms = 0;
344 assert!(verify_signed(&env).is_err());
345 }
346
347 #[test]
348 fn outside_window_rejected() {
349 let id = IdentityKeys::generate().unwrap();
350 let signed_at = 1_700_000_000_000_i64;
351 let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
352 let now = signed_at + 6 * 60 * 1000;
354 assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
355 let now = signed_at + 4 * 60 * 1000;
357 assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok());
358 }
359
360 #[test]
361 fn hybrid_pq_sign_verify_round_trip() {
362 let id = IdentityKeys::generate().unwrap();
363 let env = sign_message(&id, &sample_msg()).unwrap();
365 assert!(!verify_signed_mldsa(&env, &id.mldsa_public_bytes()).unwrap());
366 let henv = sign_message_hybrid_pq(&id, &sample_msg()).unwrap();
369 assert!(verify_signed(&henv).is_ok());
370 assert!(verify_signed_mldsa(&henv, &id.mldsa_public_bytes()).unwrap());
371 }
372
373 #[test]
374 fn hybrid_pq_downgrade_and_forgery_rejected() {
375 let id = IdentityKeys::generate().unwrap();
376 let other = IdentityKeys::generate().unwrap();
377 let henv = sign_message_hybrid_pq(&id, &sample_msg()).unwrap();
378 assert!(verify_signed_mldsa(&henv, &other.mldsa_public_bytes()).is_err());
380 let mut bad = henv.clone();
382 let mut sig = B64
383 .decode(bad.mldsa_signature_b64.as_ref().unwrap())
384 .unwrap();
385 sig[0] ^= 1;
386 bad.mldsa_signature_b64 = Some(B64.encode(&sig));
387 assert!(verify_signed_mldsa(&bad, &id.mldsa_public_bytes()).is_err());
388 }
389
390 #[test]
391 fn store_and_forward_types_exempt_from_window() {
392 let id = IdentityKeys::generate().unwrap();
398 let signed_at = 1_700_000_000_000_i64;
399 let now = signed_at + 30 * 24 * 60 * 60 * 1000; let contact = RoomMessage::ContactRequest {
402 requester_fingerprint: id.fingerprint().to_string(),
403 display_name: Some("late arrival".into()),
404 note: None,
405 sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
406 };
407 let env = sign_message_at(&id, &contact, signed_at).unwrap();
408 assert!(
409 verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
410 "a mailboxed ContactRequest must verify regardless of age"
411 );
412
413 let announce = RoomMessage::MemberAnnounce {
414 sender_fingerprint: id.fingerprint().to_string(),
415 wrapped_session_key: Some("d2VsbA==".into()),
416 display_name: None,
417 sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
418 sender_mlkem_pubkey: None,
419 mlkem_ciphertext: None,
420 };
421 let env = sign_message_at(&id, &announce, signed_at).unwrap();
422 assert!(
423 verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
424 "a mailboxed MemberAnnounce (carries the session key) must verify regardless of age"
425 );
426
427 let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
429 assert!(
430 verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
431 "non-store-and-forward types must still be window-checked"
432 );
433
434 let mut bad = sign_message_at(&id, &contact, signed_at).unwrap();
437 bad.signature_b64 = B64.encode([0u8; 64]);
438 assert!(
439 verify_signed_at(&bad, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
440 "an exempt type with a bad signature must still be rejected"
441 );
442 }
443}