huddle_core/crypto/
mod.rs1pub mod dm;
2pub mod megolm;
3pub mod passphrase;
4pub mod sas;
5
6pub use megolm::RoomCrypto;
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::{HuddleError, Result};
15use crate::identity::compute_fingerprint;
16use crate::network::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(HuddleError::Session(
52 "signed envelope is missing signed_at_ms — pre-0.7.11 sender or forgery".into(),
53 ));
54 }
55 if (now_ms - env.signed_at_ms).abs() > window_ms {
56 return Err(HuddleError::Session(format!(
57 "signed envelope timestamp {} is outside the ±{}ms window vs now {}",
58 env.signed_at_ms, window_ms, now_ms
59 )));
60 }
61
62 let pubkey_bytes = B64
63 .decode(&env.ed25519_pubkey_b64)
64 .map_err(|e| HuddleError::Session(format!("bad pubkey_b64: {e}")))?;
65 if pubkey_bytes.len() != 32 {
66 return Err(HuddleError::Session(format!(
67 "pubkey is {} bytes, expected 32",
68 pubkey_bytes.len()
69 )));
70 }
71 let mut pk_arr = [0u8; 32];
72 pk_arr.copy_from_slice(&pubkey_bytes);
73
74 let derived_fp = compute_fingerprint(&pk_arr);
75 if derived_fp != env.fingerprint {
76 return Err(HuddleError::Session(format!(
77 "fingerprint mismatch: envelope claims {}, key derives {}",
78 env.fingerprint, derived_fp
79 )));
80 }
81
82 let payload = B64
83 .decode(&env.payload_b64)
84 .map_err(|e| HuddleError::Session(format!("bad payload_b64: {e}")))?;
85 let sig_bytes = B64
86 .decode(&env.signature_b64)
87 .map_err(|e| HuddleError::Session(format!("bad signature_b64: {e}")))?;
88 if sig_bytes.len() != 64 {
89 return Err(HuddleError::Session(format!(
90 "signature is {} bytes, expected 64",
91 sig_bytes.len()
92 )));
93 }
94 let mut sig_arr = [0u8; 64];
95 sig_arr.copy_from_slice(&sig_bytes);
96 let signature = Signature::from_bytes(&sig_arr);
97
98 let verifying_key = VerifyingKey::from_bytes(&pk_arr)
99 .map_err(|e| HuddleError::Session(format!("bad verifying key: {e}")))?;
100 verifying_key
107 .verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
108 .map_err(|e| HuddleError::Session(format!("signature verify failed: {e}")))?;
109
110 let msg: RoomMessage = serde_json::from_slice(&payload)
111 .map_err(|e| HuddleError::Session(format!("bad payload json: {e}")))?;
112 Ok((msg, derived_fp))
113}
114
115pub fn sign_message(
123 identity: &crate::identity::Identity,
124 msg: &RoomMessage,
125) -> Result<SignedRoomMessage> {
126 sign_message_at(identity, msg, now_unix_ms())
127}
128
129pub fn sign_message_at(
132 identity: &crate::identity::Identity,
133 msg: &RoomMessage,
134 signed_at_ms: i64,
135) -> Result<SignedRoomMessage> {
136 let payload = serde_json::to_vec(msg)
137 .map_err(|e| HuddleError::Session(format!("encode payload: {e}")))?;
138 let sig = identity.sign(&signed_bytes(&payload, signed_at_ms));
139 Ok(SignedRoomMessage {
140 fingerprint: identity.fingerprint().to_string(),
141 ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
142 payload_b64: B64.encode(&payload),
143 signature_b64: B64.encode(sig),
144 signed_at_ms,
145 })
146}
147
148fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
153 let mut out = Vec::with_capacity(payload.len() + 24);
154 out.extend_from_slice(payload);
155 out.extend_from_slice(b"|huddle-signed-v1|");
156 out.extend_from_slice(&signed_at_ms.to_be_bytes());
157 out
158}
159
160fn now_unix_ms() -> i64 {
161 SystemTime::now()
162 .duration_since(UNIX_EPOCH)
163 .map(|d| d.as_millis() as i64)
164 .unwrap_or(0)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::identity::Identity;
171
172 fn sample_msg() -> RoomMessage {
173 RoomMessage::MemberLeave {
174 sender_fingerprint: "test-fp".into(),
175 }
176 }
177
178 #[test]
179 fn sign_verify_round_trip() {
180 let id = Identity::generate().unwrap();
181 let env = sign_message(&id, &sample_msg()).unwrap();
182 let (msg, fp) = verify_signed(&env).unwrap();
183 assert_eq!(fp, id.fingerprint());
184 assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
185 }
186
187 #[test]
188 fn tampered_payload_fails() {
189 let id = Identity::generate().unwrap();
190 let mut env = sign_message(&id, &sample_msg()).unwrap();
191 let other = serde_json::to_vec(&RoomMessage::Typing {
192 sender_fingerprint: "evil-fp".into(),
193 })
194 .unwrap();
195 env.payload_b64 = B64.encode(&other);
196 assert!(verify_signed(&env).is_err());
197 }
198
199 #[test]
200 fn tampered_timestamp_fails_signature() {
201 let id = Identity::generate().unwrap();
205 let now_ms = 1_700_000_000_000_i64;
206 let mut env = sign_message_at(&id, &sample_msg(), now_ms).unwrap();
207 env.signed_at_ms = now_ms + 1;
208 let err = verify_signed_at(&env, now_ms, SIGNED_ENVELOPE_WINDOW_MS).unwrap_err();
209 let s = format!("{err}");
210 assert!(s.contains("signature verify failed"), "got: {s}");
211 }
212
213 #[test]
214 fn fingerprint_pubkey_mismatch_fails() {
215 let alice = Identity::generate().unwrap();
216 let bob = Identity::generate().unwrap();
217 let mut env = sign_message(&alice, &sample_msg()).unwrap();
218 env.fingerprint = bob.fingerprint().to_string();
219 assert!(verify_signed(&env).is_err());
220 }
221
222 #[test]
223 fn swapped_pubkey_fails_signature() {
224 let alice = Identity::generate().unwrap();
225 let bob = Identity::generate().unwrap();
226 let mut env = sign_message(&alice, &sample_msg()).unwrap();
227 env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
228 env.fingerprint = bob.fingerprint().to_string();
229 assert!(verify_signed(&env).is_err());
230 }
231
232 #[test]
233 fn missing_timestamp_rejected() {
234 let id = Identity::generate().unwrap();
238 let mut env = sign_message(&id, &sample_msg()).unwrap();
239 env.signed_at_ms = 0;
240 assert!(verify_signed(&env).is_err());
241 }
242
243 #[test]
244 fn outside_window_rejected() {
245 let id = Identity::generate().unwrap();
246 let signed_at = 1_700_000_000_000_i64;
247 let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
248 let now = signed_at + 6 * 60 * 1000;
250 assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
251 let now = signed_at + 4 * 60 * 1000;
253 assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok());
254 }
255}