From da5950e872c9bf9bcae1a3b5968a4d71aaa02bb6 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Fri, 20 Feb 2026 23:43:15 -0500 Subject: [PATCH] TX and other UI/UX improvements --- README.md | 11 ++-- src/app.rs | 164 +++++++++++++++++++++++++++++++++++------------ src/main.rs | 8 +++ src/ui/layout.rs | 33 ++++++---- src/vuln_db.rs | 83 +++++++++++++++++++----- 5 files changed, 225 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index f0e8467..fd0acea 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ A terminal-based RF signal analysis tool for capturing, decoding, and retransmit - **Research mode** — config option to show unknown (unidentified) signals in addition to successfully decoded ones - **INI configuration** — `~/.config/KAT/config.ini` (auto-created with comments on first run): export path, max captures, research_mode, radio defaults, export format - **Embedded keystore** — manufacturer keys (Kia, VAG, etc.) built in for decoding -- **VIM-style command line** — `:freq`, `:lock`, `:unlock`, `:save`, `:load`, `:delete`, `:q` / `:quit`, and more +- **VIM-style command line** — `:freq`, `:lock`, `:unlock`, `:replay`, `:save`, `:load`, `:delete`, `:q` / `:quit`, and more - **Interactive TUI** — captures list with detail panel (protocol, freq, mod, RF, encryption), signal action menu, radio settings, fob export form; header shows device (HackRF / RTL-SDR (RX only) / No device) and status (DISCONNECTED in red when no device) ## Requirements @@ -172,10 +172,11 @@ Transmit commands (`:lock`, `:unlock`, `:trunk`, `:panic`) require HackRF; with | Command | Description | |---|---| | `:freq ` | Set receive frequency (e.g. `:freq 433.92`) | -| `:lock ` | Transmit lock signal for capture ID | -| `:unlock ` | Transmit unlock signal for capture ID | -| `:trunk ` | Transmit trunk release signal | -| `:panic ` | Transmit panic alarm signal | +| `:lock ` | Transmit lock signal (ID: single, comma list, or range; e.g. `1`, `1, 3, 5`, `1-5`) | +| `:unlock ` | Transmit unlock signal (same ID formats) | +| `:trunk ` | Transmit trunk release (same ID formats) | +| `:panic ` | Transmit panic alarm (same ID formats) | +| `:replay ` | Replay raw capture(s) by ID in order (same ID formats; HackRF only) | | `:save ` | Save capture to file | | `:delete ` | Delete capture from list | | `:load ` | Import capture from `.fob` or `.sub` file | diff --git a/src/app.rs b/src/app.rs index 116d8df..5ecf2c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -367,6 +367,12 @@ pub struct App { /// Which capture is being edited (when in CaptureMeta* modes) pub capture_meta_capture_id: Option, + // -- Pending transmit (so UI can draw TX state before blocking) -- + /// Queue of (signal, frequency) to transmit; main loop draws then runs one at a time. + pending_transmit_queue: Vec<(Vec, u32)>, + /// State to restore when queue becomes empty (set when first item is queued). + pending_transmit_restore: Option, + // -- :load file browser -- /// Current directory in the load file browser pub load_browser_cwd: PathBuf, @@ -485,6 +491,8 @@ impl App { capture_meta_model: String::new(), capture_meta_region: String::new(), capture_meta_capture_id: None, + pending_transmit_queue: Vec::new(), + pending_transmit_restore: None, load_browser_cwd: PathBuf::new(), load_browser_selected: 0, load_browser_scroll: 0, @@ -578,6 +586,34 @@ impl App { Ok(()) } + /// Parse an ID spec into a list of capture IDs in order. + /// Supports: single "1", comma-separated "1, 3, 5", range "1-5", and mixed "1, 3-5, 7". + fn parse_id_spec(s: &str) -> Result, String> { + let mut ids = Vec::new(); + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some((low, high)) = part.split_once('-') { + let low = low.trim().parse::().map_err(|_| "Invalid ID in range".to_string())?; + let high = high.trim().parse::().map_err(|_| "Invalid ID in range".to_string())?; + if low <= high { + ids.extend(low..=high); + } else { + ids.extend((high..=low).rev()); + } + } else { + let id = part.parse::().map_err(|_| "Invalid capture ID".to_string())?; + ids.push(id); + } + } + if ids.is_empty() { + return Err("No valid IDs".to_string()); + } + Ok(ids) + } + /// Execute a command pub fn execute_command(&mut self, command: &str) -> Result<()> { let parts: Vec<&str> = command.trim().split_whitespace().collect(); @@ -607,10 +643,10 @@ impl App { } } } - "unlock" => self.transmit_command(parts.get(1), ButtonCommand::Unlock)?, - "lock" => self.transmit_command(parts.get(1), ButtonCommand::Lock)?, - "trunk" => self.transmit_command(parts.get(1), ButtonCommand::Trunk)?, - "panic" => self.transmit_command(parts.get(1), ButtonCommand::Panic)?, + "unlock" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Unlock)?, + "lock" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Lock)?, + "trunk" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Trunk)?, + "panic" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Panic)?, "license" | "licence" => { self.input_mode = InputMode::License; self.overlay_scroll = 0; @@ -633,6 +669,24 @@ impl App { self.delete_capture(parts[1])?; } } + "replay" => { + let id_spec = parts.get(1).map(|_| parts[1..].join(" ")); + let id_spec = match id_spec.as_deref() { + Some(s) if !s.is_empty() => s, + _ => { + self.last_error = Some("Usage: :replay (e.g. 1, 1,3,5, 1-5)".to_string()); + return Ok(()); + } + }; + match Self::parse_id_spec(id_spec) { + Ok(ids) => { + for id in ids { + self.replay_capture(id)?; + } + } + Err(e) => self.last_error = Some(e), + } + } "lna" => { if parts.len() < 2 { self.last_error = Some("Usage: :lna <0-40>".to_string()); @@ -760,8 +814,30 @@ impl App { Ok(()) } - /// Transmit a command for a capture - fn transmit_command(&mut self, id_str: Option<&&str>, command: ButtonCommand) -> Result<()> { + /// Transmit a command for one or more captures. ID spec: "1", "1, 3, 5", "1-5", or mixed. + fn transmit_command(&mut self, id_spec: Option, command: ButtonCommand) -> Result<()> { + let id_spec = match id_spec.as_deref() { + Some(s) if !s.is_empty() => s, + _ => { + self.last_error = Some(format!("Usage: :{:?} (e.g. 1, 1,3,5, 1-5)", command).to_lowercase()); + return Ok(()); + } + }; + let ids = match Self::parse_id_spec(id_spec) { + Ok(ids) => ids, + Err(e) => { + self.last_error = Some(e); + return Ok(()); + } + }; + for id in ids { + self.transmit_one_command(id, command)?; + } + Ok(()) + } + + /// Transmit a command for a single capture by ID. + fn transmit_one_command(&mut self, id: u32, command: ButtonCommand) -> Result<()> { use crate::protocols::DecodedSignal; if let Some(ref radio) = self.radio { @@ -774,22 +850,6 @@ impl App { return Ok(()); } - let id_str = match id_str { - Some(s) => s, - None => { - self.last_error = Some(format!("Usage: :{:?} ", command).to_lowercase()); - return Ok(()); - } - }; - - let id: u32 = match id_str.parse() { - Ok(i) => i, - Err(_) => { - self.last_error = Some("Invalid capture ID".to_string()); - return Ok(()); - } - }; - let capture = match self.captures.iter().find(|c| c.id == id) { Some(c) => c.clone(), None => { @@ -840,11 +900,12 @@ impl App { } }; - if let Some(ref mut radio) = self.radio { - radio.transmit(&signal, capture.frequency)?; - self.status_message = Some(format!("Transmitted {:?} for capture {}", command, id)); + self.pending_transmit_queue.push((signal, capture.frequency)); + if self.pending_transmit_restore.is_none() { + self.pending_transmit_restore = Some(self.radio_state); + self.radio_state = RadioState::Transmitting; } - + self.status_message = Some(format!("Transmitted {:?} for capture {}", command, id)); Ok(()) } @@ -878,12 +939,14 @@ impl App { .iter() .map(|p| LevelDuration::new(p.level, p.duration_us)) .collect(); + let pair_count = signal.len(); - if let Some(ref mut radio) = self.radio { - radio.transmit(&signal, capture.frequency)?; - self.status_message = Some(format!("Replayed capture {} ({} pairs)", id, signal.len())); + self.pending_transmit_queue.push((signal, capture.frequency)); + if self.pending_transmit_restore.is_none() { + self.pending_transmit_restore = Some(self.radio_state); + self.radio_state = RadioState::Transmitting; } - + self.status_message = Some(format!("Replayed capture {} ({} pairs)", id, pair_count)); Ok(()) } @@ -949,11 +1012,34 @@ impl App { } }; - if let Some(ref mut radio) = self.radio { - radio.transmit(&signal, capture.frequency)?; - self.status_message = Some(format!("Sent next code for capture {} (button {})", id, button)); + self.pending_transmit_queue.push((signal, capture.frequency)); + if self.pending_transmit_restore.is_none() { + self.pending_transmit_restore = Some(self.radio_state); + self.radio_state = RadioState::Transmitting; } + self.status_message = Some(format!("Sent next code for capture {} (button {})", id, button)); + Ok(()) + } + /// True if there are queued transmits (UI should draw then call run_one_pending_transmit). + pub fn has_pending_transmit(&self) -> bool { + !self.pending_transmit_queue.is_empty() + } + + /// Run one queued transmit; restores radio_state when queue is empty. Call after drawing. + pub fn run_one_pending_transmit(&mut self) -> Result<()> { + let (signal, frequency) = match self.pending_transmit_queue.pop() { + Some(p) => p, + None => return Ok(()), + }; + if let Some(ref mut radio) = self.radio { + radio.transmit(&signal, frequency)?; + } + if self.pending_transmit_queue.is_empty() { + if let Some(prev) = self.pending_transmit_restore.take() { + self.radio_state = prev; + } + } Ok(()) } @@ -1179,20 +1265,16 @@ impl App { self.transmit_next_code(capture_id)?; } SignalAction::Lock => { - let id_str = capture_id.to_string(); - self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Lock)?; + self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Lock)?; } SignalAction::Unlock => { - let id_str = capture_id.to_string(); - self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Unlock)?; + self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Unlock)?; } SignalAction::Trunk => { - let id_str = capture_id.to_string(); - self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Trunk)?; + self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Trunk)?; } SignalAction::Panic => { - let id_str = capture_id.to_string(); - self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Panic)?; + self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Panic)?; } SignalAction::ExportFob => { self.export_fob(capture_id)?; diff --git a/src/main.rs b/src/main.rs index 3a988ae..9cb0c42 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,6 +176,10 @@ fn run_app(terminal: &mut Terminal, app: &mut A if app.input_mode == InputMode::Command { app.input_mode = InputMode::Normal; } + while app.has_pending_transmit() { + terminal.draw(|f| draw_ui(f, app))?; + app.run_one_pending_transmit()?; + } } KeyCode::Char(c) => { app.command_input.push(c); @@ -209,6 +213,10 @@ fn run_app(terminal: &mut Terminal, app: &mut A if app.input_mode == InputMode::SignalMenu { app.input_mode = InputMode::Normal; } + while app.has_pending_transmit() { + terminal.draw(|f| draw_ui(f, app))?; + app.run_one_pending_transmit()?; + } } KeyCode::Esc => { app.input_mode = InputMode::Normal; diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 70e41fd..620ee9c 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -138,11 +138,19 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { } } -/// Render the RSSI bar on the right (vertical bar, bottom = strong). +/// Render the RSSI bar on the right (vertical bar, bottom = strong). Shows " TX " in red when transmitting. fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) { + let is_tx = app.radio_state == RadioState::Transmitting; + let (title, filled_style, empty_style) = if is_tx { + (" TX ", Style::default().fg(Color::Red), Style::default().fg(Color::DarkGray)) + } else { + (" RX ", Style::default().fg(Color::Green), Style::default().fg(Color::DarkGray)) + }; + let block = Block::default() .borders(Borders::ALL) - .title(" RX "); + .border_style(if is_tx { Style::default().fg(Color::Red) } else { Style::default() }) + .title(title); let inner = block.inner(area); frame.render_widget(block, area); @@ -150,23 +158,24 @@ fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) { return; } - // Normalize RSSI (0..~1) to fill ratio; scale so ~0.5 magnitude ≈ full bar - let fill_ratio = (app.rssi / 0.6).min(1.0); + // When TX: show full red bar. When RX: scale RSSI to fill ratio + let (fill_ratio, style) = if is_tx { + (1.0_f32, filled_style) + } else { + let fill_ratio = (app.rssi / 0.6).min(1.0); + (fill_ratio, filled_style) + }; let filled_rows = (inner.height as f32 * fill_ratio).round() as u16; - let filled_style = Style::default().fg(Color::Green); - let empty_style = Style::default().fg(Color::DarkGray); - let mut lines = Vec::with_capacity(inner.height as usize); for r in 0..inner.height { let fill = r >= inner.height.saturating_sub(filled_rows); - let (style, ch) = if fill { - (filled_style, "█") + let (s, line_style) = if fill { + ("█".repeat(inner.width as usize), style) } else { - (empty_style, " ") + (" ".repeat(inner.width as usize), empty_style) }; - let s = ch.repeat(inner.width as usize); - lines.push(Line::from(Span::styled(s, style))); + lines.push(Line::from(Span::styled(s, line_style))); } let paragraph = Paragraph::new(Text::from(lines)); frame.render_widget(paragraph, inner); diff --git a/src/vuln_db.rs b/src/vuln_db.rs index cbf5fb8..ebe2e6c 100644 --- a/src/vuln_db.rs +++ b/src/vuln_db.rs @@ -1,13 +1,19 @@ -//! Vulnerability database: CVE entries matched by Year, Make, Model, Region. +//! Vulnerability database: CVE entries matched by year range, makes, models, region. //! Used for "Vuln Found" column and the Vulnerability detail panel. -/// One CVE entry. Year/Make/Model/Region use "ALL" to match any value. +/// One CVE entry. Year range is inclusive; "ALL" for start/end means no bound. +/// Makes and models are arrays; any match counts. Use ["ALL"] to match any make/model. #[derive(Debug, Clone)] pub struct VulnEntry { pub cve: &'static str, - pub year: &'static str, - pub make: &'static str, - pub model: &'static str, + /// Inclusive start year (e.g. "2018"). "ALL" = no lower bound. + pub year_start: &'static str, + /// Inclusive end year (e.g. "2021"). "ALL" = no upper bound. + pub year_end: &'static str, + /// Makes that are affected (e.g. ["Renault"], ["Honda", "Acura"]). ["ALL"] = any make. + pub makes: &'static [&'static str], + /// Models that are affected (e.g. ["ZOE"], ["Civic", "Accord"]). ["ALL"] = any model. + pub models: &'static [&'static str], pub region: &'static str, pub description: &'static str, } @@ -15,24 +21,28 @@ pub struct VulnEntry { pub const VULN_DB: [VulnEntry; 2] = [ VulnEntry { cve: "CVE-2022-38766", - year: "2021", - make: "Renault", - model: "ALL", + year_start: "2020", + year_end: "2022", + makes: &["Renault"], + models: &["ZOE"], region: "ALL", description: "The remote keyless system on Renault ZOE 2021 vehicles sends 433.92 MHz RF signals from the same Rolling Codes set for each door-open request, which allows for a replay attack.", }, VulnEntry { cve: "CVE-2022-27254", - year: "2018", - make: "Honda", - model: "Civic", + year_start: "2016", + year_end: "2019", + makes: &["Honda"], + models: &["Civic"], region: "ALL", description: "The remote keyless system on Honda Civic 2018 vehicles sends the same RF signal for each door-open request, which allows for a replay attack, a related issue to CVE-2019-20626.", }, ]; -/// Match a capture's year/make/model/region against the DB. "ALL" in an entry matches any value. -/// Empty/None from capture is treated as empty string (so "ALL" matches it). +/// Match a capture's year/make/model/region against the DB. +/// Year: parsed as number; must be in [year_start, year_end] when entry has bounds. +/// Make/Model: capture value must match one of the entry's list, or entry list contains "ALL". +/// Region: "ALL" in entry matches any; otherwise case-insensitive match. pub fn match_vulns( year: Option<&str>, make: Option<&str>, @@ -44,17 +54,58 @@ pub fn match_vulns( let mod_ = model.unwrap_or(""); let r = region.unwrap_or(""); + let year_num: Option = y.trim().parse().ok(); + VULN_DB .iter() .filter(|e| { - (e.year == "ALL" || eq_ignore_case(e.year, y)) - && (e.make == "ALL" || eq_ignore_case(e.make, m)) - && (e.model == "ALL" || eq_ignore_case(e.model, mod_)) + year_in_range(e.year_start, e.year_end, year_num) + && list_matches(e.makes, m) + && list_matches(e.models, mod_) && (e.region == "ALL" || eq_ignore_case(e.region, r)) }) .collect() } +fn year_in_range(start: &str, end: &str, capture_year: Option) -> bool { + let has_start = start != "ALL" && !start.trim().is_empty(); + let has_end = end != "ALL" && !end.trim().is_empty(); + if !has_start && !has_end { + return true; + } + let Some(yr) = capture_year else { + return false; + }; + if has_start { + let Ok(s) = start.trim().parse::() else { + return false; + }; + if yr < s { + return false; + } + } + if has_end { + let Ok(e) = end.trim().parse::() else { + return false; + }; + if yr > e { + return false; + } + } + true +} + +fn list_matches(list: &[&'static str], capture_value: &str) -> bool { + if list.is_empty() { + return false; + } + if list.iter().any(|s| *s == "ALL") { + return true; + } + list.iter() + .any(|s| eq_ignore_case(s, capture_value)) +} + fn eq_ignore_case(a: &str, b: &str) -> bool { a.eq_ignore_ascii_case(b) }