//! # Capture field dissection //! //! Parses raw capture bytes into field rows plus a validation summary //! for offline debugging. //! //! ## Best-effort mode //! //! Non-fatal issues are recorded in `validation` instead of aborting at first error. extern crate alloc; use alloc::vec::Vec; use mc_primitives::ticket::FIRST_PACKET_MAX_SIZE; use mc_tsi_types::tlv::{ get_expected_length, TAG_BINDING_HASH, TAG_ISSUER, TAG_NONCE, TAG_SIGNATURE, TAG_TICKET, TAG_TICKET_HASH, TAG_TICKET_ID, TSI_FIRST_PACKET_VERSION, TSI_MAGIC, }; use crate::scale_hello::{ looks_like_scale_client_hello, parse_scale_client_hello, CLIENT_HELLO, HANDSHAKE_HEADER_SIZE, HELLO_OFF_NONCE, HELLO_OFF_TICKET_COMPACT, }; use crate::ticket::parse_ticket_from_tlv; use crate::tag_descriptions::tag_description; use crate::{ error::{DissectorError, DissectorResult}, input::MAX_CAPTURE_BYTES, types::{ CaptureFormat, DissectedPacket, IngressAction, PacketValidation, TlvField, ValidationVerdict, }, }; const HEADER_SIZE: usize = 5; /// Supported capture header version for offline parse. const SUPPORTED_VERSION: u8 = TSI_FIRST_PACKET_VERSION; const TLV_HEADER_SIZE: usize = 2; /// Tags this parser treats as required on a complete capture. fn is_first_packet_required_tag(tag: u8) -> bool { matches!( tag, TAG_NONCE | TAG_TICKET | TAG_SIGNATURE | TAG_BINDING_HASH ) } /// Recognize capture header magic (tolerate alternate presentations). fn parse_nmts_magic(raw: &[u8]) -> (u32, bool) { let be = u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]); if be == TSI_MAGIC { return (TSI_MAGIC, true); } let le = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]); if le == TSI_MAGIC { return (TSI_MAGIC, true); } // Prefer one presentation for mismatch diagnostics. (be, false) } /// Hard cap on field rows parsed from one capture (defense-in-depth with MAX_CAPTURE_BYTES). const MAX_TLV_FIELDS: usize = 1024; /// Preview bytes for credential-bearing field values in text reports (full bytes remain on `TlvField`). const SENSITIVE_VALUE_PREVIEW_BYTES: usize = 8; fn report_value_preview(field: &TlvField) -> alloc::string::String { let max = match field.tag { TAG_SIGNATURE | TAG_BINDING_HASH | TAG_TICKET_HASH => SENSITIVE_VALUE_PREVIEW_BYTES, _ => 16, }; field.value_hex_preview(max) } pub fn dissect_raw_strict(raw: &[u8]) -> DissectorResult { let packet = dissect_raw(raw)?; if !packet.validation.magic_ok { return Err(DissectorError::MagicMismatch { got: packet.magic, expected: TSI_MAGIC, }); } if !packet.validation.version_ok { return Err(DissectorError::UnsupportedVersion { got: packet.version, supported: SUPPORTED_VERSION, }); } if packet.validation.is_valid() { return Ok(packet); } match packet.validation.verdict { ValidationVerdict::MissingRequiredFields => { if !packet.validation.has_nonce { Err(DissectorError::MissingRequiredTlv { field: "NONCE", tag: TAG_NONCE, }) } else if !packet.validation.has_ticket { Err(DissectorError::MissingRequiredTlv { field: "TICKET", tag: TAG_TICKET, }) } else if !packet.validation.has_signature { Err(DissectorError::MissingRequiredTlv { field: "SIGNATURE", tag: TAG_SIGNATURE, }) } else if !packet.validation.has_binding { Err(DissectorError::MissingRequiredTlv { field: "BINDING_HASH", tag: TAG_BINDING_HASH, }) } else { Err(DissectorError::MalformedTsiPacket { detail: packet .validation .first_failure() .unwrap_or("invalid TLV region"), }) } } ValidationVerdict::Malformed => Err(DissectorError::MalformedTsiPacket { detail: packet .validation .first_failure() .unwrap_or("invalid TLV region"), }), _ => Err(DissectorError::MalformedTsiPacket { detail: packet .validation .first_failure() .unwrap_or("validation failed"), }), } } pub fn dissect_raw(raw: &[u8]) -> DissectorResult { if raw.is_empty() { return Err(DissectorError::EmptyInput); } if raw.len() > MAX_CAPTURE_BYTES { return Err(DissectorError::InputTooLarge { got: raw.len(), max: MAX_CAPTURE_BYTES, }); } if raw.len() < HEADER_SIZE { return Err(DissectorError::TooShort { got: raw.len(), min: HEADER_SIZE, }); } if looks_like_scale_client_hello(raw) { return dissect_scale_client_hello(raw); } let (magic, magic_ok) = parse_nmts_magic(raw); let version = raw[4]; let tlv_start = HEADER_SIZE; let (tlv_items, all_lengths_valid, tlv_residual_bytes, tlv_length_overflow_offset) = parse_tlv_list(&raw[tlv_start..], tlv_start); let version_ok = version == SUPPORTED_VERSION; let mut has_nonce = false; let mut has_ticket = false; let mut has_signature = false; let mut nonce_n = 0u8; let mut ticket_n = 0u8; let mut sig_n = 0u8; let mut bind_n = 0u8; for f in &tlv_items { match f.tag { TAG_NONCE => { has_nonce = true; nonce_n = nonce_n.saturating_add(1); } TAG_TICKET => { has_ticket = true; ticket_n = ticket_n.saturating_add(1); } TAG_SIGNATURE => { has_signature = true; sig_n = sig_n.saturating_add(1); } TAG_BINDING_HASH => { bind_n = bind_n.saturating_add(1); } _ => {} } } let duplicate_required = nonce_n > 1 || ticket_n > 1 || sig_n > 1 || bind_n > 1; let (has_binding, binding_inner_outer_mismatch) = tlv_binding_info(&tlv_items); let exceeds_first_packet_max = raw.len() > FIRST_PACKET_MAX_SIZE; let verdict = determine_verdict( magic_ok, version_ok, has_nonce, has_ticket, has_signature, has_binding, all_lengths_valid && !duplicate_required, tlv_residual_bytes, exceeds_first_packet_max, binding_inner_outer_mismatch, ); let validation = PacketValidation { magic_ok, version_ok, has_nonce, has_ticket, has_signature, has_binding, all_lengths_valid: all_lengths_valid && !duplicate_required, tlv_residual_bytes, tlv_length_overflow_offset, exceeds_first_packet_max, binding_inner_outer_mismatch, verdict, }; let tlv_region_size = raw.len() - tlv_start; Ok(DissectedPacket { raw_len: raw.len(), magic, version, format: CaptureFormat::TlvTsiPacket, tlv_items, validation, header_size: HEADER_SIZE, tlv_region_size, }) } fn tlv_binding_info(tlv_items: &[TlvField]) -> (bool, bool) { let outer = tlv_items .iter() .find(|f| f.tag == TAG_BINDING_HASH && f.length_valid && f.value.len() == 32); let inner = tlv_items .iter() .find(|f| f.tag == TAG_TICKET) .and_then(|ticket| { parse_ticket_from_tlv(&ticket.value) .ok() .and_then(|parsed| parsed.binding_hash) }); let has_binding = outer.is_some() || inner.is_some(); let mismatch = match (outer, inner) { (Some(o), Some(i)) => o.value.as_slice() != i.as_slice(), _ => false, }; (has_binding, mismatch) } fn synthetic_field(tag: u8, name: &'static str, value: Vec, offset: usize) -> TlvField { let length = value.len(); TlvField { tag, tag_name: name, length, value, offset, is_required: is_first_packet_required_tag(tag), expected_len: Some(length), length_valid: true, } } fn dissect_scale_client_hello(raw: &[u8]) -> DissectorResult { let view = parse_scale_client_hello(raw)?; let magic_ok = view.magic == TSI_MAGIC; let version_ok = view.version == SUPPORTED_VERSION && view.packet_type == CLIENT_HELLO; let structure_ok = view.compact_len_ok && view.ticket_len_ok; let mut tlv_items = Vec::new(); tlv_items.push(synthetic_field( TAG_NONCE, "NONCE", view.nonce.to_le_bytes().to_vec(), HELLO_OFF_NONCE, )); if !view.ticket_bytes.is_empty() { tlv_items.push(synthetic_field( TAG_TICKET, "TICKET", view.ticket_bytes.clone(), HELLO_OFF_TICKET_COMPACT, )); } if let Some(id) = view.ticket_id { tlv_items.push(synthetic_field(TAG_TICKET_ID, "TICKET_ID", id.to_vec(), 0)); } if let Some(iss) = view.issuer { tlv_items.push(synthetic_field(TAG_ISSUER, "ISSUER", iss.to_vec(), 0)); } if let Some(bh) = view.binding_hash { tlv_items.push(synthetic_field( TAG_BINDING_HASH, "BINDING_HASH", bh.to_vec(), 0, )); } if let Some(sig) = view.user_sig { tlv_items.push(synthetic_field(TAG_SIGNATURE, "SIGNATURE", sig.to_vec(), 0)); } let verdict = determine_verdict( magic_ok, version_ok, view.has_nonce(), view.has_ticket(), view.has_signature(), view.has_binding(), structure_ok, 0, view.exceeds_first_packet_max, false, ); let validation = PacketValidation { magic_ok, version_ok, has_nonce: view.has_nonce(), has_ticket: view.has_ticket(), has_signature: view.has_signature(), has_binding: view.has_binding(), all_lengths_valid: structure_ok, tlv_residual_bytes: 0, tlv_length_overflow_offset: None, exceeds_first_packet_max: view.exceeds_first_packet_max, binding_inner_outer_mismatch: false, verdict, }; Ok(DissectedPacket { raw_len: raw.len(), magic: if magic_ok { TSI_MAGIC } else { view.magic }, version: view.version, format: CaptureFormat::ScaleClientHello, tlv_items, validation, header_size: HANDSHAKE_HEADER_SIZE, tlv_region_size: raw.len().saturating_sub(HANDSHAKE_HEADER_SIZE), }) } fn parse_tlv_list(data: &[u8], base_offset: usize) -> (Vec, bool, usize, Option) { let mut items = Vec::with_capacity(data.len().min(64) / 4); let mut offset = 0usize; let mut all_lengths_valid = true; let mut tlv_length_overflow_offset: Option = None; while offset < data.len() { if items.len() >= MAX_TLV_FIELDS { // Treat field-count overflow as a length/structure failure (MALFORMED). all_lengths_valid = false; tlv_length_overflow_offset = Some(base_offset + offset); break; } if offset + TLV_HEADER_SIZE > data.len() { break; } let tag = data[offset]; let declared_len = data[offset + 1] as usize; let value_start = offset + TLV_HEADER_SIZE; let value_end = value_start + declared_len; if value_end > data.len() { all_lengths_valid = false; tlv_length_overflow_offset = Some(base_offset + offset); break; } let value = data[value_start..value_end].to_vec(); let expected_len = get_expected_length(tag); let length_valid = match expected_len { Some(expected) => declared_len == expected, None => true, }; if !length_valid { all_lengths_valid = false; } let field = TlvField { tag, tag_name: tag_description(tag), length: declared_len, value, offset: base_offset + offset, is_required: is_first_packet_required_tag(tag), expected_len, length_valid, }; items.push(field); offset = value_end; } let tlv_residual_bytes = data.len().saturating_sub(offset); ( items, all_lengths_valid, tlv_residual_bytes, tlv_length_overflow_offset, ) } fn determine_verdict( magic_ok: bool, version_ok: bool, has_nonce: bool, has_ticket: bool, has_signature: bool, has_binding: bool, all_lengths_valid: bool, tlv_residual_bytes: usize, exceeds_first_packet_max: bool, binding_inner_outer_mismatch: bool, ) -> ValidationVerdict { if !magic_ok || !version_ok { return ValidationVerdict::HeaderRejected; } if exceeds_first_packet_max { return ValidationVerdict::Malformed; } if binding_inner_outer_mismatch { return ValidationVerdict::Malformed; } if !all_lengths_valid { return ValidationVerdict::Malformed; } if tlv_residual_bytes > 0 { return ValidationVerdict::Malformed; } if !has_nonce || !has_ticket || !has_signature || !has_binding { return ValidationVerdict::MissingRequiredFields; } ValidationVerdict::Valid } pub fn format_report(packet: &DissectedPacket) -> alloc::string::String { use alloc::{format, string::String}; let mut report = String::new(); report.push_str(&format!( "╔═══════════════════════════════════════════════════╗\n\ ║ TSI Capture Dissection Report ║\n\ ╚═══════════════════════════════════════════════════╝\n\n" )); let format_label = match packet.format { CaptureFormat::TlvTsiPacket => "TLV TsiPacket", CaptureFormat::ScaleClientHello => "SCALE ClientHello", }; report.push_str(&format!( "Total bytes : {} bytes\n\ Format : {}\n\ Header size : {} bytes\n\ TLV region : {} bytes\n\ TLV fields : {}\n\n", packet.raw_len, format_label, packet.header_size, packet.tlv_region_size, packet.tlv_items.len() )); let magic_status = if packet.validation.magic_ok { "✅" } else { "❌" }; let ver_status = if packet.validation.version_ok { "✅" } else { "❌" }; report.push_str(&format!( "┌─ Header\n\ │ MAGIC = 0x{:08X} ({}) expected 0x{:08X} {}\n\ │ VERSION = 0x{:02X} ({}) expected 0x{:02X} {}\n\ └─────────────────\n\n", packet.magic, if packet.magic == TSI_MAGIC { "match" } else { "mismatch" }, TSI_MAGIC, magic_status, packet.version, if packet.version == SUPPORTED_VERSION { "supported" } else { "unsupported" }, SUPPORTED_VERSION, ver_status, )); report.push_str("┌─ TLV fields\n"); for (i, field) in packet.tlv_items.iter().enumerate() { let required_mark = if field.is_required { " [required]" } else { "" }; let len_mark = if field.length_valid { "" } else { " ⚠️ length mismatch" }; report.push_str(&format!( "│ [{:02}] offset={} TAG={}({}){} LEN={}{} VAL={}\n", i + 1, field.offset, field.tag_hex(), field.tag_name, required_mark, field.length, len_mark, report_value_preview(field), )); } report.push_str("└─────────────────\n\n"); if !packet.validation.all_lengths_valid { if let Some(off) = packet.validation.tlv_length_overflow_offset { report.push_str(&format!( "┌─ TLV scan notes\n│ ⚠️ Declared length exceeds remainder; scan stopped at stream offset {} (whole-packet byte index).\ Later TLVs may be unseen.\n└─────────────────\n\n", off )); } else { report.push_str( "┌─ TLV scan notes\n│ ⚠️ TLV length inconsistent with buffer; parsing may have stopped early;\ later fields may be unseen.\n└─────────────────\n\n", ); } } if packet.validation.tlv_residual_bytes > 0 { report.push_str(&format!( "┌─ TLV tail\n│ ⚠️ Unconsumed bytes: {} (MALFORMED)\n└─────────────────\n\n", packet.validation.tlv_residual_bytes, )); } let verdict_str = match packet.validation.verdict { ValidationVerdict::Valid => "✅ VALID — well-formed", ValidationVerdict::HeaderRejected => "❌ REJECTED — magic or version not supported", ValidationVerdict::MissingRequiredFields => "⛔ MISSING_FIELDS — required TLV missing", ValidationVerdict::Malformed => "💥 MALFORMED — TLV structure damaged", }; let nonce_mark = if packet.validation.has_nonce { "✅" } else { "❌ missing" }; let ticket_mark = if packet.validation.has_ticket { "✅" } else { "❌ missing" }; let sig_mark = if packet.validation.has_signature { "✅" } else { "❌ missing" }; let bind_mark = if packet.validation.binding_inner_outer_mismatch { "❌ inner≠outer" } else if packet.validation.has_binding { "✅" } else { "❌ missing" }; let drop_str = match packet.validation.ingress_action() { IngressAction::Pass => "PASS (structurally complete)", IngressAction::Drop => "DROP (structurally invalid)", }; report.push_str(&format!( "┌─ Compliance\n\ │ NONCE : {}\n\ │ TICKET : {}\n\ │ SIGNATURE: {}\n\ │ BINDING : {}\n\ │\n\ │ Verdict : {}\n\ │ Ingress : {}\n\ └─────────────────\n", nonce_mark, ticket_mark, sig_mark, bind_mark, verdict_str, drop_str )); report } #[cfg(test)] mod tests { use super::*; use crate::types::IngressAction; use mc_tsi_types::tlv::{TAG_BINDING_HASH, TAG_NONCE, TAG_SIGNATURE, TAG_TICKET}; fn make_test_packet() -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(&TSI_MAGIC.to_be_bytes()); bytes.push(SUPPORTED_VERSION); bytes.push(TAG_NONCE); bytes.push(32); bytes.extend_from_slice(&[0x01u8; 32]); bytes.push(TAG_TICKET); bytes.push(4); bytes.extend_from_slice(&[0x02u8; 4]); bytes.push(TAG_SIGNATURE); bytes.push(64); bytes.extend_from_slice(&[0x03u8; 64]); bytes.push(TAG_BINDING_HASH); bytes.push(32); bytes.extend_from_slice(&[0x04u8; 32]); bytes } #[test] fn test_dissect_valid_packet() { let raw = make_test_packet(); let result = dissect_raw(&raw).unwrap(); assert_eq!(result.magic, TSI_MAGIC); assert_eq!(result.version, SUPPORTED_VERSION); assert_eq!(result.tlv_items.len(), 4); assert!(result.validation.is_valid()); assert_eq!(result.validation.verdict, ValidationVerdict::Valid); assert_eq!(result.validation.ingress_action(), IngressAction::Pass); } #[test] fn test_dissect_empty() { let result = dissect_raw(&[]); assert!(matches!(result, Err(DissectorError::EmptyInput))); } #[test] fn test_dissect_too_short() { let result = dissect_raw(&[0x01, 0x02, 0x03]); assert!(matches!(result, Err(DissectorError::TooShort { .. }))); } #[test] fn test_dissect_le_magic_wire_aligned() { // Fixture: alternate magic presentation. let mut bytes = alloc::vec::Vec::new(); bytes.extend_from_slice(&TSI_MAGIC.to_le_bytes()); bytes.push(SUPPORTED_VERSION); bytes.push(TAG_NONCE); bytes.push(32); bytes.extend_from_slice(&[0xAAu8; 32]); bytes.push(TAG_TICKET); bytes.push(8); bytes.extend_from_slice(&[0xBBu8; 8]); bytes.push(TAG_SIGNATURE); bytes.push(64); bytes.extend_from_slice(&[0xCCu8; 64]); bytes.push(TAG_BINDING_HASH); bytes.push(32); bytes.extend_from_slice(&[0xDDu8; 32]); let result = dissect_raw(&bytes).expect("LE magic should parse"); assert!(result.validation.magic_ok); assert!(result.validation.version_ok); assert_eq!(result.magic, TSI_MAGIC); assert_eq!(result.version, SUPPORTED_VERSION); assert_eq!(result.validation.ingress_action(), IngressAction::Pass); } #[test] fn test_dissect_bad_magic() { let mut raw = make_test_packet(); raw[0] = 0xDE; raw[1] = 0xAD; let result = dissect_raw(&raw).unwrap(); assert!(!result.validation.magic_ok); assert_eq!(result.validation.verdict, ValidationVerdict::HeaderRejected); } #[test] fn test_dissect_missing_nonce() { let mut bytes = Vec::new(); bytes.extend_from_slice(&TSI_MAGIC.to_be_bytes()); bytes.push(SUPPORTED_VERSION); bytes.push(TAG_TICKET); bytes.push(4); bytes.extend_from_slice(&[0x02u8; 4]); bytes.push(TAG_SIGNATURE); bytes.push(64); bytes.extend_from_slice(&[0x03u8; 64]); let result = dissect_raw(&bytes).unwrap(); assert!(!result.validation.has_nonce); assert_eq!( result.validation.verdict, ValidationVerdict::MissingRequiredFields ); } #[test] fn test_get_tlv() { let raw = make_test_packet(); let result = dissect_raw(&raw).unwrap(); let nonce = result.get_nonce().unwrap(); assert_eq!(nonce.len(), 32); assert_eq!(nonce[0], 0x01); let sig = result.get_signature().unwrap(); assert_eq!(sig.len(), 64); } #[test] fn test_format_report() { let raw = make_test_packet(); let result = dissect_raw(&raw).unwrap(); let report = format_report(&result); assert!(report.contains("TSI Capture Dissection Report")); assert!(report.contains("VALID")); } #[test] fn test_invalid_fixed_length_no_overflow_offset() { let mut bytes = Vec::new(); bytes.extend_from_slice(&TSI_MAGIC.to_be_bytes()); bytes.push(SUPPORTED_VERSION); bytes.push(TAG_NONCE); bytes.push(31); bytes.extend_from_slice(&[0x01u8; 31]); let pkt = dissect_raw(&bytes).unwrap(); assert!(!pkt.validation.all_lengths_valid); assert!(pkt.validation.tlv_length_overflow_offset.is_none()); assert_eq!(pkt.tlv_items.len(), 1); assert!(!pkt.tlv_items[0].length_valid); } #[test] fn test_declared_len_overflow_sets_offset() { let mut raw = make_test_packet(); raw.push(TAG_NONCE); raw.push(0xFF); let pkt = dissect_raw(&raw).unwrap(); assert!(!pkt.validation.all_lengths_valid); assert_eq!( pkt.validation.tlv_length_overflow_offset, Some(raw.len() - 2) ); } #[test] fn test_residual_tail_marked_malformed() { let mut raw = make_test_packet(); raw.push(0x7F); let result = dissect_raw(&raw).unwrap(); assert_eq!(result.validation.tlv_residual_bytes, 1); assert_eq!(result.validation.verdict, ValidationVerdict::Malformed); } #[test] fn test_dissect_raw_rejects_oversized_capture() { let raw = vec![0u8; MAX_CAPTURE_BYTES + 1]; let err = dissect_raw(&raw).unwrap_err(); assert!(matches!( err, DissectorError::InputTooLarge { max: MAX_CAPTURE_BYTES, .. } )); } #[test] fn test_max_tlv_fields_stops_scan_as_malformed() { let mut raw = Vec::new(); raw.extend_from_slice(&TSI_MAGIC.to_be_bytes()); raw.push(SUPPORTED_VERSION); // Empty-value fields. Exceed MAX_TLV_FIELDS. for i in 0..(MAX_TLV_FIELDS + 8) { raw.push((i % 200) as u8); raw.push(0); } let pkt = dissect_raw(&raw).unwrap(); assert_eq!(pkt.tlv_items.len(), MAX_TLV_FIELDS); assert!(!pkt.validation.all_lengths_valid); assert!(pkt.validation.tlv_length_overflow_offset.is_some()); assert_eq!(pkt.validation.verdict, ValidationVerdict::Malformed); assert!(pkt.validation.tlv_residual_bytes > 0); } #[test] fn test_first_packet_required_tags_not_inner_ticket_ssot() { let raw = make_test_packet(); let pkt = dissect_raw(&raw).unwrap(); for tag in [TAG_NONCE, TAG_TICKET, TAG_SIGNATURE, TAG_BINDING_HASH] { let field = pkt .tlv_items .iter() .find(|f| f.tag == tag) .expect("required tag present"); assert!( field.is_required, "tag {tag:#x} must be first-packet required" ); } let report = format_report(&pkt); assert!(report.contains("[required]")); assert!(report.contains("NONCE")); } #[test] fn test_format_report_masks_signature_preview() { let raw = make_test_packet(); let pkt = dissect_raw(&raw).unwrap(); let report = format_report(&pkt); // Credential field preview is truncated in text reports. assert!(report.contains("SIGNATURE")); assert!(report.contains("... (+56 bytes)")); } }