fix(core): harden wire validation and queues

This commit is contained in:
sechmachine
2026-08-12 14:25:47 +07:00
parent 48ee082c0b
commit dc2cbdf4d7
4 changed files with 253 additions and 26 deletions
+33 -13
View File
@@ -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<FeedbackEvent> {
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<Vec<u8>> {
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);
+72 -12
View File
@@ -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<u8>) -> Result<()> {
/// Returns a stable VGI1 parse error, `queue_full`, or `cancelled`.
pub fn enqueue_input(&mut self, bytes: Vec<u8>, 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<Vec<u8>> {
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<Vec<u8>> {
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);
}
}
+43
View File
@@ -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<u8> {
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<usize> {
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);
}