diff --git a/Cargo.lock b/Cargo.lock index 053fe8690..4eb3d2605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2406,6 +2406,7 @@ dependencies = [ "uuid", "wacore", "wacore-binary", + "wacore-noise", "waproto", "whatsapp-rust-sqlite-storage", "whatsapp-rust-tokio-transport", diff --git a/Cargo.toml b/Cargo.toml index b20db5ad6..3b5f10abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,9 +92,6 @@ wacore-noise = { path = "./wacore/noise", version = "0.5.0" } waproto = { path = "./waproto", version = "0.5.0" } yoke = { version = "0.8", features = ["derive"] } -[lints] -workspace = true - [features] debug-diagnostics = ["wacore/debug-diagnostics"] debug-snapshots = ["wacore/debug-snapshots"] @@ -150,6 +147,10 @@ whatsapp-rust-ureq-http-client = { path = "./http_clients/ureq-client", version [dev-dependencies] uuid = { workspace = true, features = ["v4"] } +wacore-noise = { path = "./wacore/noise", features = ["test-util"] } + +[lints] +workspace = true [[example]] name = "benchmark" diff --git a/agent_docs/protocol_architecture.md b/agent_docs/protocol_architecture.md index 5f1e498b4..51febdc83 100644 --- a/agent_docs/protocol_architecture.md +++ b/agent_docs/protocol_architecture.md @@ -125,3 +125,63 @@ wacore/src/iq/ ``` Each feature file contains: constants, enums (`StringEnum`), request/response structs (`ProtocolNode`), `IqSpec` impls, and unit tests. + +## Noise Handshake Patterns + +Three Noise patterns coexist, mirroring WA Web's `WAWebOpenChatSocket`: + +| Pattern | When | State machine | Cost | +| -------------- | ------------------------------------------------------------- | --------------------------- | --------------------------------- | +| **XX** | First connect / pairing / forced fallback | `XxHandshakeState` | 1.5 RTT | +| **IK** | Reconnect with valid cached `serverStaticPub` | `IkHandshakeState` | 1 RTT, ships 0-RTT login payload | +| **XXfallback** | Server rejects in-flight IK (reply has `static != null`) | `XxFallbackHandshakeState` | 1 RTT (reuses already-sent eph.) | + +### Selection (`src/handshake.rs::select_pattern`) + +```text +ik_failures >= 1 ───────────────────────────────────────► XX +no cached server_cert_chain ─────────────────────────────► XX +leaf.not_after < now OR intermediate.not_after < now ────► XX +otherwise ──────────────────────────────────────────────► IK with leaf.key +``` + +The counter `Client.ik_handshake_failures: AtomicU32` is per-process and +not persisted (matches WA Web's `K = 0` reset on process start). + +### Invalidation policy + +| Error | `ik_handshake_failures` | `server_cert_chain` | +| -------------------------------------------------- | ----------------------- | ------------------------------------------------ | +| Transient (timeout, disconnect, transport) | unchanged | unchanged | +| Crypto-fatal during IK (cert MAC, decrypt, proto) | `+= 1` | cleared via `DeviceCommand::ClearServerCertChain`| +| XX or XX-fallback failure | unchanged | unchanged (XX never reads the cache) | +| Any successful handshake | reset to `0` | repopulated (XX, XX-fallback) or kept (IK Continue)| + +Distinguishing transient from crypto-fatal is via `HandshakeError::is_transient()` +and `HandshakeError::is_crypto_fatal()`. Getting the classification wrong leads +to either oscillating back to XX needlessly or looping on a stale cache. + +### Persisted state (`Device.server_cert_chain`) + +`CachedServerCertChain { intermediate, leaf }` with each cert reduced to +`{ key: [u8; 32], not_before: i64, not_after: i64 }`. Mirrors the +storage layout WA Web uses in `PrefsInfoStore.js:setCertificateChain` — +only those fields end up on disk. + +`verify_server_cert` checks structural shape, the issuer-serial pin, the +chain link, and that `leaf.key` matches the decrypted Noise static. +Ed25519 signature verification against `WA_CERT_PUB_KEY` is intentionally +skipped (would break the e2e mock server). Same posture as whatsmeow. + +### Logs (matching WA Web's `[socket]` lines) + +```text +[socket] doFullHandshake: openChatSocket send hello +[socket] resumeNoiseHandshake started +[socket] resumeNoiseHandshake send hello +[socket] resumeNoiseHandshake rcv hello +[socket] resumeNoiseHandshake deriving secrets +[socket] resumeNoiseHandshake failed: serverStaticCiphertext not null — + doFallbackHandshake continuing handshake with given server hello +[socket] continueFullHandshakeCore client finish and deriving secrets +``` diff --git a/src/client.rs b/src/client.rs index abf6132be..69b59ef82 100644 --- a/src/client.rs +++ b/src/client.rs @@ -310,6 +310,14 @@ pub struct Client { /// Uses an AtomicBool instead of probing the noise_socket mutex to avoid /// TOCTOU races where `try_lock()` fails due to contention, not disconnection. is_connected: Arc, + + /// Per-process counter of consecutive Noise IK handshake failures, scoped + /// to the lifetime of this `Client`. Mirrors `K` in WA Web's + /// `WAWebOpenChatSocket` (`ChatSocket.js`): on the first failure within a + /// process, the next connect skips IK and falls back to XX so a stale + /// cached `serverStaticPublic` doesn't trap us in a loop. Reset to 0 on + /// any successful handshake (XX, IK, or XXfallback). + pub(crate) ik_handshake_failures: Arc, /// Terminal shutdown (process-wide). Fired ONLY by `disconnect()`. /// Long-lived subscribers that must outlive reconnect cycles (saver, /// device registry cleanup) subscribe here. @@ -732,6 +740,7 @@ impl Client { is_connecting: Arc::new(AtomicBool::new(false)), is_running: Arc::new(AtomicBool::new(false)), is_connected: Arc::new(AtomicBool::new(false)), + ik_handshake_failures: Arc::new(AtomicU32::new(0)), shutdown_notifier: wacore::runtime::ShutdownNotifier::new(), connection_shutdown: std::sync::Mutex::new(wacore::runtime::ShutdownNotifier::new()), last_data_received_ms: Arc::new(AtomicU64::new(0)), @@ -1197,11 +1206,10 @@ impl Client { })??; debug!("Version fetch and transport connection established."); - let device_snapshot = self.persistence_manager.get_device_snapshot().await; - let noise_socket = match handshake::do_handshake( self.runtime.clone(), - &device_snapshot, + &self.persistence_manager, + &self.ik_handshake_failures, transport.clone(), &mut transport_events, ) diff --git a/src/handshake.rs b/src/handshake.rs index f3f4ee1d2..20a3cbebb 100644 --- a/src/handshake.rs +++ b/src/handshake.rs @@ -1,18 +1,26 @@ use crate::socket::NoiseSocket; +use crate::store::persistence_manager::PersistenceManager; use crate::transport::{Transport, TransportEvent}; use log::{debug, info, warn}; use prost::Message; use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; use thiserror::Error; use wacore::handshake::{ - HandshakeError as CoreHandshakeError, HandshakeState, build_handshake_header, + HandshakeError as CoreHandshakeError, IkHandshakeState, IkServerHelloOutcome, + VerifiedServerCertChain, XxFallbackHandshakeState, XxHandshakeState, build_handshake_header, }; +use wacore::noise::NoiseCipher; use wacore::runtime::{Runtime, timeout as rt_timeout}; -use wacore_binary::consts::{NOISE_START_PATTERN, WA_CONN_HEADER}; +use wacore::store::DeviceCommand; +use wacore_binary::consts::WA_CONN_HEADER; const NOISE_HANDSHAKE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(20); +/// One IK failure per process before falling back to XX (matches WA Web). +const IK_FAILURE_THRESHOLD: u32 = 1; + #[derive(Debug, Error)] pub enum HandshakeError { #[error("Transport error: {0}")] @@ -21,6 +29,13 @@ pub enum HandshakeError { Core(#[from] CoreHandshakeError), #[error("Timed out waiting for handshake response")] Timeout, + /// Producer side of `transport_events` was dropped — distinct from a + /// timeout because nothing more will ever arrive on the channel, + /// regardless of how long we wait. Surfaced separately so callers can + /// log it accurately and so retry policies that pace themselves on + /// timeout don't silently swallow a teardown. + #[error("Transport event stream closed before handshake completed")] + StreamClosed, #[error("Disconnected during handshake")] Disconnected, #[error("Unexpected event during handshake: {0}")] @@ -28,97 +43,523 @@ pub enum HandshakeError { } impl HandshakeError { - /// Transient errors that are expected during reconnect and will resolve on retry. + /// Transient errors that are expected during reconnect and will resolve + /// on retry. These never invalidate the cached server static. pub fn is_transient(&self) -> bool { matches!( self, - Self::Transport(_) | Self::Timeout | Self::Disconnected + Self::Transport(_) | Self::Timeout | Self::Disconnected | Self::StreamClosed ) } + + /// Crypto-fatal: a cached server static or cert chain is no longer + /// trustworthy. The orchestration layer must clear the IK cache and + /// fall back to XX on the next attempt. + /// + /// Narrowed to the `Core` variants that actually point at a stale or + /// poisoned cache. Programmer-side bugs (`Proto` encode failure, our + /// own crypto provider misuse, HKDF impossible failure, counter + /// exhaustion in a single handshake) are NOT crypto-fatal — they + /// indicate a code defect, and clearing the cache would mask it. A + /// stream-closed event during recv is treated as transient by + /// `is_transient`, not here. + pub fn is_crypto_fatal(&self) -> bool { + let Self::Core(inner) = self else { + return false; + }; + use wacore::handshake::HandshakeError as Core; + use wacore::noise::NoiseError; + match inner { + // Server-supplied bytes failed AEAD authentication or had the + // wrong shape — canonical "the static we used to derive ee/se + // doesn't actually belong to this server" signal. + Core::Noise(NoiseError::Decrypt(_)) + | Core::Noise(NoiseError::CiphertextTooShort) + | Core::Noise(NoiseError::InvalidKeyLength { .. }) => true, + // Cert content didn't match the static we just decrypted, or + // the chain was structurally invalid. + Core::CertVerification(_) => true, + // Server sent a structurally invalid response. Either it's + // out of sync with our cached static or it has a real bug; + // either way IK won't recover, so fall back. + Core::IncompleteResponse + | Core::InvalidLength { .. } + | Core::InvalidKeyLength + | Core::ProtoDecode(_) => true, + // Programmer-side: our encode shouldn't fail with a valid + // Device, our own crypto provider shouldn't reject our own + // inputs, HKDF can't reasonably fail, and a single handshake + // can't exhaust the counter. None of these mean the cache + // is bad. + Core::Proto(_) + | Core::Crypto(_) + | Core::Noise(NoiseError::Encrypt(_)) + | Core::Noise(NoiseError::HkdfExpandFailed) + | Core::Noise(NoiseError::InvalidPatternLength { .. }) + | Core::Noise(NoiseError::CounterExhausted) => false, + } + } } type Result = std::result::Result; +/// Pattern picked at the start of a handshake based on cached state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HandshakePattern { + /// Cold start / pairing / forced fallback after an earlier IK failure. + Xx, + /// Cached server static + valid cert chain available; attempt IK. + Ik([u8; 32]), +} + +fn select_pattern( + device: &wacore::store::Device, + ik_failures: u32, + now_secs: i64, +) -> HandshakePattern { + // Unregistered + cached chain is a signal of a legacy DB written before + // the registration gate; `do_handshake` no longer creates that state but + // we still need to refuse IK against it. + if !device.is_registered() { + return HandshakePattern::Xx; + } + if ik_failures >= IK_FAILURE_THRESHOLD { + return HandshakePattern::Xx; + } + let Some(chain) = device.server_cert_chain.as_ref() else { + return HandshakePattern::Xx; + }; + // `not_before` covers backwards clock skew, `not_after` is normal expiry. + if now_secs < chain.leaf.not_before + || now_secs < chain.intermediate.not_before + || now_secs >= chain.leaf.not_after + || now_secs >= chain.intermediate.not_after + { + return HandshakePattern::Xx; + } + HandshakePattern::Ik(chain.leaf.key) +} + +/// `server_cert_chain` is `Some` for XX / XX-fallback (fresh chain to persist) +/// and `None` for IK Continue (on-disk cache stays authoritative). +struct HandshakeSuccess { + write_cipher: NoiseCipher, + read_cipher: NoiseCipher, + server_cert_chain: Option, +} + +fn should_persist_cert_chain(device: &wacore::store::Device) -> bool { + device.is_registered() +} + pub async fn do_handshake( runtime: Arc, - device: &crate::store::Device, + persistence_manager: &PersistenceManager, + ik_handshake_failures: &AtomicU32, transport: Arc, transport_events: &mut async_channel::Receiver, ) -> Result> { - // Prepare the client payload (convert Device-specific data to bytes) - let client_payload = device.core.get_client_payload().encode_to_vec(); + let device_snapshot = persistence_manager.get_device_snapshot().await; + let now_secs = wacore::time::now_secs(); + let pattern = select_pattern( + &device_snapshot, + ik_handshake_failures.load(Ordering::Acquire), + now_secs, + ); + + let mut fallback_taken = false; + + let result = match pattern { + HandshakePattern::Xx => { + debug!("[socket] doFullHandshake: openChatSocket send hello"); + run_xx_handshake( + &runtime, + &device_snapshot, + transport.clone(), + transport_events, + ) + .await + } + HandshakePattern::Ik(server_static_pub) => { + debug!("[socket] resumeNoiseHandshake started"); + run_ik_handshake( + &runtime, + &device_snapshot, + server_static_pub, + transport.clone(), + transport_events, + &mut fallback_taken, + ) + .await + } + }; + + match result { + Ok(success) => { + if let Some(chain) = success.server_cert_chain + && should_persist_cert_chain(&device_snapshot) + { + persistence_manager + .process_command(DeviceCommand::SetServerCertChain(chain.into())) + .await; + } + ik_handshake_failures.store(0, Ordering::Release); + Ok(Arc::new(NoiseSocket::new( + runtime, + transport, + success.write_cipher, + success.read_cipher, + ))) + } + Err(e) => { + // Skip invalidation past the XXfallback pivot: by that point the + // server has already accepted our IK ClientHello and the cache + // is no longer the implicated party. + if matches!(pattern, HandshakePattern::Ik(_)) && !fallback_taken && e.is_crypto_fatal() + { + warn!( + "[socket] resumeNoiseHandshake failed crypto-fatally; \ + clearing cached server cert chain and forcing XX next connect: {e}" + ); + ik_handshake_failures.fetch_add(1, Ordering::AcqRel); + persistence_manager + .process_command(DeviceCommand::ClearServerCertChain) + .await; + } + Err(e) + } + } +} + +async fn run_xx_handshake( + runtime: &Arc, + device: &wacore::store::Device, + transport: Arc, + transport_events: &mut async_channel::Receiver, +) -> Result { + let client_payload = device.get_client_payload().encode_to_vec(); + let mut handshake_state = + XxHandshakeState::new(device.noise_key.clone(), client_payload, &WA_CONN_HEADER)?; + let mut frame_decoder = wacore::framing::FrameDecoder::new(); + + let client_hello_bytes = handshake_state.build_client_hello()?; + send_first_handshake_message(&transport, device, &client_hello_bytes).await?; + + let resp_frame = recv_frame(runtime, transport_events, &mut frame_decoder).await?; + debug!("[socket] openChatSocket rcv hello"); + + let client_finish_bytes = + handshake_state.read_server_hello_and_build_client_finish(&resp_frame)?; - let mut handshake_state = HandshakeState::new( - device.core.noise_key.clone(), + debug!("[socket] continueFullHandshakeCore client finish and deriving secrets"); + let framed = wacore::framing::encode_frame(&client_finish_bytes, None) + .map_err(HandshakeError::Transport)?; + transport.send(bytes::Bytes::from(framed)).await?; + + let outcome = handshake_state.finish()?; + info!("Handshake complete (XX), switching to encrypted communication"); + + Ok(HandshakeSuccess { + write_cipher: outcome.write_cipher, + read_cipher: outcome.read_cipher, + server_cert_chain: Some(outcome.server_cert_chain), + }) +} + +/// `fallback_taken` is set to `true` once we pivot from IK to XXfallback, +/// before any operation that could fail. +async fn run_ik_handshake( + runtime: &Arc, + device: &wacore::store::Device, + server_static_pub: [u8; 32], + transport: Arc, + transport_events: &mut async_channel::Receiver, + fallback_taken: &mut bool, +) -> Result { + let client_payload = device.get_client_payload().encode_to_vec(); + let mut ik = IkHandshakeState::new( + device.noise_key.clone(), + server_static_pub, client_payload, - NOISE_START_PATTERN, &WA_CONN_HEADER, )?; let mut frame_decoder = wacore::framing::FrameDecoder::new(); - debug!("--> Sending ClientHello"); - let client_hello_bytes = handshake_state.build_client_hello()?; + debug!("[socket] resumeNoiseHandshake send hello"); + let client_hello_bytes = ik.build_client_hello()?; + send_first_handshake_message(&transport, device, &client_hello_bytes).await?; + + let resp_frame = recv_frame(runtime, transport_events, &mut frame_decoder).await?; + debug!("[socket] resumeNoiseHandshake rcv hello"); - // Build the connection header, optionally with edge routing pre-intro - let (header, used_edge_routing) = - build_handshake_header(device.core.edge_routing_info.as_deref()); + match ik.read_server_hello(&resp_frame)? { + IkServerHelloOutcome::Continue(out) => { + debug!("[socket] resumeNoiseHandshake deriving secrets"); + info!("Handshake complete (IK), switching to encrypted communication"); + Ok(HandshakeSuccess { + write_cipher: out.write_cipher, + read_cipher: out.read_cipher, + server_cert_chain: None, + }) + } + IkServerHelloOutcome::Fallback(inputs) => { + *fallback_taken = true; + warn!( + "[socket] resumeNoiseHandshake failed: serverStaticCiphertext not null — \ + doFallbackHandshake continuing handshake with given server hello" + ); + let mut fb = XxFallbackHandshakeState::from_ik_failure(*inputs, &WA_CONN_HEADER)?; + let client_finish_bytes = fb.build_client_finish()?; + debug!( + "[socket] continueFullHandshakeCore client finish and deriving secrets (XXfallback)" + ); + let framed = wacore::framing::encode_frame(&client_finish_bytes, None) + .map_err(HandshakeError::Transport)?; + transport.send(bytes::Bytes::from(framed)).await?; + let outcome = fb.finish()?; + info!("Handshake complete (XXfallback), switching to encrypted communication"); + Ok(HandshakeSuccess { + write_cipher: outcome.write_cipher, + read_cipher: outcome.read_cipher, + server_cert_chain: Some(outcome.server_cert_chain), + }) + } + } +} + +async fn send_first_handshake_message( + transport: &Arc, + device: &wacore::store::Device, + payload_bytes: &[u8], +) -> Result<()> { + let (header, used_edge_routing) = build_handshake_header(device.edge_routing_info.as_deref()); if used_edge_routing { debug!("Sending edge routing pre-intro for optimized reconnection"); - } else if device.core.edge_routing_info.is_some() { + } else if device.edge_routing_info.is_some() { warn!("Edge routing info provided but not used (possibly too large)"); } - - // First message includes the WA connection header (with optional edge routing) - let framed = wacore::framing::encode_frame(&client_hello_bytes, Some(&header)) + let framed = wacore::framing::encode_frame(payload_bytes, Some(&header)) .map_err(HandshakeError::Transport)?; transport.send(bytes::Bytes::from(framed)).await?; + Ok(()) +} - // Wait for server response frame - let resp_frame = loop { +async fn recv_frame( + runtime: &Arc, + transport_events: &mut async_channel::Receiver, + frame_decoder: &mut wacore::framing::FrameDecoder, +) -> Result { + loop { match rt_timeout( - &*runtime, + &**runtime, NOISE_HANDSHAKE_RESPONSE_TIMEOUT, transport_events.recv(), ) .await { Ok(Ok(TransportEvent::DataReceived(data))) => { - // Feed data into decoder frame_decoder.feed(&data); - - // Try to decode a frame if let Some(frame) = frame_decoder.decode_frame() { - break frame; + return Ok(frame); } - // If no complete frame yet, continue waiting for more data - continue; - } - Ok(Ok(TransportEvent::Connected)) => { - // Ignore Connected event, we're already connected continue; } - Ok(Ok(TransportEvent::Disconnected)) => { - return Err(HandshakeError::Disconnected); - } - Ok(Err(_)) => return Err(HandshakeError::Timeout), // Channel closed + Ok(Ok(TransportEvent::Connected)) => continue, + Ok(Ok(TransportEvent::Disconnected)) => return Err(HandshakeError::Disconnected), + // Channel closed (no more producers) — distinct from a real timeout. + Ok(Err(_)) => return Err(HandshakeError::StreamClosed), Err(_) => return Err(HandshakeError::Timeout), } - }; + } +} - debug!("<-- Received handshake response, building ClientFinish"); - let client_finish_bytes = - handshake_state.read_server_hello_and_build_client_finish(&resp_frame)?; +#[cfg(test)] +mod tests { + use super::*; + use wacore::store::CachedNoiseCert; + use wacore::store::CachedServerCertChain; - debug!("--> Sending ClientFinish"); - // Subsequent messages don't need the header - let framed = wacore::framing::encode_frame(&client_finish_bytes, None) - .map_err(HandshakeError::Transport)?; - transport.send(bytes::Bytes::from(framed)).await?; + fn cached_chain( + leaf_key: [u8; 32], + leaf_not_after: i64, + intermediate_not_after: i64, + ) -> CachedServerCertChain { + CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xCC; 32], + not_before: 1_700_000_000, + not_after: intermediate_not_after, + }, + leaf: CachedNoiseCert { + key: leaf_key, + not_before: 1_700_000_000, + not_after: leaf_not_after, + }, + } + } + + fn paired_device() -> wacore::store::Device { + let mut device = wacore::store::Device::new(); + device.pn = Some("12345@s.whatsapp.net".parse().unwrap()); + device + } - let (write_key, read_key) = handshake_state.finish()?; - info!("Handshake complete, switching to encrypted communication"); + #[test] + fn select_pattern_no_cache_returns_xx() { + let device = paired_device(); + assert_eq!( + select_pattern(&device, 0, 1_800_000_000), + HandshakePattern::Xx + ); + } - Ok(Arc::new(NoiseSocket::new( - runtime, transport, write_key, read_key, - ))) + #[test] + fn select_pattern_with_valid_cache_returns_ik() { + let mut device = paired_device(); + let pub_key = [0xAA; 32]; + device.server_cert_chain = Some(cached_chain(pub_key, 1_900_000_000, 1_900_000_000)); + assert_eq!( + select_pattern(&device, 0, 1_800_000_000), + HandshakePattern::Ik(pub_key) + ); + } + + #[test] + fn select_pattern_after_one_failure_returns_xx() { + let mut device = paired_device(); + device.server_cert_chain = Some(cached_chain([0xAA; 32], 1_900_000_000, 1_900_000_000)); + assert_eq!( + select_pattern(&device, IK_FAILURE_THRESHOLD, 1_800_000_000), + HandshakePattern::Xx + ); + } + + #[test] + fn select_pattern_with_expired_leaf_returns_xx() { + let mut device = paired_device(); + device.server_cert_chain = Some(cached_chain([0xAA; 32], 1_700_000_500, 1_900_000_000)); + assert_eq!( + select_pattern(&device, 0, 1_800_000_000), + HandshakePattern::Xx + ); + } + + #[test] + fn select_pattern_with_expired_intermediate_returns_xx() { + let mut device = paired_device(); + device.server_cert_chain = Some(cached_chain([0xAA; 32], 1_900_000_000, 1_700_000_500)); + assert_eq!( + select_pattern(&device, 0, 1_800_000_000), + HandshakePattern::Xx + ); + } + + #[test] + fn select_pattern_with_clock_before_leaf_not_before_returns_xx() { + let mut device = paired_device(); + device.server_cert_chain = Some(cached_chain([0xAA; 32], 1_900_000_000, 1_900_000_000)); + assert_eq!( + select_pattern(&device, 0, 1_699_999_999), + HandshakePattern::Xx + ); + } + + #[test] + fn select_pattern_with_clock_before_intermediate_not_before_returns_xx() { + let mut device = paired_device(); + let mut chain = cached_chain([0xAA; 32], 1_900_000_000, 1_900_000_000); + chain.intermediate.not_before = 1_800_000_001; + device.server_cert_chain = Some(chain); + assert_eq!( + select_pattern(&device, 0, 1_800_000_000), + HandshakePattern::Xx + ); + } + + #[test] + fn select_pattern_unregistered_device_returns_xx_even_with_valid_cache() { + let mut device = wacore::store::Device::new(); + assert!( + !device.is_registered(), + "fresh Device::new() must be unpaired" + ); + device.server_cert_chain = Some(cached_chain([0xAA; 32], 1_900_000_000, 1_900_000_000)); + assert_eq!( + select_pattern(&device, 0, 1_800_000_000), + HandshakePattern::Xx + ); + } + + #[test] + fn should_persist_cert_chain_unregistered_returns_false() { + let device = wacore::store::Device::new(); + assert!(!device.is_registered()); + assert!(!should_persist_cert_chain(&device)); + } + + #[test] + fn should_persist_cert_chain_registered_returns_true() { + let device = paired_device(); + assert!(device.is_registered()); + assert!(should_persist_cert_chain(&device)); + } + + #[test] + fn handshake_error_classification() { + // Transient — never invalidate the cache. + assert!(HandshakeError::Timeout.is_transient()); + assert!(HandshakeError::Disconnected.is_transient()); + assert!(HandshakeError::StreamClosed.is_transient()); + assert!(!HandshakeError::Timeout.is_crypto_fatal()); + assert!(!HandshakeError::Disconnected.is_crypto_fatal()); + assert!(!HandshakeError::StreamClosed.is_crypto_fatal()); + + // Stale-cache-indicating Core variants. + for err in [ + HandshakeError::Core(CoreHandshakeError::IncompleteResponse), + HandshakeError::Core(CoreHandshakeError::CertVerification("x".into())), + HandshakeError::Core(CoreHandshakeError::InvalidKeyLength), + ] { + assert!(err.is_crypto_fatal(), "{err:?} should be crypto-fatal"); + assert!(!err.is_transient(), "{err:?} should not be transient"); + } + + // Programmer-side bug: Crypto(String) wraps generic crypto-provider + // misuse; not a server-side cache problem. + let bug = HandshakeError::Core(CoreHandshakeError::Crypto("bug".into())); + assert!( + !bug.is_crypto_fatal(), + "generic Crypto(String) errors must not invalidate the cache" + ); + assert!(!bug.is_transient()); + } + + /// Both the XX and IK initial messages must travel inside a frame whose + /// prologue is `WA_CONN_HEADER` (optionally preceded by an edge-routing + /// pre-intro). The wire-side server validates this prologue when it + /// re-derives `h0` for transcript MAC checks, so any divergence between + /// the two paths would surface only as a generic AEAD failure. + /// + /// We compare by fingerprinting the header bytes returned by the shared + /// helper for the two relevant scenarios — IK and XX both must hit the + /// same builder, with edge-routing applied identically when present. + #[test] + fn xx_and_ik_share_same_first_frame_prologue() { + // No edge routing: pure WA_CONN_HEADER. + let (xx_header, xx_used) = wacore::handshake::build_handshake_header(None); + let (ik_header, ik_used) = wacore::handshake::build_handshake_header(None); + assert_eq!(xx_header, ik_header); + assert_eq!(xx_used, ik_used); + assert!(xx_header.starts_with(b"WA")); + + // With edge routing: pre-intro applied identically. + let routing = vec![0xDE, 0xAD, 0xBE, 0xEF]; + let (xx_h2, xx_used2) = wacore::handshake::build_handshake_header(Some(&routing)); + let (ik_h2, ik_used2) = wacore::handshake::build_handshake_header(Some(&routing)); + assert_eq!(xx_h2, ik_h2); + assert_eq!(xx_used2, ik_used2); + assert!(xx_used2); + assert!(xx_h2.starts_with(b"ED\x00\x01")); + assert!(xx_h2.ends_with(b"WA\x06\x03") || xx_h2.ends_with(b"WA\x06\x04")); + } } diff --git a/storages/sqlite-storage/migrations/2026-04-26-000000_add_server_cert_chain/down.sql b/storages/sqlite-storage/migrations/2026-04-26-000000_add_server_cert_chain/down.sql new file mode 100644 index 000000000..c12f263fd --- /dev/null +++ b/storages/sqlite-storage/migrations/2026-04-26-000000_add_server_cert_chain/down.sql @@ -0,0 +1 @@ +ALTER TABLE device DROP COLUMN server_cert_chain; diff --git a/storages/sqlite-storage/migrations/2026-04-26-000000_add_server_cert_chain/up.sql b/storages/sqlite-storage/migrations/2026-04-26-000000_add_server_cert_chain/up.sql new file mode 100644 index 000000000..f72c99c5c --- /dev/null +++ b/storages/sqlite-storage/migrations/2026-04-26-000000_add_server_cert_chain/up.sql @@ -0,0 +1 @@ +ALTER TABLE device ADD COLUMN server_cert_chain BLOB NULL; diff --git a/storages/sqlite-storage/src/schema.rs b/storages/sqlite-storage/src/schema.rs index e6028be66..9eaad3c0e 100644 --- a/storages/sqlite-storage/src/schema.rs +++ b/storages/sqlite-storage/src/schema.rs @@ -71,6 +71,7 @@ diesel::table! { next_pre_key_id -> Integer, nct_salt -> Nullable, server_has_prekeys -> Bool, + server_cert_chain -> Nullable, } } diff --git a/storages/sqlite-storage/src/sqlite_store.rs b/storages/sqlite-storage/src/sqlite_store.rs index a9921e8a3..5588a120e 100644 --- a/storages/sqlite-storage/src/sqlite_store.rs +++ b/storages/sqlite-storage/src/sqlite_store.rs @@ -82,6 +82,7 @@ struct DeviceRow { next_pre_key_id: i32, nct_salt: Option>, server_has_prekeys: bool, + server_cert_chain: Option>, } #[derive(Clone)] @@ -327,6 +328,15 @@ impl SqliteStore { let next_pre_key_id = device_data.next_pre_key_id as i32; let server_has_prekeys = device_data.server_has_prekeys; let nct_salt: Option> = device_data.nct_salt.as_deref().map(Arc::from); + let server_cert_chain: Option> = device_data + .server_cert_chain + .as_ref() + .map(|chain| { + bincode::serde::encode_to_vec(chain, bincode::config::standard()) + .map(Arc::from) + .map_err(|e| StoreError::Serialization(Box::new(e))) + }) + .transpose()?; let new_lid: Arc = Arc::from( device_data .lid @@ -355,6 +365,7 @@ impl SqliteStore { let edge_routing_info = edge_routing_info.clone(); let props_hash = props_hash.clone(); let nct_salt = nct_salt.clone(); + let server_cert_chain = server_cert_chain.clone(); let new_lid = Arc::clone(&new_lid); let new_pn = Arc::clone(&new_pn); @@ -382,6 +393,7 @@ impl SqliteStore { device::next_pre_key_id.eq(next_pre_key_id), device::server_has_prekeys.eq(server_has_prekeys), device::nct_salt.eq(nct_salt.as_deref()), + device::server_cert_chain.eq(server_cert_chain.as_deref()), )) .on_conflict(device::id) .do_update() @@ -408,6 +420,7 @@ impl SqliteStore { device::next_pre_key_id.eq(excluded(device::next_pre_key_id)), device::server_has_prekeys.eq(excluded(device::server_has_prekeys)), device::nct_salt.eq(excluded(device::nct_salt)), + device::server_cert_chain.eq(excluded(device::server_cert_chain)), )) .execute(conn) .map(|_| ()) @@ -469,6 +482,7 @@ impl SqliteStore { device::next_pre_key_id.eq(next_pre_key_id), device::server_has_prekeys.eq(server_has_prekeys), device::nct_salt.eq(None::<&[u8]>), + device::server_cert_chain.eq(None::<&[u8]>), )) .execute(conn) .map(|_| device_id) @@ -574,6 +588,31 @@ impl SqliteStore { server_has_prekeys: row.server_has_prekeys, nct_salt: row.nct_salt, nct_salt_sync_seen: false, + server_cert_chain: row + .server_cert_chain + .as_deref() + .and_then(|bytes| { + // The cert chain is a perf cache, not load-bearing + // identity. A corrupt blob (truncated row, format + // change between versions) must NOT block startup — + // log it and degrade to None so the next connect + // simply pays one XX handshake to repopulate. + match bincode::serde::decode_from_slice( + bytes, + bincode::config::standard(), + ) { + Ok((chain, _)) => Some(chain), + Err(e) => { + log::warn!( + "device {} server_cert_chain blob ({} bytes) failed to decode: {e}; \ + dropping cache, next connect will use XX", + self.device_id, + bytes.len(), + ); + None + } + } + }), })) } else { Ok(None) @@ -2862,4 +2901,100 @@ mod tests { "device data should be loadable by configured id" ); } + + /// Round-trips a `CachedServerCertChain` through the SQLite schema: + /// save → close store → reopen on the same db_name → load. Exercises + /// the `2026-04-26-000000_add_server_cert_chain` migration plus the + /// bincode encode/decode path in `save_device_data_for_device` / + /// `load_device_data_for_device` (the part that the in-memory backend + /// integration tests don't reach). + #[tokio::test] + async fn test_server_cert_chain_survives_save_load_roundtrip() { + use portable_atomic::AtomicU64; + use std::sync::atomic::Ordering; + use wacore::store::device::{CachedNoiseCert, CachedServerCertChain}; + + static COUNTER: AtomicU64 = AtomicU64::new(200); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + // shared-cache so a second SqliteStore opened on the same name + // sees the same on-disk state — the closest we can get to a real + // process restart inside a single test run. + let db_name = format!( + "file:memdb_certchain_{}_{}?mode=memory&cache=shared", + std::process::id(), + id + ); + + let device_id = 7; + let chain = CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xAB; 32], + not_before: 1_700_000_000, + not_after: 1_900_000_000, + }, + leaf: CachedNoiseCert { + key: [0xCD; 32], + not_before: 1_700_000_500, + not_after: 1_899_999_500, + }, + }; + + // First store: create + populate. Keep it alive until after the + // second store opens — `cache=shared` only persists the in-memory + // database while at least one connection is open. Dropping the + // first store would also drop the schema before the second can + // see it. + let _writer = SqliteStore::new_for_device(&db_name, device_id) + .await + .expect("create store"); + _writer.create_new_device().await.expect("create device"); + + let mut device = _writer + .load_device_data_for_device(device_id) + .await + .expect("load") + .expect("device should exist after create"); + device.server_cert_chain = Some(chain.clone()); + _writer + .save_device_data_for_device(device_id, &device) + .await + .expect("save with cert chain"); + + // Second store on the SAME shared-cache db: this exercises the + // exact path a fresh-process load would take — schema migration + // already applied, BLOB column present, and the bincode-encoded + // chain decoded by the load path. + let store = SqliteStore::new_for_device(&db_name, device_id) + .await + .expect("reopen store"); + let loaded = store + .load_device_data_for_device(device_id) + .await + .expect("load") + .expect("device should exist after reopen"); + assert_eq!( + loaded.server_cert_chain.as_ref(), + Some(&chain), + "server_cert_chain must survive a save/load roundtrip" + ); + + // Sanity: clearing the chain and saving leaves the column as NULL, + // not as an empty serialized struct. + let mut device = loaded; + device.server_cert_chain = None; + store + .save_device_data_for_device(device_id, &device) + .await + .expect("save with cleared cert chain"); + + let reloaded = store + .load_device_data_for_device(device_id) + .await + .expect("reload") + .expect("device should exist"); + assert!( + reloaded.server_cert_chain.is_none(), + "cleared chain must round-trip as None" + ); + } } diff --git a/tests/handshake_integration.rs b/tests/handshake_integration.rs new file mode 100644 index 000000000..c4ea52d25 --- /dev/null +++ b/tests/handshake_integration.rs @@ -0,0 +1,891 @@ +//! Integration tests for the full Noise handshake orchestration in +//! `whatsapp_rust::handshake::do_handshake`. +//! +//! Each test stands up an in-process Noise responder, drives the client +//! through one or more handshakes, and asserts on the outcome plus any +//! state mutations visible via the `PersistenceManager` (cached cert +//! chain) and the per-process IK failure counter. +//! +//! These tests do NOT depend on the external mock server (bartender) — +//! the responder side is implemented inline using the same primitives +//! that `wacore-noise` exports for unit-test use. +//! +//! Coverage: +//! - cold-start XX populates the cert chain on disk +//! - subsequent connect picks IK (via the cached chain) and succeeds +//! - server rejects IK → client recovers via XX-fallback in one round +//! - IK with a stale cached static key → counter incremented, cache +//! cleared, next connect can fall back to XX cleanly +//! +//! The "IK Continue" path is asserted indirectly by checking that the +//! second connect both runs without sending three ClientHello bytes +//! (i.e. it speaks IK shape, not XX shape) AND completes successfully. + +use async_trait::async_trait; +use bytes::Bytes; +use prost::Message; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicU32; +use std::time::Duration; + +use wacore::handshake::NoiseHandshake; +use wacore::libsignal::protocol::KeyPair; +use wacore_binary::consts::{NOISE_PATTERN_IK, NOISE_PATTERN_XX, WA_CONN_HEADER}; +use wacore_noise::test_util::build_cert_chain_bytes; +use whatsapp_rust::waproto::whatsapp as wa; + +use whatsapp_rust::handshake::do_handshake; +use whatsapp_rust::transport::{Transport, TransportEvent}; + +/// In-process responder driving Noise XX or IK from the server side. +/// Holds the long-lived server identity keypair and the cert chain +/// bytes that the client must verify and cache. +struct InProcessServer { + identity_kp: KeyPair, + cert_chain_bytes: Vec, +} + +impl InProcessServer { + fn new() -> Self { + let identity_kp = KeyPair::generate(&mut rand::rng()); + let server_static_pub: [u8; 32] = identity_kp + .public_key + .public_key_bytes() + .try_into() + .expect("X25519 pub key is 32 bytes"); + let cert_chain_bytes = build_cert_chain_bytes(&server_static_pub); + Self { + identity_kp, + cert_chain_bytes, + } + } + + fn server_static_pub(&self) -> [u8; 32] { + self.identity_kp + .public_key + .public_key_bytes() + .try_into() + .expect("X25519 pub key is 32 bytes") + } +} + +/// Strips the leading WA_CONN_HEADER (4 bytes) when present and then +/// parses one length-prefixed frame from the buffer. Returns the inner +/// payload bytes. +fn parse_first_client_frame(raw: &[u8]) -> Vec { + // Detect optional WA_CONN_HEADER at the very front. + let body_start = if raw.starts_with(&WA_CONN_HEADER) { + WA_CONN_HEADER.len() + } else { + 0 + }; + parse_one_frame(&raw[body_start..]) +} + +fn parse_one_frame(buf: &[u8]) -> Vec { + assert!(buf.len() >= 3, "frame buffer must hold at least the length"); + let len = ((buf[0] as usize) << 16) | ((buf[1] as usize) << 8) | (buf[2] as usize); + assert!(buf.len() >= 3 + len, "frame buffer truncated"); + buf[3..3 + len].to_vec() +} + +/// Mock transport: capture client→server bytes into a shared Vec; the +/// test drives the server→client side directly via `events_tx`. +#[derive(Clone)] +struct CaptureTransport { + sent: Arc>>, +} + +#[async_trait] +impl Transport for CaptureTransport { + async fn send(&self, data: Bytes) -> anyhow::Result<()> { + self.sent.lock().unwrap().push(data); + Ok(()) + } + async fn disconnect(&self) {} +} + +fn new_transport_pair() -> ( + Arc, + async_channel::Sender, + async_channel::Receiver, +) { + let transport = Arc::new(CaptureTransport { + sent: Arc::new(StdMutex::new(Vec::new())), + }); + let (events_tx, events_rx) = async_channel::unbounded::(); + (transport, events_tx, events_rx) +} + +/// Synchronously serves an XX handshake: consumes the buffered +/// ClientHello frame, builds + signals the ServerHello via events_tx, +/// then consumes the ClientFinish. +async fn xx_serve_full( + server: &InProcessServer, + transport: &Arc, + events_tx: &async_channel::Sender, +) { + // Wait until the client put something on the wire. + wait_for_send(transport, 1).await; + let raw_hello = transport.sent.lock().unwrap()[0].to_vec(); + let client_hello_bytes = parse_first_client_frame(&raw_hello); + + let msg = wa::HandshakeMessage::decode(client_hello_bytes.as_slice()).unwrap(); + let client_eph_pub_vec = msg.client_hello.unwrap().ephemeral.unwrap(); + let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); + + let mut noise = NoiseHandshake::new(NOISE_PATTERN_XX, &WA_CONN_HEADER).unwrap(); + noise.authenticate(&client_eph_pub); + + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + noise.authenticate(&server_eph_pub); + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_eph_pub) + .unwrap(); + let encrypted_static = noise.encrypt(&server.server_static_pub()).unwrap(); + noise + .mix_shared_secret(server.identity_kp.private_key.serialize(), &client_eph_pub) + .unwrap(); + let encrypted_payload = noise.encrypt(&server.cert_chain_bytes).unwrap(); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: Some(encrypted_static), + payload: Some(encrypted_payload), + ..Default::default() + }), + ..Default::default() + }; + let mut sh_bytes = Vec::new(); + server_hello.encode(&mut sh_bytes).unwrap(); + let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); + events_tx + .send(TransportEvent::DataReceived(framed.into())) + .await + .unwrap(); + + // Consume the ClientFinish. We don't bother completing the responder + // side — the assertion target is the cipher pair on the client side + // (and the cached cert chain), both of which are derived before the + // ClientFinish is consumed by the server. + wait_for_send(transport, 2).await; +} + +/// IK-accept responder: parses the IK ClientHello (e + encrypted s + +/// encrypted payload), and replies with a ServerHello whose `static` is +/// absent (signals IK success). +async fn ik_serve_accept( + server: &InProcessServer, + transport: &Arc, + events_tx: &async_channel::Sender, +) { + wait_for_send(transport, 1).await; + let raw_hello = transport.sent.lock().unwrap()[0].to_vec(); + let client_hello_bytes = parse_first_client_frame(&raw_hello); + + let msg = wa::HandshakeMessage::decode(client_hello_bytes.as_slice()).unwrap(); + let ch = msg.client_hello.unwrap(); + let client_eph_pub: [u8; 32] = ch.ephemeral.unwrap().try_into().unwrap(); + let encrypted_static = ch.r#static.unwrap(); + let encrypted_payload = ch.payload.unwrap(); + + let mut noise = NoiseHandshake::new(NOISE_PATTERN_IK, &WA_CONN_HEADER).unwrap(); + noise.authenticate(&server.server_static_pub()); + noise.authenticate(&client_eph_pub); + noise + .mix_shared_secret(server.identity_kp.private_key.serialize(), &client_eph_pub) + .unwrap(); + let client_static = noise.decrypt(&encrypted_static).unwrap(); + let client_static: [u8; 32] = client_static.try_into().unwrap(); + noise + .mix_shared_secret(server.identity_kp.private_key.serialize(), &client_static) + .unwrap(); + let _payload = noise.decrypt(&encrypted_payload).unwrap(); + + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + noise.authenticate(&server_eph_pub); + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_eph_pub) + .unwrap(); + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_static) + .unwrap(); + let encrypted_cert = noise.encrypt(&server.cert_chain_bytes).unwrap(); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: None, + payload: Some(encrypted_cert), + ..Default::default() + }), + ..Default::default() + }; + let mut sh_bytes = Vec::new(); + server_hello.encode(&mut sh_bytes).unwrap(); + let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); + events_tx + .send(TransportEvent::DataReceived(framed.into())) + .await + .unwrap(); +} + +/// Waits up to ~3s for the captured-sent buffer to reach `min_count` +/// entries. Polls every 5 ms; tight enough for unit tests. +async fn wait_for_send(transport: &Arc, min_count: usize) { + let start = wacore::time::Instant::now(); + let timeout = Duration::from_secs(3); + loop { + if transport.sent.lock().unwrap().len() >= min_count { + return; + } + if start.elapsed() >= timeout { + panic!( + "transport did not produce {} sends within deadline (got {})", + min_count, + transport.sent.lock().unwrap().len() + ); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// Builds a fresh PersistenceManager backed by an in-memory store. +async fn pm() -> Arc { + let backend: Arc = + Arc::new(wacore::store::InMemoryBackend::new()); + Arc::new( + whatsapp_rust::store::persistence_manager::PersistenceManager::new(backend) + .await + .expect("pm init"), + ) +} + +/// `pm()` seeded with a `pn`, so `select_pattern` will consider IK. +async fn paired_pm() -> Arc { + let pm = pm().await; + pm.process_command(wacore::store::DeviceCommand::SetId(Some( + "12345@s.whatsapp.net".parse().unwrap(), + ))) + .await; + pm +} + +fn runtime() -> Arc { + Arc::new(whatsapp_rust::runtime_impl::TokioRuntime) +} + +#[tokio::test] +async fn cold_start_xx_then_cached_ik_reconnect() { + let _ = env_logger::builder().is_test(true).try_init(); + + // The XX→IK reconnect path runs only for paired devices; an unpaired + // device intentionally sticks to XX and never persists the chain (see + // `should_persist_cert_chain`). Real cold-start happens unpaired and + // is covered by the unit tests in `src/handshake.rs`. + let pm = paired_pm().await; + let counter = Arc::new(AtomicU32::new(0)); + let server = InProcessServer::new(); + let server_static_pub_expected = server.server_static_pub(); + + // ── 1. Cold start: no cache → XX + let (transport, events_tx, mut events_rx) = new_transport_pair(); + let pm2 = pm.clone(); + let counter2 = counter.clone(); + let runtime = runtime(); + let server_t = transport.clone(); + let server_arc = Arc::new(server); + let server_clone = server_arc.clone(); + let server_task = tokio::spawn(async move { + xx_serve_full(&server_clone, &server_t, &events_tx).await; + }); + + let result = do_handshake( + runtime.clone(), + pm2.as_ref(), + counter2.as_ref(), + transport.clone(), + &mut events_rx, + ) + .await; + + server_task.await.unwrap(); + + // The XX handshake should complete fully, including the cert-chain + // verification (so the orchestration layer can persist it). + assert!( + result.is_ok(), + "XX handshake should succeed: {:?}", + result.err() + ); + + // The cert chain must now be cached on the device. + let device = pm.get_device_snapshot().await; + let chain = device + .server_cert_chain + .as_ref() + .expect("cert chain must be persisted after XX"); + assert_eq!( + chain.leaf.key, server_static_pub_expected, + "cached leaf.key must equal server's actual static pub" + ); + + // Counter must remain at 0 after a successful handshake. + assert_eq!(counter.load(std::sync::atomic::Ordering::Acquire), 0); + + // ── 2. Reconnect: cache present → IK + let (transport2, events_tx2, mut events_rx2) = new_transport_pair(); + let pm3 = pm.clone(); + let counter3 = counter.clone(); + let server_clone2 = server_arc.clone(); + let server_t2 = transport2.clone(); + let ik_task = tokio::spawn(async move { + ik_serve_accept(&server_clone2, &server_t2, &events_tx2).await; + }); + + let result = do_handshake( + runtime, + pm3.as_ref(), + counter3.as_ref(), + transport2.clone(), + &mut events_rx2, + ) + .await; + + ik_task.await.unwrap(); + + assert!( + result.is_ok(), + "IK handshake on reconnect should succeed: {:?}", + result.err() + ); + + // After IK Continue, the on-disk cache stays as-is (the orchestrator + // does NOT issue SetServerCertChain on the IK path). + let device = pm.get_device_snapshot().await; + assert_eq!( + device.server_cert_chain.as_ref().unwrap().leaf.key, + server_static_pub_expected + ); + assert_eq!(counter.load(std::sync::atomic::Ordering::Acquire), 0); + + // The IK ClientHello should carry static + payload, not just an + // ephemeral. Inspect the captured bytes to confirm the path. + let sent = transport2.sent.lock().unwrap(); + let raw = sent[0].to_vec(); + let body = parse_first_client_frame(&raw); + let parsed = wa::HandshakeMessage::decode(body.as_slice()).unwrap(); + let ch = parsed.client_hello.unwrap(); + assert!( + ch.r#static.is_some(), + "IK ClientHello carries client static" + ); + assert!(ch.payload.is_some(), "IK ClientHello carries 0-RTT payload"); +} + +/// Sends a fallback-shaped ServerHello (`static.is_some()`) with garbage +/// AEAD payloads, so the initiator pivots and then fails inside XXfallback. +async fn ik_serve_fallback_with_corrupt_payloads( + transport: &Arc, + events_tx: &async_channel::Sender, +) { + wait_for_send(transport, 1).await; + + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: Some(vec![0xCC; 32 + 16]), + payload: Some(vec![0xDE; 64]), + ..Default::default() + }), + ..Default::default() + }; + let mut sh_bytes = Vec::new(); + server_hello.encode(&mut sh_bytes).unwrap(); + let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); + events_tx + .send(TransportEvent::DataReceived(framed.into())) + .await + .unwrap(); +} + +#[tokio::test] +async fn post_xxfallback_failure_does_not_invalidate_ik_cache() { + let _ = env_logger::builder().is_test(true).try_init(); + + let pm = paired_pm().await; + let counter = Arc::new(AtomicU32::new(0)); + + // Pre-seed a valid-looking chain with a sentinel `not_after` so we can + // distinguish "untouched" from "cleared and rewritten by some path". + const SENTINEL_NOT_AFTER: i64 = 1_899_999_999; + use wacore::store::{CachedNoiseCert, CachedServerCertChain, DeviceCommand}; + pm.process_command(DeviceCommand::SetServerCertChain(CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xCC; 32], + not_before: 1_700_000_000, + not_after: SENTINEL_NOT_AFTER, + }, + leaf: CachedNoiseCert { + key: [0xAA; 32], + not_before: 1_700_000_500, + not_after: SENTINEL_NOT_AFTER, + }, + })) + .await; + + let (transport, events_tx, mut events_rx) = new_transport_pair(); + let transport_clone = transport.clone(); + let task = tokio::spawn(async move { + ik_serve_fallback_with_corrupt_payloads(&transport_clone, &events_tx).await; + }); + + let result = do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport.clone(), + &mut events_rx, + ) + .await; + task.await.unwrap(); + + assert!( + result.is_err(), + "post-pivot AEAD failure must surface as Err" + ); + let err = result.err().unwrap(); + assert!( + err.is_crypto_fatal(), + "AEAD decrypt failure on XXfallback ServerHello must classify as crypto-fatal: {err:?}" + ); + + // Cache must be untouched: the failure happened post-pivot, so the + // orchestrator's invalidation gate must have skipped both the clear + // and the counter increment. + let device = pm.get_device_snapshot().await; + let chain = device + .server_cert_chain + .as_ref() + .expect("post-fallback failure must NOT clear the cached chain"); + assert_eq!(chain.leaf.not_after, SENTINEL_NOT_AFTER); + assert_eq!(chain.intermediate.not_after, SENTINEL_NOT_AFTER); + assert_eq!( + counter.load(std::sync::atomic::Ordering::Acquire), + 0, + "post-fallback failure must NOT increment ik_handshake_failures" + ); +} + +#[tokio::test] +async fn ik_continue_does_not_overwrite_cached_chain() { + let _ = env_logger::builder().is_test(true).try_init(); + + let pm = paired_pm().await; + let counter = Arc::new(AtomicU32::new(0)); + let server = Arc::new(InProcessServer::new()); + + // Sentinel distinct from anything `build_cert_chain_bytes` produces. + const SENTINEL_NOT_AFTER: i64 = 1_899_999_999; + use wacore::store::{CachedNoiseCert, CachedServerCertChain, DeviceCommand}; + pm.process_command(DeviceCommand::SetServerCertChain(CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xCC; 32], + not_before: 1_700_000_000, + not_after: SENTINEL_NOT_AFTER, + }, + leaf: CachedNoiseCert { + key: server.server_static_pub(), + not_before: 1_700_000_500, + not_after: SENTINEL_NOT_AFTER, + }, + })) + .await; + + let (transport, events_tx, mut events_rx) = new_transport_pair(); + let server_clone = server.clone(); + let server_t = transport.clone(); + let task = tokio::spawn(async move { + ik_serve_accept(&server_clone, &server_t, &events_tx).await; + }); + + let result = do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport.clone(), + &mut events_rx, + ) + .await; + task.await.unwrap(); + + assert!( + result.is_ok(), + "IK Continue must succeed: {:?}", + result.err() + ); + + let device = pm.get_device_snapshot().await; + let chain = device + .server_cert_chain + .as_ref() + .expect("chain still present"); + assert_eq!( + chain.leaf.not_after, SENTINEL_NOT_AFTER, + "IK Continue must leave the cached leaf.not_after untouched" + ); + assert_eq!( + chain.intermediate.not_after, SENTINEL_NOT_AFTER, + "IK Continue must leave the cached intermediate.not_after untouched" + ); +} + +/// XX-1 unpaired (no persist) → SetId (mocks pair-success) → XX-2 paired +/// (persists). Mirrors the post-515 reconnect. +#[tokio::test] +async fn xx_after_pair_success_persists_cert_chain() { + let _ = env_logger::builder().is_test(true).try_init(); + + let pm = pm().await; + let counter = Arc::new(AtomicU32::new(0)); + let server = Arc::new(InProcessServer::new()); + + let (transport1, events_tx1, mut events_rx1) = new_transport_pair(); + let server_t1 = transport1.clone(); + let server_c1 = server.clone(); + let task1 = tokio::spawn(async move { + xx_serve_full(&server_c1, &server_t1, &events_tx1).await; + }); + do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport1.clone(), + &mut events_rx1, + ) + .await + .expect("unpaired XX must succeed"); + task1.await.unwrap(); + + let device = pm.get_device_snapshot().await; + assert!(!device.is_registered(), "still unpaired after first XX"); + assert!( + device.server_cert_chain.is_none(), + "unpaired XX must not persist chain" + ); + + pm.process_command(wacore::store::DeviceCommand::SetId(Some( + "12345@s.whatsapp.net".parse().unwrap(), + ))) + .await; + + let (transport2, events_tx2, mut events_rx2) = new_transport_pair(); + let server_t2 = transport2.clone(); + let server_c2 = server.clone(); + let task2 = tokio::spawn(async move { + xx_serve_full(&server_c2, &server_t2, &events_tx2).await; + }); + do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport2.clone(), + &mut events_rx2, + ) + .await + .expect("paired XX must succeed"); + task2.await.unwrap(); + + let device = pm.get_device_snapshot().await; + assert!(device.is_registered(), "paired after SetId"); + let chain = device + .server_cert_chain + .as_ref() + .expect("paired XX must persist cert chain"); + assert_eq!( + chain.leaf.key, + server.server_static_pub(), + "persisted leaf.key must match server static" + ); +} + +/// Regression: PR #598's unpaired-then-restart loop. XX must not persist +/// the chain when `pn` is unset, otherwise the next connect picks IK +/// against an unregistered identity and the server closes mid-handshake. +#[tokio::test] +async fn unpaired_xx_does_not_persist_cert_chain() { + let _ = env_logger::builder().is_test(true).try_init(); + + let pm = pm().await; // Intentionally NOT paired_pm — that's the point. + let counter = Arc::new(AtomicU32::new(0)); + let server = Arc::new(InProcessServer::new()); + + let (transport, events_tx, mut events_rx) = new_transport_pair(); + let server_clone = server.clone(); + let server_t = transport.clone(); + let task = tokio::spawn(async move { + xx_serve_full(&server_clone, &server_t, &events_tx).await; + }); + + let result = do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport.clone(), + &mut events_rx, + ) + .await; + task.await.unwrap(); + + assert!( + result.is_ok(), + "XX handshake on unpaired device should succeed: {:?}", + result.err() + ); + + let device = pm.get_device_snapshot().await; + assert!( + !device.is_registered(), + "precondition: device must still be unpaired" + ); + assert!(device.server_cert_chain.is_none()); + assert_eq!(counter.load(std::sync::atomic::Ordering::Acquire), 0); +} + +/// IK-reject responder: parses the IK ClientHello, then replies with an +/// XX-shaped ServerHello (carrying `static != null`) using the +/// XXfallback pattern. The client must: +/// 1. detect `static != null` and fall back without sending another +/// ephemeral +/// 2. process the response as XX (in XXfallback mode, reusing the +/// already-sent ephemeral) +/// 3. send a ClientFinish carrying its own static + payload +/// 4. ultimately persist the cert chain again (because XX-fallback DOES +/// bring back a fresh chain) +async fn ik_serve_force_fallback_then_consume_finish( + server: &InProcessServer, + transport: &Arc, + events_tx: &async_channel::Sender, +) { + wait_for_send(transport, 1).await; + let raw_hello = transport.sent.lock().unwrap()[0].to_vec(); + let client_hello_bytes = parse_first_client_frame(&raw_hello); + + let msg = wa::HandshakeMessage::decode(client_hello_bytes.as_slice()).unwrap(); + let client_eph_pub: [u8; 32] = msg + .client_hello + .unwrap() + .ephemeral + .unwrap() + .try_into() + .unwrap(); + + // Build XXfallback responder state matching what the initiator will + // construct after seeing `static != null`. + let mut noise = NoiseHandshake::new( + wacore_binary::consts::NOISE_PATTERN_XXFALLBACK, + &WA_CONN_HEADER, + ) + .unwrap(); + noise.authenticate(&client_eph_pub); + + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + noise.authenticate(&server_eph_pub); + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_eph_pub) + .unwrap(); + let encrypted_static = noise.encrypt(&server.server_static_pub()).unwrap(); + noise + .mix_shared_secret(server.identity_kp.private_key.serialize(), &client_eph_pub) + .unwrap(); + let encrypted_cert = noise.encrypt(&server.cert_chain_bytes).unwrap(); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: Some(encrypted_static), + payload: Some(encrypted_cert), + ..Default::default() + }), + ..Default::default() + }; + let mut sh_bytes = Vec::new(); + server_hello.encode(&mut sh_bytes).unwrap(); + let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); + events_tx + .send(TransportEvent::DataReceived(framed.into())) + .await + .unwrap(); + + // Wait for the client's XXfallback ClientFinish. + wait_for_send(transport, 2).await; +} + +#[tokio::test] +async fn ik_rejected_recovers_via_xxfallback_and_repopulates_cache() { + let _ = env_logger::builder().is_test(true).try_init(); + + let pm = paired_pm().await; + let counter = Arc::new(AtomicU32::new(0)); + let server = Arc::new(InProcessServer::new()); + let server_static_pub_expected = server.server_static_pub(); + + // Pre-seed the device with the *correct* cert chain (so IK is selected), + // but the responder will still force fallback (mirrors the case where + // the server has just rotated and the client's cache is one connect + // out of date). + use wacore::store::{CachedNoiseCert, CachedServerCertChain, DeviceCommand}; + pm.process_command(DeviceCommand::SetServerCertChain(CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xCC; 32], + not_before: 1_700_000_000, + not_after: 1_900_000_000, + }, + leaf: CachedNoiseCert { + key: server_static_pub_expected, + not_before: 1_700_000_500, + not_after: 1_899_999_500, + }, + })) + .await; + + let (transport, events_tx, mut events_rx) = new_transport_pair(); + let server_clone = server.clone(); + let transport_clone = transport.clone(); + let task = tokio::spawn(async move { + ik_serve_force_fallback_then_consume_finish(&server_clone, &transport_clone, &events_tx) + .await; + }); + + let result = do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport.clone(), + &mut events_rx, + ) + .await; + task.await.unwrap(); + + assert!( + result.is_ok(), + "IK rejection must be recovered via XXfallback: {:?}", + result.err() + ); + + // After XXfallback completion: counter zero, cache repopulated with + // the fresh chain (same key here, but the orchestration MUST emit + // SetServerCertChain regardless). + assert_eq!(counter.load(std::sync::atomic::Ordering::Acquire), 0); + let device = pm.get_device_snapshot().await; + let chain = device.server_cert_chain.as_ref().expect("cert chain"); + assert_eq!(chain.leaf.key, server_static_pub_expected); +} + +#[tokio::test] +async fn ik_with_stale_cache_invalidates_and_increments_counter() { + let _ = env_logger::builder().is_test(true).try_init(); + + let pm = paired_pm().await; + let counter = Arc::new(AtomicU32::new(0)); + + // Pre-seed the device with a STALE cert chain — the leaf.key is wrong + // (random, not matching any real server static). The next connect + // will pick IK based on this cache, the responder will fail to + // process the resulting clientHello (because the server doesn't have + // the matching private key), and we expect: + // - the handshake errors out crypto-fatally (or the responder side + // panics, which we catch and surface as a transient channel close + // event) + // - the orchestrator increments the counter and clears the cache. + use wacore::store::{CachedNoiseCert, CachedServerCertChain, DeviceCommand}; + let stale_pub = [0xDE; 32]; + pm.process_command(DeviceCommand::SetServerCertChain(CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xCC; 32], + not_before: 1_700_000_000, + not_after: 1_900_000_000, + }, + leaf: CachedNoiseCert { + key: stale_pub, + not_before: 1_700_000_500, + not_after: 1_899_999_500, + }, + })) + .await; + + let (transport, events_tx, mut events_rx) = new_transport_pair(); + + // Stand up a real server with a DIFFERENT static keypair than the one + // cached above. When the client sends an IK ClientHello using + // stale_pub, the responder's `es` derivation diverges and decryption + // of the client static fails → we surface that by closing the events + // channel without sending a ServerHello, which the client interprets + // as a Disconnected event (transient). + // + // To test the *crypto-fatal* path instead, we send a malformed + // ServerHello so the client's read_server_hello errors out at + // decrypt of the cert (Core handshake error → invalidation runs). + let bogus_server_eph = KeyPair::generate(&mut rand::rng()).public_key; + let bogus_server_eph_bytes: [u8; 32] = bogus_server_eph.public_key_bytes().try_into().unwrap(); + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(bogus_server_eph_bytes.to_vec()), + r#static: None, + // `payload` here is just garbage — AEAD MAC check will fail. + payload: Some(vec![0xAB; 64]), + ..Default::default() + }), + ..Default::default() + }; + let mut sh_bytes = Vec::new(); + server_hello.encode(&mut sh_bytes).unwrap(); + let framed = wacore::framing::encode_frame(&sh_bytes, None).unwrap(); + + let transport_for_task = transport.clone(); + let driver = tokio::spawn(async move { + wait_for_send(&transport_for_task, 1).await; + events_tx + .send(TransportEvent::DataReceived(framed.into())) + .await + .unwrap(); + }); + + let result = do_handshake( + runtime(), + pm.as_ref(), + counter.as_ref(), + transport.clone(), + &mut events_rx, + ) + .await; + + driver.await.unwrap(); + + assert!(result.is_err(), "IK with bogus server hello must fail"); + let err = result.err().unwrap(); + assert!( + err.is_crypto_fatal(), + "decrypt failure must be classified as crypto-fatal: {err:?}" + ); + + // Invalidation policy: counter incremented, cache cleared. + assert_eq!( + counter.load(std::sync::atomic::Ordering::Acquire), + 1, + "ik_handshake_failures must be 1 after one crypto-fatal failure" + ); + let device = pm.get_device_snapshot().await; + assert!( + device.server_cert_chain.is_none(), + "stale cert chain must be cleared after crypto-fatal IK failure" + ); +} diff --git a/wacore/binary/src/consts.rs b/wacore/binary/src/consts.rs index 247764df5..bf475a3a5 100644 --- a/wacore/binary/src/consts.rs +++ b/wacore/binary/src/consts.rs @@ -1,6 +1,44 @@ use crate::token::DICT_VERSION; -pub const NOISE_START_PATTERN: &str = "Noise_XX_25519_AESGCM_SHA256\x00\x00\x00\x00"; +/// Noise XX pattern name, zero-padded to HASHLEN (32 bytes) per Noise § 5.2. +/// Used on the first connect / pairing where the client has no cached server +/// static key. Mirrors WA Web's `M` constant in `WAWebOpenChatSocket`. +pub const NOISE_PATTERN_XX: &str = "Noise_XX_25519_AESGCM_SHA256\x00\x00\x00\x00"; + +/// Noise IK pattern name, zero-padded to HASHLEN (32 bytes). +/// Used on reconnect when the client has a valid cached `serverStaticPublic` +/// (from a previous XX handshake). Saves one round trip and ships a 0-RTT +/// payload. Mirrors WA Web's `w` constant. +pub const NOISE_PATTERN_IK: &str = "Noise_IK_25519_AESGCM_SHA256\x00\x00\x00\x00"; + +/// Noise XXfallback pattern name (36 bytes, hashed by Noise spec). +/// Used to recover gracefully when an in-flight IK handshake is rejected by +/// the server (server's static rotated, cached pub key is stale). The client +/// reuses the ephemeral it already sent in the IK ClientHello and processes +/// the server's XX-style ServerHello. Mirrors WA Web's `A` constant. +pub const NOISE_PATTERN_XXFALLBACK: &str = "Noise_XXfallback_25519_AESGCM_SHA256"; pub const WA_MAGIC_VALUE: u8 = 6; pub const WA_CONN_HEADER: [u8; 4] = [b'W', b'A', WA_MAGIC_VALUE, DICT_VERSION]; + +#[cfg(test)] +mod pattern_length_tests { + use super::*; + + #[test] + fn xx_is_padded_to_hashlen() { + assert_eq!(NOISE_PATTERN_XX.len(), 32); + } + + #[test] + fn ik_is_padded_to_hashlen() { + assert_eq!(NOISE_PATTERN_IK.len(), 32); + } + + #[test] + fn xxfallback_is_unpadded_36_bytes() { + // Longer than HASHLEN -> Noise spec hashes it; padding here would be + // a correctness bug on the wire. Lock the length explicitly. + assert_eq!(NOISE_PATTERN_XXFALLBACK.len(), 36); + } +} diff --git a/wacore/noise/Cargo.toml b/wacore/noise/Cargo.toml index 4e1fa3633..ab4e1cb0f 100644 --- a/wacore/noise/Cargo.toml +++ b/wacore/noise/Cargo.toml @@ -10,6 +10,11 @@ description = "Noise Protocol implementation for WhatsApp with AES-256-GCM" [lib] crate-type = ["rlib"] +[features] +# Exposes `wacore_noise::test_util` (cert-chain bytes builder, etc.) so +# downstream crates can share fixtures with the unit tests in this crate. +test-util = [] + [dependencies] anyhow = { workspace = true } bytes = { workspace = true } diff --git a/wacore/noise/src/handshake.rs b/wacore/noise/src/handshake.rs index a8239dec5..bd8528ddc 100644 --- a/wacore/noise/src/handshake.rs +++ b/wacore/noise/src/handshake.rs @@ -8,7 +8,9 @@ use waproto::whatsapp::{self as wa, CertChain, HandshakeMessage}; const WA_CERT_ISSUER_SERIAL: i64 = 0; -/// The public key for verifying the server's intermediate certificate. +/// Ed25519 issuer key for the Noise cert chain. Intentionally unused: wiring +/// it into `verify_server_cert` would block the e2e mock server. Kept +/// exported for SemVer; do not make it load-bearing without an opt-out. pub const WA_CERT_PUB_KEY: [u8; 32] = [ 0x14, 0x23, 0x75, 0x57, 0x4d, 0x0a, 0x58, 0x71, 0x66, 0xaa, 0xe7, 0x1e, 0xbe, 0x51, 0x64, 0x37, 0xc4, 0xa2, 0x8b, 0x73, 0xe3, 0x69, 0x5c, 0x6c, 0xe1, 0xf7, 0xf9, 0x54, 0x5d, 0xa8, 0xee, 0x6b, @@ -40,11 +42,28 @@ pub enum HandshakeError { pub type Result = std::result::Result; +/// Parsed leaf+intermediate certificate identities pulled from a verified +/// `CertChain`. +/// +/// The wider crate (`wacore::store::device::CachedServerCertChain`) wraps +/// these fields with serde so they can persist for Noise IK reuse on later +/// connects. Keeping the no-std-friendly version here lets `wacore-noise` +/// stay free of a serde dependency. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VerifiedServerCertChain { + pub intermediate_key: [u8; 32], + pub intermediate_not_before: i64, + pub intermediate_not_after: i64, + pub leaf_key: [u8; 32], + pub leaf_not_before: i64, + pub leaf_not_after: i64, +} + /// Handshake utilities for WhatsApp protocol operations pub struct HandshakeUtils; impl HandshakeUtils { - /// Creates a ClientHello message with the given ephemeral key + /// Creates a ClientHello message with the given ephemeral key only (XX). pub fn build_client_hello(ephemeral_key: &[u8]) -> HandshakeMessage { HandshakeMessage { client_hello: Some(wa::handshake_message::ClientHello { @@ -55,13 +74,52 @@ impl HandshakeUtils { } } - /// Extracts server handshake data from ServerHello response - pub fn parse_server_hello(response_bytes: &[u8]) -> Result<(Vec, Vec, Vec)> { + /// Creates an IK ClientHello carrying the encrypted client static and + /// the encrypted 0-RTT payload alongside the ephemeral. + pub fn build_ik_client_hello( + ephemeral_key: &[u8], + encrypted_static: Vec, + encrypted_payload: Vec, + ) -> HandshakeMessage { + HandshakeMessage { + client_hello: Some(wa::handshake_message::ClientHello { + ephemeral: Some(ephemeral_key.to_vec()), + r#static: Some(encrypted_static), + payload: Some(encrypted_payload), + ..Default::default() + }), + ..Default::default() + } + } + + /// Decodes a `HandshakeMessage` and returns its `ServerHello` body. + /// Validates only the ephemeral length — `static` and `payload` are + /// optional fields whose presence depends on the active pattern. + pub fn parse_server_hello_body( + response_bytes: &[u8], + ) -> Result { let handshake_response = HandshakeMessage::decode(response_bytes)?; let server_hello = handshake_response .server_hello .ok_or(HandshakeError::IncompleteResponse)?; + if let Some(ephemeral) = server_hello.ephemeral.as_ref() + && ephemeral.len() != 32 + { + return Err(HandshakeError::InvalidLength { + name: "server ephemeral key".into(), + expected: 32, + got: ephemeral.len(), + }); + } + + Ok(server_hello) + } + + /// XX-style parse: requires all three fields. Mirrors the historical + /// shape used by the full XX handshake and by XX-fallback. + pub fn parse_server_hello(response_bytes: &[u8]) -> Result<(Vec, Vec, Vec)> { + let server_hello = Self::parse_server_hello_body(response_bytes)?; let server_ephemeral = server_hello .ephemeral .ok_or(HandshakeError::IncompleteResponse)?; @@ -71,15 +129,6 @@ impl HandshakeUtils { let certificate_ciphertext = server_hello .payload .ok_or(HandshakeError::IncompleteResponse)?; - - if server_ephemeral.len() != 32 { - return Err(HandshakeError::InvalidLength { - name: "server ephemeral key".into(), - expected: 32, - got: server_ephemeral.len(), - }); - } - Ok(( server_ephemeral, server_static_ciphertext, @@ -87,8 +136,13 @@ impl HandshakeUtils { )) } - /// Verifies the server's certificate chain - pub fn verify_server_cert(cert_decrypted: &[u8], static_decrypted: &[u8; 32]) -> Result<()> { + /// Verifies the server's certificate chain and returns a stripped form + /// suitable for caching across reconnects (signatures and the global + /// issuer serial are dropped — they were just checked). + pub fn verify_server_cert( + cert_decrypted: &[u8], + static_decrypted: &[u8; 32], + ) -> Result { let cert_chain = CertChain::decode(cert_decrypted)?; let intermediate = cert_chain @@ -98,7 +152,6 @@ impl HandshakeUtils { .leaf .ok_or_else(|| HandshakeError::CertVerification("Missing leaf cert".into()))?; - // Unmarshal details and perform further checks let intermediate_details_bytes = intermediate.details.as_ref().ok_or_else(|| { HandshakeError::CertVerification("Missing intermediate details".into()) })?; @@ -119,11 +172,9 @@ impl HandshakeUtils { "Intermediate details missing key".into(), )); } - if intermediate_pk_bytes.len() != 32 { - return Err(HandshakeError::CertVerification( - "Intermediate details key is not 32 bytes".into(), - )); - } + let intermediate_key: [u8; 32] = intermediate_pk_bytes.try_into().map_err(|_| { + HandshakeError::CertVerification("Intermediate details key is not 32 bytes".into()) + })?; let leaf_details_bytes = leaf .details @@ -139,14 +190,20 @@ impl HandshakeUtils { ))); } - // Finally, check if the leaf cert's key matches the server's static key if leaf_details.key() != static_decrypted { return Err(HandshakeError::CertVerification( "Cert key does not match decrypted static key".into(), )); } - Ok(()) + Ok(VerifiedServerCertChain { + intermediate_key, + intermediate_not_before: intermediate_details.not_before() as i64, + intermediate_not_after: intermediate_details.not_after() as i64, + leaf_key: *static_decrypted, + leaf_not_before: leaf_details.not_before() as i64, + leaf_not_after: leaf_details.not_after() as i64, + }) } pub fn build_client_finish( @@ -245,34 +302,65 @@ impl NoiseHandshake { } } -/// Full handshake state machine for WhatsApp Noise XX handshake. -/// -/// This orchestrates the complete handshake flow including key generation, -/// message building, and cryptographic operations. -pub struct HandshakeState { +/// Outcome of a successful XX (or XX-fallback) handshake. Bundles the final +/// cipher pair with the freshly-validated server cert chain so the caller +/// can persist the latter for the next reconnect's IK attempt. +pub struct XxHandshakeOutcome { + pub write_cipher: NoiseCipher, + pub read_cipher: NoiseCipher, + pub server_cert_chain: VerifiedServerCertChain, +} + +/// Outcome of a successful IK handshake — only the cipher keys, since the +/// cached cert chain on disk stays valid (the server demonstrably still +/// owns the static private key by virtue of completing the handshake). +pub struct IkHandshakeOutcome { + pub write_cipher: NoiseCipher, + pub read_cipher: NoiseCipher, +} + +/// Carryover state when an in-flight IK is rejected by the server (server +/// replied with an XX-shaped ServerHello carrying `static != null`). The +/// initiator hands these to `XxFallbackHandshakeState::from_ik_failure` +/// without losing the ephemeral it already sent on the wire. +pub struct IkFallbackInputs { + pub ephemeral_kp: KeyPair, + pub static_kp: KeyPair, + pub client_payload: Vec, + pub server_hello_bytes: Vec, +} + +/// What the IK initiator learns from reading the ServerHello. +pub enum IkServerHelloOutcome { + /// Server accepted IK; cert payload was decrypted and the cipher keys + /// are derivable. The cached cert chain on the device stays untouched. + Continue(IkHandshakeOutcome), + /// Server rejected IK by replying with `static != null`. Switch to + /// XX-fallback using the carryover inputs. Boxed since `IkFallbackInputs` + /// holds two KeyPairs and a payload, dwarfing `Continue`'s two ciphers. + Fallback(Box), +} + +/// Full handshake state machine for WhatsApp Noise XX handshake. Used for +/// the first connect / pairing where the client has no cached server static. +pub struct XxHandshakeState { noise: NoiseHandshake, ephemeral_kp: KeyPair, static_kp: KeyPair, payload: Vec, + /// Captured during `read_server_hello_and_build_client_finish` so that + /// `finish()` can ship it back without a second decrypt. + cert_chain: Option, } -impl HandshakeState { - /// Creates a new handshake state with the given static key pair and client payload. - /// +impl XxHandshakeState { /// # Arguments /// * `static_kp` - The device's static Noise key pair /// * `client_payload` - The encoded client payload bytes - /// * `pattern` - The Noise pattern string (e.g., NOISE_START_PATTERN) /// * `prologue` - The prologue/header bytes (e.g., WA_CONN_HEADER) - pub fn new( - static_kp: KeyPair, - client_payload: Vec, - pattern: &str, - prologue: &[u8], - ) -> Result { + pub fn new(static_kp: KeyPair, client_payload: Vec, prologue: &[u8]) -> Result { let ephemeral_kp = KeyPair::generate(&mut rand::rng()); - let mut noise = NoiseHandshake::new(pattern, prologue)?; - + let mut noise = NoiseHandshake::new(wacore_binary::consts::NOISE_PATTERN_XX, prologue)?; noise.authenticate(ephemeral_kp.public_key.public_key_bytes()); Ok(Self { @@ -280,6 +368,7 @@ impl HandshakeState { ephemeral_kp, static_kp, payload: client_payload, + cert_chain: None, }) } @@ -304,45 +393,669 @@ impl HandshakeState { .try_into() .map_err(|_| HandshakeError::InvalidKeyLength)?; - self.noise.authenticate(&server_ephemeral); - self.noise - .mix_shared_secret(self.ephemeral_kp.private_key.serialize(), &server_ephemeral)?; + process_xx_server_hello_into( + &mut self.noise, + &self.ephemeral_kp, + &self.static_kp, + &self.payload, + server_ephemeral, + &server_static_ciphertext, + &certificate_ciphertext, + &mut self.cert_chain, + ) + } + + pub fn finish(self) -> Result { + let cert_chain = self.cert_chain.ok_or(HandshakeError::IncompleteResponse)?; + let (write_cipher, read_cipher) = self.noise.finish()?; + Ok(XxHandshakeOutcome { + write_cipher, + read_cipher, + server_cert_chain: cert_chain, + }) + } +} - let static_decrypted = self.noise.decrypt(&server_static_ciphertext)?; +/// Shared XX serverHello -> clientFinish core. Used by both the cold-start +/// `XxHandshakeState` and the post-IK-rejection `XxFallbackHandshakeState`, +/// since once the ephemeral is in the transcript both flows look identical. +#[allow(clippy::too_many_arguments)] +fn process_xx_server_hello_into( + noise: &mut NoiseHandshake, + ephemeral_kp: &KeyPair, + static_kp: &KeyPair, + payload: &[u8], + server_ephemeral: [u8; 32], + server_static_ciphertext: &[u8], + certificate_ciphertext: &[u8], + cert_chain_out: &mut Option, +) -> Result> { + noise.authenticate(&server_ephemeral); + noise.mix_shared_secret(ephemeral_kp.private_key.serialize(), &server_ephemeral)?; - let static_decrypted_arr: [u8; 32] = static_decrypted - .try_into() - .map_err(|_| HandshakeError::InvalidKeyLength)?; + let static_decrypted = noise.decrypt(server_static_ciphertext)?; + let static_decrypted_arr: [u8; 32] = static_decrypted + .try_into() + .map_err(|_| HandshakeError::InvalidKeyLength)?; - self.noise.mix_shared_secret( - self.ephemeral_kp.private_key.serialize(), - &static_decrypted_arr, - )?; + noise.mix_shared_secret(ephemeral_kp.private_key.serialize(), &static_decrypted_arr)?; + + let cert_decrypted = noise.decrypt(certificate_ciphertext)?; + + let chain = HandshakeUtils::verify_server_cert(&cert_decrypted, &static_decrypted_arr) + .map_err(|e| { + HandshakeError::CertVerification(format!("Error verifying server cert: {e}")) + })?; + *cert_chain_out = Some(chain); - let cert_decrypted = self.noise.decrypt(&certificate_ciphertext)?; + let encrypted_pubkey = noise.encrypt(static_kp.public_key.public_key_bytes())?; - HandshakeUtils::verify_server_cert(&cert_decrypted, &static_decrypted_arr).map_err( - |e| HandshakeError::CertVerification(format!("Error verifying server cert: {e}")), + noise.mix_shared_secret(static_kp.private_key.serialize(), &server_ephemeral)?; + + let encrypted_payload = noise.encrypt(payload)?; + + let client_finish = HandshakeUtils::build_client_finish(encrypted_pubkey, encrypted_payload); + let mut buf = Vec::new(); + client_finish.encode(&mut buf)?; + Ok(buf) +} + +/// Handshake state for **Noise IK** — used on reconnect when the device has a +/// cached server static public key. Saves one round trip vs XX and ships a +/// 0-RTT payload (login info) inside the very first message. +/// +/// IK message 1 layout per Noise § 7.5 (`-> e, es, s, ss`) preceded by the +/// pre-message `<- s` (responder static is known to initiator): +/// +/// ```text +/// mixHash(server_static_pub) <- pre-message +/// gen e +/// mixHash(e_pub) <- e token +/// mixKey(DH(e_priv, server_static)) <- es +/// encryptAndHash(s_pub) <- s token (encrypted client static) +/// mixKey(DH(s_priv, server_static)) <- ss +/// encryptAndHash(payload) <- 0-RTT login payload +/// ``` +pub struct IkHandshakeState { + noise: NoiseHandshake, + ephemeral_kp: KeyPair, + static_kp: KeyPair, + /// Server static public key cached from a previous XX. Used both for the + /// pre-message MixHash and for `es` / `ss` derivations. + server_static_pub: [u8; 32], + payload: Vec, +} + +impl IkHandshakeState { + pub fn new( + static_kp: KeyPair, + server_static_pub: [u8; 32], + client_payload: Vec, + prologue: &[u8], + ) -> Result { + let ephemeral_kp = KeyPair::generate(&mut rand::rng()); + let noise = NoiseHandshake::new(wacore_binary::consts::NOISE_PATTERN_IK, prologue)?; + + Ok(Self { + noise, + ephemeral_kp, + static_kp, + server_static_pub, + payload: client_payload, + }) + } + + /// Builds and serializes the IK ClientHello carrying the encrypted + /// client static and 0-RTT payload alongside the ephemeral. + pub fn build_client_hello(&mut self) -> Result> { + // pre-message: <- s + self.noise.authenticate(&self.server_static_pub); + + // -> e + let ephemeral_pub_bytes = self.ephemeral_kp.public_key.public_key_bytes().to_vec(); + self.noise.authenticate(&ephemeral_pub_bytes); + + // es: DH(e_priv, server_static_pub) + self.noise.mix_shared_secret( + self.ephemeral_kp.private_key.serialize(), + &self.server_static_pub, )?; - let encrypted_pubkey = self + // -> s (encrypted) + let encrypted_static = self .noise .encrypt(self.static_kp.public_key.public_key_bytes())?; - self.noise - .mix_shared_secret(self.static_kp.private_key.serialize(), &server_ephemeral)?; + // ss: DH(s_priv, server_static_pub) + self.noise.mix_shared_secret( + self.static_kp.private_key.serialize(), + &self.server_static_pub, + )?; + // 0-RTT payload (encrypted) let encrypted_payload = self.noise.encrypt(&self.payload)?; - let client_finish = - HandshakeUtils::build_client_finish(encrypted_pubkey, encrypted_payload); - + let msg = HandshakeUtils::build_ik_client_hello( + &ephemeral_pub_bytes, + encrypted_static, + encrypted_payload, + ); let mut buf = Vec::new(); - client_finish.encode(&mut buf)?; + msg.encode(&mut buf)?; Ok(buf) } - pub fn finish(self) -> Result<(NoiseCipher, NoiseCipher)> { - self.noise.finish() + /// `serverHello.static.is_some()` signals fallback (server rotated static). + pub fn read_server_hello(self, response_bytes: &[u8]) -> Result { + let server_hello = HandshakeUtils::parse_server_hello_body(response_bytes)?; + + if server_hello.r#static.is_some() { + // Fallback: server rejected our cached static. + return Ok(IkServerHelloOutcome::Fallback(Box::new(IkFallbackInputs { + ephemeral_kp: self.ephemeral_kp, + static_kp: self.static_kp, + client_payload: self.payload, + server_hello_bytes: response_bytes.to_vec(), + }))); + } + + let server_ephemeral = server_hello + .ephemeral + .ok_or(HandshakeError::IncompleteResponse)?; + let cert_payload = server_hello + .payload + .ok_or(HandshakeError::IncompleteResponse)?; + let server_ephemeral: [u8; 32] = server_ephemeral + .try_into() + .map_err(|_| HandshakeError::InvalidKeyLength)?; + + let mut noise = self.noise; + + // <- e + noise.authenticate(&server_ephemeral); + // ee + noise.mix_shared_secret(self.ephemeral_kp.private_key.serialize(), &server_ephemeral)?; + // se + noise.mix_shared_secret(self.static_kp.private_key.serialize(), &server_ephemeral)?; + + // Decrypting the cert payload also authenticates the transcript via + // AEAD. If the server is impersonating with a stale static the AEAD + // tag check fails here. + let _cert_plaintext = noise.decrypt(&cert_payload)?; + + let (write_cipher, read_cipher) = noise.finish()?; + Ok(IkServerHelloOutcome::Continue(IkHandshakeOutcome { + write_cipher, + read_cipher, + })) + } +} + +/// Handshake state for **Noise XXfallback** — used to recover from an IK +/// rejection without losing the ephemeral that was already on the wire. +/// +/// The trick (mirrored from WA Web's `doFallbackHandshake`): re-init Noise +/// with the XXfallback protocol_name, then `mixHash(client_ephemeral_pub)` +/// to bring the transcript to the same state it would be in if the client +/// had just sent an XX ClientHello. From there the ServerHello processing +/// and ClientFinish look exactly like XX. +pub struct XxFallbackHandshakeState { + noise: NoiseHandshake, + ephemeral_kp: KeyPair, + static_kp: KeyPair, + payload: Vec, + server_hello_bytes: Vec, + cert_chain: Option, +} + +impl XxFallbackHandshakeState { + pub fn from_ik_failure(inputs: IkFallbackInputs, prologue: &[u8]) -> Result { + let mut noise = + NoiseHandshake::new(wacore_binary::consts::NOISE_PATTERN_XXFALLBACK, prologue)?; + // Reuse the ephemeral that was already sent in the IK ClientHello — + // its public bytes go into the XXfallback transcript here. + noise.authenticate(inputs.ephemeral_kp.public_key.public_key_bytes()); + + Ok(Self { + noise, + ephemeral_kp: inputs.ephemeral_kp, + static_kp: inputs.static_kp, + payload: inputs.client_payload, + server_hello_bytes: inputs.server_hello_bytes, + cert_chain: None, + }) + } + + pub fn build_client_finish(&mut self) -> Result> { + let (server_ephemeral_raw, server_static_ciphertext, certificate_ciphertext) = + HandshakeUtils::parse_server_hello(&self.server_hello_bytes).map_err(|e| { + HandshakeError::CertVerification(format!("Error parsing server hello: {e}")) + })?; + + let server_ephemeral: [u8; 32] = server_ephemeral_raw + .try_into() + .map_err(|_| HandshakeError::InvalidKeyLength)?; + + process_xx_server_hello_into( + &mut self.noise, + &self.ephemeral_kp, + &self.static_kp, + &self.payload, + server_ephemeral, + &server_static_ciphertext, + &certificate_ciphertext, + &mut self.cert_chain, + ) + } + + pub fn finish(self) -> Result { + let cert_chain = self.cert_chain.ok_or(HandshakeError::IncompleteResponse)?; + let (write_cipher, read_cipher) = self.noise.finish()?; + Ok(XxHandshakeOutcome { + write_cipher, + read_cipher, + server_cert_chain: cert_chain, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use prost::Message; + use wacore_binary::consts::WA_CONN_HEADER; + use waproto::whatsapp as wa; + + /// A self-contained Noise responder used to exercise the initiator-side + /// state machines end-to-end. Mirrors what the production WhatsApp server + /// does on the wire so each flow can be validated without a network. + struct TestResponder { + identity_kp: KeyPair, + cert_chain_bytes: Vec, + } + + impl TestResponder { + fn new() -> (Self, [u8; 32]) { + let kp = KeyPair::generate(&mut rand::rng()); + let server_static_pub: [u8; 32] = kp.public_key.public_key_bytes().try_into().unwrap(); + let cert_chain_bytes = crate::test_util::build_cert_chain_bytes(&server_static_pub); + ( + Self { + identity_kp: kp, + cert_chain_bytes, + }, + server_static_pub, + ) + } + + fn server_static_pub(&self) -> [u8; 32] { + self.identity_kp + .public_key + .public_key_bytes() + .try_into() + .expect("X25519 pub key is always 32 bytes") + } + } + + /// Variant of `xx_serve` that also returns the server's ephemeral key + /// pair so the test can finish the handshake without exposing secrets + /// from inside `NoiseHandshake`. + fn xx_serve_ext( + responder: &TestResponder, + client_hello_bytes: &[u8], + pattern: &str, + prologue: &[u8], + ) -> (Vec, NoiseHandshake, KeyPair, [u8; 32]) { + let msg = wa::HandshakeMessage::decode(client_hello_bytes).expect("decode hello"); + let client_eph_pub_vec = msg.client_hello.unwrap().ephemeral.unwrap(); + let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); + + let mut noise = NoiseHandshake::new(pattern, prologue).expect("init responder"); + noise.authenticate(&client_eph_pub); + + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + noise.authenticate(&server_eph_pub); + + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_eph_pub) + .expect("ee"); + + let server_static_pub = responder.server_static_pub(); + let encrypted_static = noise.encrypt(&server_static_pub).expect("enc static"); + + noise + .mix_shared_secret( + responder.identity_kp.private_key.serialize(), + &client_eph_pub, + ) + .expect("es"); + + let encrypted_payload = noise + .encrypt(&responder.cert_chain_bytes) + .expect("enc cert"); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: Some(encrypted_static), + payload: Some(encrypted_payload), + ..Default::default() + }), + ..Default::default() + }; + let mut bytes = Vec::new(); + server_hello.encode(&mut bytes).unwrap(); + (bytes, noise, server_eph, client_eph_pub) + } + + fn xx_finish_ext( + mut noise: NoiseHandshake, + server_eph: KeyPair, + client_finish_bytes: &[u8], + ) -> (NoiseCipher, NoiseCipher) { + let msg = wa::HandshakeMessage::decode(client_finish_bytes).expect("decode finish"); + let cf = msg.client_finish.unwrap(); + let client_static = noise.decrypt(&cf.r#static.unwrap()).expect("dec s"); + let client_static_arr: [u8; 32] = client_static.try_into().unwrap(); + + // se: DH(server_eph_priv, client_static_pub) + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_static_arr) + .expect("se"); + + let _payload_pt = noise.decrypt(&cf.payload.unwrap()).expect("dec payload"); + + let (write, read) = noise.finish().expect("finish"); + // Initiator's write is responder's read and vice versa. + (read, write) + } + + /// IK responder that accepts the handshake. Mirrors WA Web's accept + /// path in `ChatSocket.js` (`q(...)` -> serverHello with no static). + fn ik_serve_accept( + responder: &TestResponder, + client_hello_bytes: &[u8], + prologue: &[u8], + ) -> Vec { + // Run IK responder side per Noise § 7.5. + let msg = wa::HandshakeMessage::decode(client_hello_bytes).expect("decode hello"); + let ch = msg.client_hello.unwrap(); + let client_eph_pub_vec = ch.ephemeral.unwrap(); + let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); + let encrypted_static = ch.r#static.unwrap(); + let encrypted_payload = ch.payload.unwrap(); + + let mut noise = + NoiseHandshake::new(wacore_binary::consts::NOISE_PATTERN_IK, prologue).unwrap(); + // pre-message: <- s (responder's own static) + let server_static_pub = responder.server_static_pub(); + noise.authenticate(&server_static_pub); + // -> e + noise.authenticate(&client_eph_pub); + // es: DH(s_priv_resp, e_pub_init) + noise + .mix_shared_secret( + responder.identity_kp.private_key.serialize(), + &client_eph_pub, + ) + .unwrap(); + // -> s (decrypt client static) + let client_static = noise.decrypt(&encrypted_static).unwrap(); + let client_static_arr: [u8; 32] = client_static.try_into().unwrap(); + // ss: DH(s_priv_resp, s_pub_init) + noise + .mix_shared_secret( + responder.identity_kp.private_key.serialize(), + &client_static_arr, + ) + .unwrap(); + // -> payload + let _payload_plaintext = noise.decrypt(&encrypted_payload).unwrap(); + + // Now build ServerHello (<- e, ee, se, payload). + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + noise.authenticate(&server_eph_pub); + // ee + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_eph_pub) + .unwrap(); + // se: DH(e_priv_resp, s_pub_init) + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_static_arr) + .unwrap(); + let encrypted_cert = noise.encrypt(&responder.cert_chain_bytes).unwrap(); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: None, + payload: Some(encrypted_cert), + ..Default::default() + }), + ..Default::default() + }; + let mut bytes = Vec::new(); + server_hello.encode(&mut bytes).unwrap(); + bytes + } + + /// IK responder that rejects with an XX-shaped serverHello, prompting + /// the initiator to fall back. The transcript here intentionally + /// matches what the XXfallback initiator expects. + fn ik_serve_force_fallback( + responder: &TestResponder, + client_hello_bytes: &[u8], + prologue: &[u8], + ) -> (Vec, NoiseHandshake, KeyPair) { + // Pull the client's ephemeral out of the IK clientHello to seed an + // XXfallback responder. We ignore the encrypted client static and + // 0-RTT payload since the client will resend on the XXfallback path. + let msg = wa::HandshakeMessage::decode(client_hello_bytes).expect("decode hello"); + let client_eph_pub_vec = msg.client_hello.unwrap().ephemeral.unwrap(); + let client_eph_pub: [u8; 32] = client_eph_pub_vec.try_into().unwrap(); + + // Stand up a fresh XXfallback responder and authenticate the + // already-sent client ephemeral. + let mut noise = + NoiseHandshake::new(wacore_binary::consts::NOISE_PATTERN_XXFALLBACK, prologue).unwrap(); + noise.authenticate(&client_eph_pub); + + let server_eph = KeyPair::generate(&mut rand::rng()); + let server_eph_pub: [u8; 32] = server_eph.public_key.public_key_bytes().try_into().unwrap(); + noise.authenticate(&server_eph_pub); + noise + .mix_shared_secret(server_eph.private_key.serialize(), &client_eph_pub) + .unwrap(); + + let server_static_pub = responder.server_static_pub(); + let encrypted_static = noise.encrypt(&server_static_pub).unwrap(); + + noise + .mix_shared_secret( + responder.identity_kp.private_key.serialize(), + &client_eph_pub, + ) + .unwrap(); + + let encrypted_cert = noise.encrypt(&responder.cert_chain_bytes).unwrap(); + + let server_hello = wa::HandshakeMessage { + server_hello: Some(wa::handshake_message::ServerHello { + ephemeral: Some(server_eph_pub.to_vec()), + r#static: Some(encrypted_static), + payload: Some(encrypted_cert), + ..Default::default() + }), + ..Default::default() + }; + let mut bytes = Vec::new(); + server_hello.encode(&mut bytes).unwrap(); + (bytes, noise, server_eph) + } + + #[test] + fn xx_handshake_round_trip_completes() { + let prologue = WA_CONN_HEADER; + let (responder, _) = TestResponder::new(); + + let client_static = KeyPair::generate(&mut rand::rng()); + let payload = b"login-payload".to_vec(); + + let mut state = + XxHandshakeState::new(client_static.clone(), payload.clone(), &prologue).unwrap(); + let client_hello = state.build_client_hello().unwrap(); + + let (server_hello, server_noise, server_eph, _client_eph_pub) = xx_serve_ext( + &responder, + &client_hello, + wacore_binary::consts::NOISE_PATTERN_XX, + &prologue, + ); + + let client_finish = state + .read_server_hello_and_build_client_finish(&server_hello) + .expect("server hello must be processable"); + + let outcome = state.finish().expect("finish"); + let (server_write, server_read) = xx_finish_ext(server_noise, server_eph, &client_finish); + + // Cipher pair must be symmetric (initiator's write == responder's read). + let plaintext = b"hello after xx"; + let ct = outcome + .write_cipher + .encrypt_with_counter(0, plaintext) + .unwrap(); + let mut buf = ct.clone(); + server_read + .decrypt_in_place_with_counter(0, &mut buf) + .expect("decrypt with responder read"); + assert_eq!(buf, plaintext); + + let ct2 = server_write.encrypt_with_counter(0, plaintext).unwrap(); + let mut buf2 = ct2.clone(); + outcome + .read_cipher + .decrypt_in_place_with_counter(0, &mut buf2) + .expect("decrypt with initiator read"); + assert_eq!(buf2, plaintext); + + // Cert chain must be the one the responder served. + assert_eq!( + outcome.server_cert_chain.leaf_key, + responder.server_static_pub() + ); + } + + #[test] + fn ik_handshake_round_trip_continue() { + let prologue = WA_CONN_HEADER; + let (responder, server_static_pub) = TestResponder::new(); + + let client_static = KeyPair::generate(&mut rand::rng()); + let payload = b"ik-zero-rtt".to_vec(); + + let mut state = IkHandshakeState::new( + client_static.clone(), + server_static_pub, + payload.clone(), + &prologue, + ) + .unwrap(); + let client_hello = state.build_client_hello().unwrap(); + + let server_hello = ik_serve_accept(&responder, &client_hello, &prologue); + let outcome = state + .read_server_hello(&server_hello) + .expect("ik must succeed"); + match outcome { + IkServerHelloOutcome::Continue(_) => (), + IkServerHelloOutcome::Fallback(_) => panic!("expected Continue, got Fallback"), + } + } + + #[test] + fn ik_to_xx_fallback_round_trip() { + let prologue = WA_CONN_HEADER; + let (responder, server_static_pub) = TestResponder::new(); + + let client_static = KeyPair::generate(&mut rand::rng()); + let payload = b"login-after-fallback".to_vec(); + + let mut ik = IkHandshakeState::new( + client_static.clone(), + server_static_pub, + payload.clone(), + &prologue, + ) + .unwrap(); + let ik_hello = ik.build_client_hello().unwrap(); + + let (server_hello, server_noise, server_eph) = + ik_serve_force_fallback(&responder, &ik_hello, &prologue); + + let outcome = ik + .read_server_hello(&server_hello) + .expect("ik must report fallback, not error"); + let inputs = match outcome { + IkServerHelloOutcome::Fallback(inp) => *inp, + IkServerHelloOutcome::Continue(_) => panic!("expected Fallback"), + }; + + let mut fb = XxFallbackHandshakeState::from_ik_failure(inputs, &prologue).unwrap(); + let client_finish = fb.build_client_finish().expect("fallback must succeed"); + let result = fb.finish().expect("finish"); + + let (server_write, server_read) = xx_finish_ext(server_noise, server_eph, &client_finish); + + let plaintext = b"hi over fallback"; + let ct = result + .write_cipher + .encrypt_with_counter(0, plaintext) + .unwrap(); + let mut buf = ct.clone(); + server_read + .decrypt_in_place_with_counter(0, &mut buf) + .expect("responder reads what initiator wrote"); + assert_eq!(buf, plaintext); + + let ct2 = server_write.encrypt_with_counter(0, plaintext).unwrap(); + let mut buf2 = ct2.clone(); + result + .read_cipher + .decrypt_in_place_with_counter(0, &mut buf2) + .expect("initiator reads what responder wrote"); + assert_eq!(buf2, plaintext); + + assert_eq!( + result.server_cert_chain.leaf_key, + responder.server_static_pub() + ); + } + + #[test] + fn ik_with_wrong_server_static_fails_at_decrypt() { + let prologue = WA_CONN_HEADER; + let (responder, _correct_pub) = TestResponder::new(); + + // Wrong server static = handshake will not authenticate. We expect + // a Decrypt error somewhere in read_server_hello. + let bogus_static = [0xDEu8; 32]; + + let client_static = KeyPair::generate(&mut rand::rng()); + let mut state = + IkHandshakeState::new(client_static, bogus_static, b"x".to_vec(), &prologue).unwrap(); + let hello = state.build_client_hello().unwrap(); + + // Responder uses its actual key, so the mac will not match. + let server_hello_result = + std::panic::catch_unwind(|| ik_serve_accept(&responder, &hello, &prologue)); + // The responder itself will panic on decrypt of the client static + // (since the bogus server_static_pub means es derivation diverges). + assert!( + server_hello_result.is_err(), + "responder must reject IK forged with wrong server static" + ); } } diff --git a/wacore/noise/src/lib.rs b/wacore/noise/src/lib.rs index cd6f704ed..6168ed475 100644 --- a/wacore/noise/src/lib.rs +++ b/wacore/noise/src/lib.rs @@ -24,9 +24,10 @@ //! # Example (WhatsApp) //! //! ```ignore -//! use wacore_noise::{NoiseHandshake, HandshakeUtils}; +//! use wacore_noise::NoiseHandshake; +//! use wacore_binary::consts::{NOISE_PATTERN_XX, WA_CONN_HEADER}; //! -//! let mut nh = NoiseHandshake::new(NOISE_START_PATTERN, &WA_CONN_HEADER)?; +//! let mut nh = NoiseHandshake::new(NOISE_PATTERN_XX, &WA_CONN_HEADER)?; //! nh.authenticate(&ephemeral_public); //! nh.mix_shared_secret(&private_key, &their_public)?; //! let (write_key, read_key) = nh.finish()?; @@ -38,12 +39,16 @@ pub mod framing; mod handshake; mod state; +#[cfg(any(test, feature = "test-util"))] +pub mod test_util; + pub use edge_routing::{ EdgeRoutingError, MAX_EDGE_ROUTING_LEN, build_edge_routing_preintro, build_handshake_header, }; pub use error::{NoiseError, Result}; pub use handshake::{ - HandshakeError, HandshakeState, HandshakeUtils, NoiseHandshake, Result as HandshakeResult, - WA_CERT_PUB_KEY, + HandshakeError, HandshakeUtils, IkFallbackInputs, IkHandshakeOutcome, IkHandshakeState, + IkServerHelloOutcome, NoiseHandshake, Result as HandshakeResult, VerifiedServerCertChain, + WA_CERT_PUB_KEY, XxFallbackHandshakeState, XxHandshakeOutcome, XxHandshakeState, }; pub use state::{NoiseCipher, NoiseKeys, NoiseState, generate_iv}; diff --git a/wacore/noise/src/state.rs b/wacore/noise/src/state.rs index f59528552..fe679f147 100644 --- a/wacore/noise/src/state.rs +++ b/wacore/noise/src/state.rs @@ -71,14 +71,6 @@ impl NoiseCipher { } } -fn to_array(slice: &[u8], name: &'static str) -> Result<[u8; 32]> { - slice.try_into().map_err(|_| NoiseError::InvalidKeyLength { - name, - expected: 32, - got: slice.len(), - }) -} - fn sha256_digest(data: &[u8]) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update(data); @@ -111,10 +103,15 @@ impl NoiseState { } /// Creates a new Noise state with the given pattern and prologue. + /// + /// Per Noise spec § 5.2: when `protocol_name` is ≤ HASHLEN bytes, append + /// zero bytes to make HASHLEN; otherwise hash with SHA256. pub fn new(pattern: impl AsRef<[u8]>, prologue: &[u8]) -> Result { let pattern = pattern.as_ref(); - let h: [u8; 32] = if pattern.len() == 32 { - to_array(pattern, "noise pattern prefix")? + let h: [u8; 32] = if pattern.len() <= 32 { + let mut h = [0u8; 32]; + h[..pattern.len()].copy_from_slice(pattern); + h } else { sha256_digest(pattern) }; @@ -249,6 +246,40 @@ mod tests { assert_ne!(noise.hash(), noise.salt()); } + #[test] + fn test_protocol_name_short_is_zero_padded() { + // Spec § 5.2: name <= HASHLEN bytes is zero-padded, NOT hashed. + // The 28-byte unpadded form must produce the same h0 as the 32-byte + // pre-padded form, after applying the same prologue. + let prologue = b"test"; + let unpadded = NoiseState::new(b"Noise_XX_25519_AESGCM_SHA256", prologue) + .expect("unpadded init should succeed"); + let padded = NoiseState::new(b"Noise_XX_25519_AESGCM_SHA256\0\0\0\0", prologue) + .expect("padded init should succeed"); + assert_eq!(unpadded.hash(), padded.hash()); + assert_eq!(unpadded.salt(), padded.salt()); + } + + #[test] + fn test_protocol_name_long_is_hashed() { + // 36-byte XXfallback name exceeds HASHLEN, so h0 = SHA256(name). + // We isolate the name-handling branch by constructing two states with + // identical prologues: one that hashes (>32 byte name) and one with a + // hand-computed 32-byte equivalent. They must converge. + let prologue = b"prologue-bytes"; + let long_name: &[u8] = b"Noise_XXfallback_25519_AESGCM_SHA256"; + let state_long = + NoiseState::new(long_name, prologue).expect("long-name init should succeed"); + + // Build the same handshake state with the pre-hashed name (32 bytes). + let prehashed = sha256_digest(long_name); + let state_short = + NoiseState::new(prehashed, prologue).expect("short-name init should succeed"); + + assert_eq!(state_long.hash(), state_short.hash()); + assert_eq!(state_long.salt(), state_short.salt()); + } + #[test] fn test_encrypt_decrypt_roundtrip() { let prologue = b"test"; diff --git a/wacore/noise/src/test_util.rs b/wacore/noise/src/test_util.rs new file mode 100644 index 000000000..96d316ee0 --- /dev/null +++ b/wacore/noise/src/test_util.rs @@ -0,0 +1,55 @@ +//! Test fixtures shared between this crate's unit tests and downstream +//! integration tests. Visible only under `#[cfg(test)]` (this crate) or +//! when the `test-util` feature is enabled. + +use prost::Message; +use waproto::whatsapp::{self as wa, cert_chain::noise_certificate}; + +/// Builds a minimal `CertChain` blob whose leaf.key matches `server_static_pub`. +/// +/// The validity windows are pinned (`not_before = 1_700_000_000` for both +/// certs, `not_after` slightly under `1_900_000_000`) so callers can exercise +/// `select_pattern`'s clock checks against deterministic boundaries. +/// +/// Signatures are zero-filled — the client today does NOT verify the +/// intermediate's Ed25519 signature against `WA_CERT_PUB_KEY`, so the bytes +/// only need to round-trip through prost. +pub fn build_cert_chain_bytes(server_static_pub: &[u8; 32]) -> Vec { + let intermediate_details = noise_certificate::Details { + serial: Some(1), + issuer_serial: Some(0), + key: Some(vec![0xCC; 32]), + not_before: Some(1_700_000_000), + not_after: Some(1_900_000_000), + }; + let mut intermediate_details_bytes = Vec::new(); + intermediate_details + .encode(&mut intermediate_details_bytes) + .expect("encode intermediate details"); + + let leaf_details = noise_certificate::Details { + serial: Some(2), + issuer_serial: Some(1), + key: Some(server_static_pub.to_vec()), + not_before: Some(1_700_000_500), + not_after: Some(1_899_999_500), + }; + let mut leaf_details_bytes = Vec::new(); + leaf_details + .encode(&mut leaf_details_bytes) + .expect("encode leaf details"); + + let chain = wa::CertChain { + leaf: Some(wa::cert_chain::NoiseCertificate { + details: Some(leaf_details_bytes), + signature: Some(vec![0u8; 64]), + }), + intermediate: Some(wa::cert_chain::NoiseCertificate { + details: Some(intermediate_details_bytes), + signature: Some(vec![0u8; 64]), + }), + }; + let mut bytes = Vec::new(); + chain.encode(&mut bytes).expect("encode chain"); + bytes +} diff --git a/wacore/src/handshake/mod.rs b/wacore/src/handshake/mod.rs index 9e555bdf4..385d6b114 100644 --- a/wacore/src/handshake/mod.rs +++ b/wacore/src/handshake/mod.rs @@ -1,6 +1,8 @@ // Re-export everything from wacore-noise pub use wacore_noise::{ - EdgeRoutingError, HandshakeError, HandshakeResult as Result, HandshakeState, HandshakeUtils, - MAX_EDGE_ROUTING_LEN, NoiseCipher, NoiseError, NoiseHandshake, WA_CERT_PUB_KEY, - build_edge_routing_preintro, build_handshake_header, generate_iv, + EdgeRoutingError, HandshakeError, HandshakeResult as Result, HandshakeUtils, IkFallbackInputs, + IkHandshakeOutcome, IkHandshakeState, IkServerHelloOutcome, MAX_EDGE_ROUTING_LEN, NoiseCipher, + NoiseError, NoiseHandshake, VerifiedServerCertChain, WA_CERT_PUB_KEY, XxFallbackHandshakeState, + XxHandshakeOutcome, XxHandshakeState, build_edge_routing_preintro, build_handshake_header, + generate_iv, }; diff --git a/wacore/src/store/commands.rs b/wacore/src/store/commands.rs index 252948050..36c6db5ab 100644 --- a/wacore/src/store/commands.rs +++ b/wacore/src/store/commands.rs @@ -1,6 +1,6 @@ use crate::client_profile::ClientProfile; use crate::store::Device; -use crate::store::device::DevicePropsOverride; +use crate::store::device::{CachedServerCertChain, DevicePropsOverride}; use wacore_binary::Jid; use waproto::whatsapp as wa; @@ -18,6 +18,13 @@ pub enum DeviceCommand { SetAdvSecretKey([u8; 32]), SetNctSalt(Option>), SetNctSaltFromHistorySync(Vec), + /// Cache the server cert chain extracted from a successful XX (or + /// XX-fallback) handshake. Enables Noise IK on the next connect. + SetServerCertChain(CachedServerCertChain), + /// Drop the cached server cert chain (e.g. after IK fails with a + /// crypto-fatal error, signalling that the cached `leaf.key` is stale). + /// Forces XX on the next connect. + ClearServerCertChain, } pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) { @@ -64,6 +71,12 @@ pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) { device.nct_salt = Some(salt); } } + DeviceCommand::SetServerCertChain(chain) => { + device.server_cert_chain = Some(chain); + } + DeviceCommand::ClearServerCertChain => { + device.server_cert_chain = None; + } } } @@ -71,6 +84,64 @@ pub fn apply_command_to_device(device: &mut Device, command: DeviceCommand) { mod tests { use super::{DeviceCommand, apply_command_to_device}; use crate::store::Device; + use crate::store::device::{CachedNoiseCert, CachedServerCertChain}; + + fn dummy_chain() -> CachedServerCertChain { + CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0x11; 32], + not_before: 1_700_000_000, + not_after: 1_900_000_000, + }, + leaf: CachedNoiseCert { + key: [0x22; 32], + not_before: 1_700_000_100, + not_after: 1_899_999_900, + }, + } + } + + #[test] + fn set_server_cert_chain_populates_field() { + let mut device = Device::new(); + assert!(device.server_cert_chain.is_none()); + + let chain = dummy_chain(); + apply_command_to_device( + &mut device, + DeviceCommand::SetServerCertChain(chain.clone()), + ); + assert_eq!(device.server_cert_chain, Some(chain)); + } + + #[test] + fn clear_server_cert_chain_drops_field() { + // Seed via the command path rather than mutating Device directly, + // so that the test exercises the same single mutation surface used + // in production (PersistenceManager::process_command -> apply_*). + let mut device = Device::new(); + apply_command_to_device( + &mut device, + DeviceCommand::SetServerCertChain(dummy_chain()), + ); + assert!(device.server_cert_chain.is_some(), "seed precondition"); + + apply_command_to_device(&mut device, DeviceCommand::ClearServerCertChain); + assert!(device.server_cert_chain.is_none()); + } + + #[test] + fn set_then_clear_roundtrips() { + let mut device = Device::new(); + let chain = dummy_chain(); + apply_command_to_device( + &mut device, + DeviceCommand::SetServerCertChain(chain.clone()), + ); + assert_eq!(device.server_cert_chain.as_ref(), Some(&chain)); + apply_command_to_device(&mut device, DeviceCommand::ClearServerCertChain); + assert!(device.server_cert_chain.is_none()); + } #[test] fn test_history_sync_salt_backfills_when_no_syncd_mutation_was_seen() { diff --git a/wacore/src/store/device.rs b/wacore/src/store/device.rs index 357145955..1656436fb 100644 --- a/wacore/src/store/device.rs +++ b/wacore/src/store/device.rs @@ -243,6 +243,51 @@ pub struct Device { /// This prevents stale history sync data from resurrecting a cleared salt. #[serde(skip)] pub nct_salt_sync_seen: bool, + /// Server cert chain cached from the last successful XX (or XX-fallback) + /// handshake. Enables Noise IK on the next connect by exposing + /// `leaf.key` as the server's static public key, and lets us reject + /// stale entries via `not_after` before even attempting IK. + /// `None` forces XX on the next connect. + #[serde(default)] + pub server_cert_chain: Option, +} + +/// Minimal cached form of a Noise certificate. Mirrors the JSON shape WA Web +/// persists in `waNoiseInfo.certificateChainBuffer` (only `key` plus the +/// validity window — signatures and issuer_serial are intentionally dropped). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedNoiseCert { + /// 32-byte X25519 public key from `NoiseCertificate.Details.key`. + pub key: [u8; 32], + /// Unix epoch seconds. Validation window from `NoiseCertificate.Details`. + pub not_before: i64, + pub not_after: i64, +} + +/// Cached form of the server's two-cert chain. `leaf.key` is the server +/// static public key consumed by Noise IK; the intermediate is kept solely +/// to mirror WA Web's expiry checks. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedServerCertChain { + pub intermediate: CachedNoiseCert, + pub leaf: CachedNoiseCert, +} + +impl From for CachedServerCertChain { + fn from(v: wacore_noise::VerifiedServerCertChain) -> Self { + Self { + intermediate: CachedNoiseCert { + key: v.intermediate_key, + not_before: v.intermediate_not_before, + not_after: v.intermediate_not_after, + }, + leaf: CachedNoiseCert { + key: v.leaf_key, + not_before: v.leaf_not_before, + not_after: v.leaf_not_after, + }, + } + } } impl Default for Device { @@ -298,6 +343,7 @@ impl Device { server_has_prekeys: false, nct_salt: None, nct_salt_sync_seen: false, + server_cert_chain: None, } } @@ -320,6 +366,12 @@ impl Device { self.pn.is_some() && !self.push_name.is_empty() } + /// Mirrors WA Web `WAWebUserPrefsMultiDevice.isRegistered()`: + /// `!!(m() && getMaybeMeDevicePn())`. + pub fn is_registered(&self) -> bool { + self.pn.is_some() + } + pub fn set_device_props(&mut self, o: DevicePropsOverride) { if let Some(os) = o.os { self.device_props.os = Some(os); @@ -443,6 +495,43 @@ mod tests { ); } + #[test] + fn test_device_server_cert_chain_serde_roundtrip() { + let mut device = Device::new(); + device.server_cert_chain = Some(CachedServerCertChain { + intermediate: CachedNoiseCert { + key: [0xAA; 32], + not_before: 1_700_000_000, + not_after: 1_900_000_000, + }, + leaf: CachedNoiseCert { + key: [0xBB; 32], + not_before: 1_700_000_500, + not_after: 1_899_999_500, + }, + }); + + let json = serde_json::to_string(&device).expect("serialize should succeed"); + let restored: Device = serde_json::from_str(&json).expect("deserialize should succeed"); + assert_eq!(device.server_cert_chain, restored.server_cert_chain); + } + + #[test] + fn test_device_legacy_record_without_cert_chain_deserializes() { + // Devices serialized before this field existed must still load — the + // #[serde(default)] attribute is what makes that work. + let mut device = Device::new(); + device.server_cert_chain = None; + let json = serde_json::to_string(&device).expect("serialize should succeed"); + // Strip the field as if a legacy file lacked it entirely. + let stripped = json.replace(",\"server_cert_chain\":null", ""); + assert_ne!(stripped, json, "field was expected to be present in JSON"); + + let restored: Device = + serde_json::from_str(&stripped).expect("legacy record should deserialize"); + assert!(restored.server_cert_chain.is_none()); + } + /// Regression: #403 #[test] fn test_device_serde_preserves_account() { diff --git a/wacore/src/store/mod.rs b/wacore/src/store/mod.rs index b716f5527..d8fc959fa 100644 --- a/wacore/src/store/mod.rs +++ b/wacore/src/store/mod.rs @@ -10,7 +10,7 @@ pub mod traits; pub use cache::CacheStore; pub use commands::*; -pub use device::{Device, DevicePropsOverride}; +pub use device::{CachedNoiseCert, CachedServerCertChain, Device, DevicePropsOverride}; pub use in_memory::InMemoryBackend; pub use persistence::PersistenceManager; pub use signal_cache::SignalStoreCache; diff --git a/wacore/tests/noise_handshake_test.rs b/wacore/tests/noise_handshake_test.rs index 9a6908827..ba9769cc0 100644 --- a/wacore/tests/noise_handshake_test.rs +++ b/wacore/tests/noise_handshake_test.rs @@ -6,7 +6,7 @@ use wacore::handshake::NoiseHandshake; use wacore::libsignal::crypto::CryptographicHash; use wacore::libsignal::protocol::{PrivateKey, PublicKey}; use wacore::noise::generate_iv; -use wacore_binary::consts::{NOISE_START_PATTERN, WA_CONN_HEADER}; +use wacore_binary::consts::{NOISE_PATTERN_XX, WA_CONN_HEADER}; fn hex_to_bytes(hex_str: &str) -> [u8; N] { hex::decode(hex_str) @@ -169,7 +169,7 @@ fn test_full_handshake_flow_with_go_data() { hex_to_bytes::<32>("4a82b448599eb44f85bacedaff0a81820999a87be156b08989c2857b8651d4d2"); println!("Step 1: Prologue"); - let mut nh = NoiseHandshake::new(NOISE_START_PATTERN, wa_header) + let mut nh = NoiseHandshake::new(NOISE_PATTERN_XX, wa_header) .expect("noise handshake should initialize"); assert_eq!(*nh.hash(), hash_after_prologue, "Mismatch after prologue"); @@ -269,3 +269,38 @@ fn test_initial_pattern_hash() { assert_eq!(actual_hash.as_slice(), expected_hash.as_slice()); } + +/// Locks the post-init hash for `Noise_XX_25519_AESGCM_SHA256` (zero-padded to +/// 32 bytes) with WhatsApp's WA_CONN_HEADER prologue. +/// +/// The expected value is `SHA256(name_padded || WA_CONN_HEADER)` where +/// `name_padded` is the 32-byte pattern bytes used directly as `h0` per Noise +/// § 5.2 (length <= HASHLEN, no SHA256 of the name). +/// +/// Pre-computed via: +/// python3 -c "import hashlib; \ +/// h=b'Noise_XX_25519_AESGCM_SHA256\x00\x00\x00\x00'+bytes([0x57,0x41,6,3]); \ +/// print(hashlib.sha256(h).hexdigest())" +#[test] +fn test_xx_h_after_init_matches_known_vector() { + let nh = + NoiseHandshake::new(NOISE_PATTERN_XX, &WA_CONN_HEADER).expect("noise init should succeed"); + + let expected: [u8; 32] = + hex_to_bytes("ffff0c9267310966f1311170c04b38c79504285bf5edf763e5c946492a50a755"); + assert_eq!( + nh.hash(), + &expected, + "h after XX init with WA_CONN_HEADER drifted; \ + padding bug or WA_CONN_HEADER changed" + ); + // h0 == salt0 in Noise: both seeded from the (post-pad/hash) name. + // After authenticate(prologue), only `hash` mutates; `salt` is untouched. + let expected_salt: [u8; 32] = + hex_to_bytes("4e6f6973655f58585f32353531395f41455347434d5f53484132353600000000"); + assert_eq!( + nh.salt(), + &expected_salt, + "salt should equal raw 32-byte pattern bytes (not hashed)" + ); +}