168 lines
5.6 KiB
Rust
168 lines
5.6 KiB
Rust
//! Relay-side (callee) cryptographic handshake.
|
|
//!
|
|
//! Performs the callee role of the WarzonePhone key exchange:
|
|
//! recv `CallOffer` → verify → generate ephemeral → derive session → send `CallAnswer`.
|
|
|
|
use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange};
|
|
use wzp_proto::{MediaTransport, QualityProfile, SignalMessage, default_signal_version};
|
|
|
|
/// Accept the relay (callee) side of the cryptographic handshake.
|
|
///
|
|
/// 1. Receive `CallOffer` from client
|
|
/// 2. Verify caller's signature over `(ephemeral_pub || "call-offer")`
|
|
/// 3. Generate our own ephemeral X25519 keypair
|
|
/// 4. Sign `(ephemeral_pub || "call-answer")` with our identity key
|
|
/// 5. Derive shared ChaCha20-Poly1305 session
|
|
/// 6. Send `CallAnswer` back
|
|
///
|
|
/// Returns the derived `CryptoSession`, the chosen `QualityProfile`, the caller's fingerprint,
|
|
/// and the caller's alias (if provided in CallOffer).
|
|
pub async fn accept_handshake(
|
|
transport: &dyn MediaTransport,
|
|
seed: &[u8; 32],
|
|
) -> Result<
|
|
(
|
|
Box<dyn CryptoSession>,
|
|
QualityProfile,
|
|
String,
|
|
Option<String>,
|
|
),
|
|
anyhow::Error,
|
|
> {
|
|
// 1. Receive CallOffer
|
|
let offer = transport
|
|
.recv_signal()
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
|
|
|
|
let (
|
|
caller_identity_pub,
|
|
caller_ephemeral_pub,
|
|
caller_signature,
|
|
supported_profiles,
|
|
caller_alias,
|
|
protocol_version,
|
|
) = match offer {
|
|
SignalMessage::CallOffer {
|
|
identity_pub,
|
|
ephemeral_pub,
|
|
signature,
|
|
supported_profiles,
|
|
alias,
|
|
protocol_version,
|
|
supported_versions: _,
|
|
..
|
|
} => (
|
|
identity_pub,
|
|
ephemeral_pub,
|
|
signature,
|
|
supported_profiles,
|
|
alias,
|
|
protocol_version,
|
|
),
|
|
other => {
|
|
return Err(anyhow::anyhow!(
|
|
"expected CallOffer, got {:?}",
|
|
std::mem::discriminant(&other)
|
|
));
|
|
}
|
|
};
|
|
|
|
// 1a. Protocol version check — we only speak v2.
|
|
if protocol_version != 2 {
|
|
let mismatch = SignalMessage::Hangup {
|
|
version: default_signal_version(),
|
|
reason: wzp_proto::HangupReason::ProtocolVersionMismatch {
|
|
server_supported: vec![2],
|
|
},
|
|
call_id: None,
|
|
};
|
|
let _ = transport.send_signal(&mismatch).await;
|
|
return Err(anyhow::anyhow!(
|
|
"protocol version mismatch: client requested {protocol_version}, server supports [2]"
|
|
));
|
|
}
|
|
|
|
// 2. Verify caller's signature over (ephemeral_pub || "call-offer")
|
|
let mut verify_data = Vec::with_capacity(32 + 10);
|
|
verify_data.extend_from_slice(&caller_ephemeral_pub);
|
|
verify_data.extend_from_slice(b"call-offer");
|
|
if !WarzoneKeyExchange::verify(&caller_identity_pub, &verify_data, &caller_signature) {
|
|
return Err(anyhow::anyhow!("caller signature verification failed"));
|
|
}
|
|
|
|
// 3. Create our key exchange and generate ephemeral
|
|
let mut kx = WarzoneKeyExchange::from_identity_seed(seed);
|
|
let identity_pub = kx.identity_public_key();
|
|
let ephemeral_pub = kx.generate_ephemeral();
|
|
|
|
// 4. Sign (ephemeral_pub || "call-answer")
|
|
let mut sign_data = Vec::with_capacity(32 + 11);
|
|
sign_data.extend_from_slice(&ephemeral_pub);
|
|
sign_data.extend_from_slice(b"call-answer");
|
|
let signature = kx.sign(&sign_data);
|
|
|
|
// 5. Derive session from caller's ephemeral public key
|
|
let session = kx.derive_session(&caller_ephemeral_pub)?;
|
|
|
|
// Choose the best supported profile (prefer GOOD > DEGRADED > CATASTROPHIC)
|
|
let chosen_profile = choose_profile(&supported_profiles);
|
|
|
|
// 6. Send CallAnswer
|
|
let answer = SignalMessage::CallAnswer {
|
|
version: default_signal_version(),
|
|
identity_pub,
|
|
ephemeral_pub,
|
|
signature,
|
|
chosen_profile,
|
|
};
|
|
transport.send_signal(&answer).await?;
|
|
|
|
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
|
|
// Must match the format used in signal registration and presence.
|
|
let caller_fp = {
|
|
use sha2::{Digest, Sha256};
|
|
let hash = Sha256::digest(&caller_identity_pub);
|
|
let fp = wzp_crypto::Fingerprint([
|
|
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8],
|
|
hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
|
|
]);
|
|
fp.to_string()
|
|
};
|
|
|
|
Ok((session, chosen_profile, caller_fp, caller_alias))
|
|
}
|
|
|
|
/// Select the best quality profile from those the caller supports.
|
|
///
|
|
/// The `_supported` list is currently ignored — we hardcode GOOD (24k) until
|
|
/// studio tiers (32k/48k/64k) have been validated across federation (large
|
|
/// packets may exceed path MTU and fragment in unpleasant ways). Once that's
|
|
/// tested, the body should pick the highest supported profile ≤ the relay's
|
|
/// configured ceiling.
|
|
fn choose_profile(_supported: &[QualityProfile]) -> QualityProfile {
|
|
QualityProfile::GOOD
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn choose_profile_picks_highest_bitrate() {
|
|
let profiles = vec![
|
|
QualityProfile::CATASTROPHIC,
|
|
QualityProfile::GOOD,
|
|
QualityProfile::DEGRADED,
|
|
];
|
|
let chosen = choose_profile(&profiles);
|
|
assert_eq!(chosen, QualityProfile::GOOD);
|
|
}
|
|
|
|
#[test]
|
|
fn choose_profile_empty_defaults_to_good() {
|
|
let chosen = choose_profile(&[]);
|
|
assert_eq!(chosen, QualityProfile::GOOD);
|
|
}
|
|
}
|