diff --git a/core/src/input.rs b/core/src/input.rs index a7804f8..e68de06 100644 --- a/core/src/input.rs +++ b/core/src/input.rs @@ -285,6 +285,21 @@ pub struct FecStatus { pub multi_fec_block_count: u8, } +impl FecStatus { + fn validate(&self) -> Result<()> { + if self.total_data_packets == 0 + || self.received_data_packets > self.total_data_packets + || self.received_parity_packets > self.total_parity_packets + || self.fec_percentage > 100 + || self.multi_fec_block_count == 0 + || self.multi_fec_block_index >= self.multi_fec_block_count + { + return Err(CoreError::Field); + } + Ok(()) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum FeedbackEvent { IdrRequest, @@ -335,19 +350,23 @@ pub fn decode_feedback(bytes: &[u8]) -> Result { let body = &bytes[FEEDBACK_HEADER..]; match (direction, bytes[5]) { (0, 0x01) if body.is_empty() => Ok(FeedbackEvent::IdrRequest), - (0, 0x02) if body.len() == 21 => Ok(FeedbackEvent::Fec(FecStatus { - frame_index: u32_at(body, 0), - highest_received_sequence: u16_at(body, 4), - next_contiguous_sequence: u16_at(body, 6), - missing_before_highest: u16_at(body, 8), - total_data_packets: u16_at(body, 10), - total_parity_packets: u16_at(body, 12), - received_data_packets: u16_at(body, 14), - received_parity_packets: u16_at(body, 16), - fec_percentage: body[18], - multi_fec_block_index: body[19], - multi_fec_block_count: body[20], - })), + (0, 0x02) if body.len() == 21 => { + let status = FecStatus { + frame_index: u32_at(body, 0), + highest_received_sequence: u16_at(body, 4), + next_contiguous_sequence: u16_at(body, 6), + missing_before_highest: u16_at(body, 8), + total_data_packets: u16_at(body, 10), + total_parity_packets: u16_at(body, 12), + received_data_packets: u16_at(body, 14), + received_parity_packets: u16_at(body, 16), + fec_percentage: body[18], + multi_fec_block_index: body[19], + multi_fec_block_count: body[20], + }; + status.validate()?; + Ok(FeedbackEvent::Fec(status)) + } (0, 0x03) if body.is_empty() => Ok(FeedbackEvent::TerminalReceipt), (1, 0x10) if body.len() == 4 => Ok(FeedbackEvent::Termination { exit_code: u32_at(body, 0), @@ -375,6 +394,7 @@ pub fn encode_feedback(event: &FeedbackEvent) -> Result> { let (direction, kind, body) = match event { FeedbackEvent::IdrRequest => (0, 0x01, Vec::new()), FeedbackEvent::Fec(status) => { + status.validate()?; let mut body = Vec::with_capacity(21); body.extend_from_slice(&status.frame_index.to_be_bytes()); push_u16(&mut body, status.highest_received_sequence); diff --git a/core/src/session.rs b/core/src/session.rs index 89eaa32..2c7a333 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -1,6 +1,7 @@ use std::collections::VecDeque; use crate::error::{CoreError, Result}; +use crate::input::decode_input; use crate::media::EncodedUnit; pub const INPUT_QUEUE_CAPACITY: usize = 64; @@ -28,18 +29,16 @@ impl SessionCore { Self::default() } - /// Enqueues one already-validated VGI1 envelope without blocking. + /// Validates and enqueues one VGI1 envelope without blocking. /// /// # Errors /// - /// Returns `invalid_argument`, `queue_full`, or `cancelled`. - pub fn enqueue_input(&mut self, bytes: Vec) -> Result<()> { + /// Returns a stable VGI1 parse error, `queue_full`, or `cancelled`. + pub fn enqueue_input(&mut self, bytes: Vec, features: &[&str]) -> Result<()> { if self.cancelled { return Err(CoreError::Cancelled); } - if bytes.is_empty() || bytes.len() > 23 { - return Err(CoreError::InvalidArgument); - } + decode_input(&bytes, features)?; if self.input.len() == INPUT_QUEUE_CAPACITY { return Err(CoreError::QueueFull); } @@ -47,6 +46,12 @@ impl SessionCore { Ok(()) } + /// Removes the oldest queued VGI1 envelope. + #[must_use] + pub fn dequeue_input(&mut self) -> Option> { + self.input.pop_front() + } + /// Enqueues one bounded reliable control body without blocking. /// /// # Errors @@ -66,6 +71,12 @@ impl SessionCore { Ok(()) } + /// Removes the oldest queued reliable control body. + #[must_use] + pub fn dequeue_control(&mut self) -> Option> { + self.control.pop_front() + } + /// Enqueues one bounded complete encoded media unit. /// /// # Errors @@ -145,15 +156,16 @@ mod tests { fn queues_are_bounded_and_cancellation_is_idempotent() { let mut session = SessionCore::new(); assert_eq!( - session.enqueue_input(vec![0; 24]), - Err(CoreError::InvalidArgument) + session.enqueue_input(vec![0; 24], &[]), + Err(CoreError::Magic) ); - for value in 0..INPUT_QUEUE_CAPACITY { + let input = b"VGI1\x01\x04\x01\x00\x00\x1e".to_vec(); + for _ in 0..INPUT_QUEUE_CAPACITY { session - .enqueue_input(vec![u8::try_from(value).expect("capacity fits u8")]) + .enqueue_input(input.clone(), &[]) .expect("within bound"); } - assert_eq!(session.enqueue_input(vec![0]), Err(CoreError::QueueFull)); + assert_eq!(session.enqueue_input(input, &[]), Err(CoreError::QueueFull)); for value in 0..CONTROL_QUEUE_CAPACITY { session @@ -179,7 +191,10 @@ mod tests { session.cancel(); session.cancel(); assert!(session.is_cancelled()); - assert_eq!(session.enqueue_input(vec![0]), Err(CoreError::Cancelled)); + assert_eq!( + session.enqueue_input(vec![0], &[]), + Err(CoreError::Cancelled) + ); assert!(session.pop_media().is_none()); } @@ -217,4 +232,49 @@ mod tests { assert_eq!(transport.pop_incoming(), Some(vec![1, 2])); assert_eq!(transport.pop_incoming(), Some(vec![3])); } + + #[test] + fn input_queue_rejects_malformed_vgi1_at_the_boundary() { + let mut session = SessionCore::new(); + assert_eq!( + session.enqueue_input(vec![0], &[]), + Err(CoreError::Truncated) + ); + } + + #[test] + fn input_queue_honors_negotiated_vgi1_features() { + let mut session = SessionCore::new(); + let absolute = b"VGI1\x06\x08\x00\x01\x00\x01\x00\x02\x00\x02".to_vec(); + assert_eq!( + session.enqueue_input(absolute.clone(), &[]), + Err(CoreError::UnsupportedFeature) + ); + session + .enqueue_input(absolute.clone(), &["input.absolute.v1"]) + .expect("negotiated absolute input"); + assert_eq!(session.dequeue_input(), Some(absolute)); + } + + #[test] + fn input_and_control_queues_drain_in_order() { + let mut session = SessionCore::new(); + let first = b"VGI1\x01\x04\x01\x00\x00\x1e".to_vec(); + let second = b"VGI1\x01\x04\x00\x00\x00\x1e".to_vec(); + session + .enqueue_input(first.clone(), &[]) + .expect("valid input"); + session + .enqueue_input(second.clone(), &[]) + .expect("valid input"); + session.enqueue_control(vec![1]).expect("valid control"); + session.enqueue_control(vec![2]).expect("valid control"); + + assert_eq!(session.dequeue_input(), Some(first)); + assert_eq!(session.dequeue_input(), Some(second)); + assert_eq!(session.dequeue_input(), None); + assert_eq!(session.dequeue_control(), Some(vec![1])); + assert_eq!(session.dequeue_control(), Some(vec![2])); + assert_eq!(session.dequeue_control(), None); + } } diff --git a/core/src/wire.rs b/core/src/wire.rs index 9e978bd..6963fb1 100644 --- a/core/src/wire.rs +++ b/core/src/wire.rs @@ -18,6 +18,42 @@ fn bounded(value: &str, minimum: usize, maximum: usize) -> bool { (minimum..=maximum).contains(&value.len()) } +fn contains_private_key_pem(value: &str) -> bool { + value.lines().any(|line| { + let line = line.trim(); + line.starts_with("-----BEGIN ") && line.ends_with("-----") && line.contains("PRIVATE KEY") + }) +} + +const fn base64url_value(value: u8) -> Option { + match value { + b'A'..=b'Z' => Some(value - b'A'), + b'a'..=b'z' => Some(value - b'a' + 26), + b'0'..=b'9' => Some(value - b'0' + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } +} + +fn raw_base64url_decoded_len(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) { + return None; + } + let remainder_bytes = match bytes.len() % 4 { + 0 => 0, + 2 if base64url_value(*bytes.last()?)?.trailing_zeros() >= 4 => 1, + 3 if base64url_value(*bytes.last()?)?.trailing_zeros() >= 2 => 2, + _ => return None, + }; + bytes + .len() + .checked_div(4)? + .checked_mul(3)? + .checked_add(remainder_bytes) +} + #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] struct Timestamp { year: u16, @@ -359,6 +395,8 @@ impl NativeTunnelCredential { || !bounded(&credential.device_key_id, 1, 128) || !bounded(&credential.certificate_chain_pem, 1, 65_536) || !bounded(&credential.trust_bundle_pem, 1, 65_536) + || contains_private_key_pem(&credential.certificate_chain_pem) + || contains_private_key_pem(&credential.trust_bundle_pem) || timestamp(&credential.expires_at, false).is_none() { return Err(CoreError::InvalidArgument); @@ -409,6 +447,11 @@ impl TunnelAdmissionRequest { || !bounded(&request.grant, 43, 256) || !bounded(&request.client_nonce, 16, 128) || request.device_signature.len() != 86 + || !matches!( + raw_base64url_decoded_len(&request.client_nonce), + Some(12..=96) + ) + || raw_base64url_decoded_len(&request.device_signature) != Some(64) { return Err(CoreError::InvalidArgument); } diff --git a/core/tests/protocol_fixtures.rs b/core/tests/protocol_fixtures.rs index ec1f3df..b645418 100644 --- a/core/tests/protocol_fixtures.rs +++ b/core/tests/protocol_fixtures.rs @@ -1,7 +1,9 @@ use std::path::PathBuf; use std::process::Command; -use versevdi_core::input::{decode_feedback, decode_input, encode_feedback, encode_input}; +use versevdi_core::input::{ + decode_feedback, decode_input, encode_feedback, encode_input, FecStatus, FeedbackEvent, +}; use versevdi_core::media::{MediaFragment, Reassembler}; use versevdi_core::wire::{ CapabilityProfile, ClientSessionAuthority, ConnectionManifest, NativeTunnelCredential, @@ -150,6 +152,23 @@ fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() { assert!(ClientSessionAuthority::decode(provider_authority).is_err()); } +#[test] +fn native_tunnel_credential_rejects_private_key_pem_in_certificate_fields() { + for field in ["certificate_chain_pem", "trust_bundle_pem"] { + let credential = r#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"# + .to_owned() + .replacen( + &format!(r#""{field}":"{}""#, if field == "certificate_chain_pem" { "certificate" } else { "root" }), + &format!(r#""{field}":"-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----""#), + 1, + ); + assert!( + NativeTunnelCredential::decode(credential.as_bytes()).is_err(), + "private key armor accepted in {field}" + ); + } +} + #[test] fn expiry_mismatch_and_capability_escalation_are_rejected() { let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest"); @@ -210,6 +229,24 @@ fn admission_transcript_matches_rc5_literal() { ); } +#[test] +fn admission_rejects_non_raw_base64url_nonce_and_signature() { + for (nonce, signature) in [ + ("!".repeat(16), "A".repeat(86)), + ("A".repeat(17), "A".repeat(86)), + ("A".repeat(16), "!".repeat(86)), + ("A".repeat(16), format!("{}B", "A".repeat(85))), + ] { + let request = format!( + r#"{{"version":"1","session_id":"session","gateway_id":"gateway","audience":"audience","grant":"{}","reconnect_sequence":0,"client_nonce":"{}","device_signature":"{}","capabilities":{{"transport":"quic-tls13","framing":"datagram-v2","media":"encoded","audio":"encoded","source_rate_control":"server","client_decode":["h264-opus"]}}}}"#, + "g".repeat(43), + nonce, + signature, + ); + assert!(TunnelAdmissionRequest::decode(request.as_bytes()).is_err()); + } +} + fn decode_hex(value: &str) -> Vec { value .as_bytes() @@ -371,3 +408,70 @@ fn input_features_and_feedback_booleans_fail_closed() { "length" ); } + +#[test] +fn fec_feedback_enforces_rc5_go_field_invariants_on_decode_and_encode() { + for invalid in [ + "56474631000200150000002a0005000300020000000200080002140001", + "56474631000200150000002a000500030002000a0002000b0002140001", + "56474631000200150000002a000500030002000a000200080003140001", + "56474631000200150000002a000500030002000a000200080002650001", + "56474631000200150000002a000500030002000a000200080002140000", + "56474631000200150000002a000500030002000a000200080002140101", + ] { + assert_eq!( + decode_feedback(&decode_hex(invalid)) + .expect_err("invalid FEC status") + .code(), + "field" + ); + } + + let valid = FecStatus { + frame_index: 42, + highest_received_sequence: 5, + next_contiguous_sequence: 3, + missing_before_highest: 2, + total_data_packets: 10, + total_parity_packets: 2, + received_data_packets: 8, + received_parity_packets: 2, + fec_percentage: 20, + multi_fec_block_index: 0, + multi_fec_block_count: 1, + }; + let invalid = [ + FecStatus { + total_data_packets: 0, + ..valid.clone() + }, + FecStatus { + received_data_packets: 11, + ..valid.clone() + }, + FecStatus { + received_parity_packets: 3, + ..valid.clone() + }, + FecStatus { + fec_percentage: 101, + ..valid.clone() + }, + FecStatus { + multi_fec_block_count: 0, + ..valid.clone() + }, + FecStatus { + multi_fec_block_index: 1, + ..valid + }, + ]; + for status in invalid { + assert_eq!( + encode_feedback(&FeedbackEvent::Fec(status)) + .expect_err("invalid FEC status") + .code(), + "field" + ); + } +}