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
17pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
21
22pub 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
41pub 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 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 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 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
128fn window_applies(msg: &RoomMessage) -> bool {
133 !matches!(
134 msg,
135 RoomMessage::ContactRequest { .. }
136 | RoomMessage::MemberAnnounce { .. }
137 | RoomMessage::SessionKeyRequest { .. }
138 )
139}
140
141pub 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
155pub 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
174fn 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 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 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 let now = signed_at + 6 * 60 * 1000;
277 assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
278 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 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; 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 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 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}