pub mod code_join;
pub mod dm;
pub mod mldsa;
pub mod mnemonic;
pub mod passphrase;
pub mod pqc;
pub mod sas;
use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use crate::error::{ProtocolError, Result};
use crate::identity::compute_fingerprint;
use crate::protocol::{RoomMessage, SignedRoomMessage};
pub const SIGNED_ENVELOPE_WINDOW_MS: i64 = 5 * 60 * 1000;
pub fn verify_signed(env: &SignedRoomMessage) -> Result<(RoomMessage, String)> {
let now_ms = now_unix_ms();
verify_signed_at(env, now_ms, SIGNED_ENVELOPE_WINDOW_MS)
}
pub fn verify_signed_at(
env: &SignedRoomMessage,
now_ms: i64,
window_ms: i64,
) -> Result<(RoomMessage, String)> {
if env.signed_at_ms == 0 {
return Err(ProtocolError::Session(
"signed envelope is missing signed_at_ms — pre-0.7.11 sender or forgery".into(),
));
}
let pubkey_bytes = B64
.decode(&env.ed25519_pubkey_b64)
.map_err(|e| ProtocolError::Session(format!("bad pubkey_b64: {e}")))?;
if pubkey_bytes.len() != 32 {
return Err(ProtocolError::Session(format!(
"pubkey is {} bytes, expected 32",
pubkey_bytes.len()
)));
}
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pubkey_bytes);
let derived_fp = compute_fingerprint(&pk_arr);
if derived_fp != env.fingerprint {
return Err(ProtocolError::Session(format!(
"fingerprint mismatch: envelope claims {}, key derives {}",
env.fingerprint, derived_fp
)));
}
let payload = B64
.decode(&env.payload_b64)
.map_err(|e| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
let sig_bytes = B64
.decode(&env.signature_b64)
.map_err(|e| ProtocolError::Session(format!("bad signature_b64: {e}")))?;
if sig_bytes.len() != 64 {
return Err(ProtocolError::Session(format!(
"signature is {} bytes, expected 64",
sig_bytes.len()
)));
}
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let signature = Signature::from_bytes(&sig_arr);
let verifying_key = VerifyingKey::from_bytes(&pk_arr)
.map_err(|e| ProtocolError::Session(format!("bad verifying key: {e}")))?;
verifying_key
.verify_strict(&signed_bytes(&payload, env.signed_at_ms), &signature)
.map_err(|e| ProtocolError::Session(format!("signature verify failed: {e}")))?;
let msg: RoomMessage = serde_json::from_slice(&payload)
.map_err(|e| ProtocolError::Session(format!("bad payload json: {e}")))?;
if window_applies(&msg) && (now_ms - env.signed_at_ms).abs() > window_ms {
return Err(ProtocolError::Session(format!(
"signed envelope timestamp {} is outside the ±{}ms window vs now {}",
env.signed_at_ms, window_ms, now_ms
)));
}
Ok((msg, derived_fp))
}
fn window_applies(msg: &RoomMessage) -> bool {
!matches!(
msg,
RoomMessage::ContactRequest { .. }
| RoomMessage::MemberAnnounce { .. }
| RoomMessage::SessionKeyRequest { .. }
)
}
pub fn sign_message(
identity: &crate::identity::IdentityKeys,
msg: &RoomMessage,
) -> Result<SignedRoomMessage> {
sign_message_at(identity, msg, now_unix_ms())
}
pub fn sign_message_at(
identity: &crate::identity::IdentityKeys,
msg: &RoomMessage,
signed_at_ms: i64,
) -> Result<SignedRoomMessage> {
let payload = serde_json::to_vec(msg)
.map_err(|e| ProtocolError::Session(format!("encode payload: {e}")))?;
let sig = identity.sign(&signed_bytes(&payload, signed_at_ms));
Ok(SignedRoomMessage {
fingerprint: identity.fingerprint().to_string(),
ed25519_pubkey_b64: B64.encode(identity.public_bytes()),
payload_b64: B64.encode(&payload),
signature_b64: B64.encode(sig),
signed_at_ms,
mldsa_pubkey_b64: None,
mldsa_signature_b64: None,
})
}
pub fn sign_message_hybrid_pq(
identity: &crate::identity::IdentityKeys,
msg: &RoomMessage,
) -> Result<SignedRoomMessage> {
sign_message_hybrid_pq_at(identity, msg, now_unix_ms())
}
pub fn sign_message_hybrid_pq_at(
identity: &crate::identity::IdentityKeys,
msg: &RoomMessage,
signed_at_ms: i64,
) -> Result<SignedRoomMessage> {
let mut env = sign_message_at(identity, msg, signed_at_ms)?;
let payload = B64
.decode(&env.payload_b64)
.map_err(|e| ProtocolError::Session(format!("re-decode payload: {e}")))?;
let mldsa_sig = identity.mldsa_sign(&signed_bytes(&payload, signed_at_ms));
env.mldsa_pubkey_b64 = Some(B64.encode(identity.mldsa_public_bytes()));
env.mldsa_signature_b64 = Some(B64.encode(mldsa_sig));
Ok(env)
}
pub fn verify_signed_mldsa(env: &SignedRoomMessage, pinned_mldsa_pubkey: &[u8]) -> Result<bool> {
let (pk_b64, sig_b64) = match (&env.mldsa_pubkey_b64, &env.mldsa_signature_b64) {
(Some(p), Some(s)) => (p, s),
_ => return Ok(false),
};
let pk = B64
.decode(pk_b64)
.map_err(|e| ProtocolError::Session(format!("bad mldsa_pubkey_b64: {e}")))?;
if pk.as_slice() != pinned_mldsa_pubkey {
return Err(ProtocolError::Session(
"ML-DSA key in envelope does not match the pinned key for this signer \
(post-quantum downgrade or forgery) — rejecting"
.into(),
));
}
let sig = B64
.decode(sig_b64)
.map_err(|e| ProtocolError::Session(format!("bad mldsa_signature_b64: {e}")))?;
let payload = B64
.decode(&env.payload_b64)
.map_err(|e| ProtocolError::Session(format!("bad payload_b64: {e}")))?;
if crate::crypto::mldsa::verify(&pk, &signed_bytes(&payload, env.signed_at_ms), &sig) {
Ok(true)
} else {
Err(ProtocolError::Session(
"ML-DSA signature failed to verify over the envelope's signed bytes".into(),
))
}
}
fn signed_bytes(payload: &[u8], signed_at_ms: i64) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 24);
out.extend_from_slice(payload);
out.extend_from_slice(b"|huddle-signed-v1|");
out.extend_from_slice(&signed_at_ms.to_be_bytes());
out
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::IdentityKeys;
fn sample_msg() -> RoomMessage {
RoomMessage::MemberLeave {
sender_fingerprint: "test-fp".into(),
room_id: None,
}
}
#[test]
fn sign_verify_round_trip() {
let id = IdentityKeys::generate().unwrap();
let env = sign_message(&id, &sample_msg()).unwrap();
let (msg, fp) = verify_signed(&env).unwrap();
assert_eq!(fp, id.fingerprint());
assert!(matches!(msg, RoomMessage::MemberLeave { .. }));
}
#[test]
fn tampered_payload_fails() {
let id = IdentityKeys::generate().unwrap();
let mut env = sign_message(&id, &sample_msg()).unwrap();
let other = serde_json::to_vec(&RoomMessage::Typing {
sender_fingerprint: "evil-fp".into(),
})
.unwrap();
env.payload_b64 = B64.encode(&other);
assert!(verify_signed(&env).is_err());
}
#[test]
fn tampered_timestamp_fails_signature() {
let id = IdentityKeys::generate().unwrap();
let now_ms = 1_700_000_000_000_i64;
let mut env = sign_message_at(&id, &sample_msg(), now_ms).unwrap();
env.signed_at_ms = now_ms + 1;
let err = verify_signed_at(&env, now_ms, SIGNED_ENVELOPE_WINDOW_MS).unwrap_err();
let s = format!("{err}");
assert!(s.contains("signature verify failed"), "got: {s}");
}
#[test]
fn fingerprint_pubkey_mismatch_fails() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let mut env = sign_message(&alice, &sample_msg()).unwrap();
env.fingerprint = bob.fingerprint().to_string();
assert!(verify_signed(&env).is_err());
}
#[test]
fn swapped_pubkey_fails_signature() {
let alice = IdentityKeys::generate().unwrap();
let bob = IdentityKeys::generate().unwrap();
let mut env = sign_message(&alice, &sample_msg()).unwrap();
env.ed25519_pubkey_b64 = B64.encode(bob.public_bytes());
env.fingerprint = bob.fingerprint().to_string();
assert!(verify_signed(&env).is_err());
}
#[test]
fn missing_timestamp_rejected() {
let id = IdentityKeys::generate().unwrap();
let mut env = sign_message(&id, &sample_msg()).unwrap();
env.signed_at_ms = 0;
assert!(verify_signed(&env).is_err());
}
#[test]
fn outside_window_rejected() {
let id = IdentityKeys::generate().unwrap();
let signed_at = 1_700_000_000_000_i64;
let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
let now = signed_at + 6 * 60 * 1000;
assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err());
let now = signed_at + 4 * 60 * 1000;
assert!(verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok());
}
#[test]
fn hybrid_pq_sign_verify_round_trip() {
let id = IdentityKeys::generate().unwrap();
let env = sign_message(&id, &sample_msg()).unwrap();
assert!(!verify_signed_mldsa(&env, &id.mldsa_public_bytes()).unwrap());
let henv = sign_message_hybrid_pq(&id, &sample_msg()).unwrap();
assert!(verify_signed(&henv).is_ok());
assert!(verify_signed_mldsa(&henv, &id.mldsa_public_bytes()).unwrap());
}
#[test]
fn hybrid_pq_downgrade_and_forgery_rejected() {
let id = IdentityKeys::generate().unwrap();
let other = IdentityKeys::generate().unwrap();
let henv = sign_message_hybrid_pq(&id, &sample_msg()).unwrap();
assert!(verify_signed_mldsa(&henv, &other.mldsa_public_bytes()).is_err());
let mut bad = henv.clone();
let mut sig = B64
.decode(bad.mldsa_signature_b64.as_ref().unwrap())
.unwrap();
sig[0] ^= 1;
bad.mldsa_signature_b64 = Some(B64.encode(&sig));
assert!(verify_signed_mldsa(&bad, &id.mldsa_public_bytes()).is_err());
}
#[test]
fn store_and_forward_types_exempt_from_window() {
let id = IdentityKeys::generate().unwrap();
let signed_at = 1_700_000_000_000_i64;
let now = signed_at + 30 * 24 * 60 * 60 * 1000;
let contact = RoomMessage::ContactRequest {
requester_fingerprint: id.fingerprint().to_string(),
display_name: Some("late arrival".into()),
note: None,
sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
};
let env = sign_message_at(&id, &contact, signed_at).unwrap();
assert!(
verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
"a mailboxed ContactRequest must verify regardless of age"
);
let announce = RoomMessage::MemberAnnounce {
sender_fingerprint: id.fingerprint().to_string(),
wrapped_session_key: Some("d2VsbA==".into()),
display_name: None,
sender_ed25519_pubkey: Some(B64.encode(id.public_bytes())),
sender_mlkem_pubkey: None,
mlkem_ciphertext: None,
};
let env = sign_message_at(&id, &announce, signed_at).unwrap();
assert!(
verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_ok(),
"a mailboxed MemberAnnounce (carries the session key) must verify regardless of age"
);
let env = sign_message_at(&id, &sample_msg(), signed_at).unwrap();
assert!(
verify_signed_at(&env, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
"non-store-and-forward types must still be window-checked"
);
let mut bad = sign_message_at(&id, &contact, signed_at).unwrap();
bad.signature_b64 = B64.encode([0u8; 64]);
assert!(
verify_signed_at(&bad, now, SIGNED_ENVELOPE_WINDOW_MS).is_err(),
"an exempt type with a bad signature must still be rejected"
);
}
}