fix(core): harden wire validation and queues
This commit is contained in:
+22
-2
@@ -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,7 +350,8 @@ 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 {
|
||||
(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),
|
||||
@@ -347,7 +363,10 @@ pub fn decode_feedback(bytes: &[u8]) -> Result<FeedbackEvent> {
|
||||
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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user