diff --git a/CHANGELOG.md b/CHANGELOG.md index 3224bca..c58e8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to KAT are documented here. +## [1.1.3] - 2026-02-20 + +### Added + +- **Command field** — Capture metadata (press **i**) and .fob export now include **Command** (e.g. Unlock, Lock, Trunk, Panic). Export filename for unknown protocol uses Year_Make_Model_Region_Command; 8-hex suffix is shown in the filename field and saved in uppercase (e.g. `…_A1B2C3D4.fob`). .fob vehicle info and import support optional `command`. + +### Changed + +- **Unknown signals** — Shown by default (`research_mode` default is now `true`). Config comment and storage docs clarify that no keystore directory is used or created (keys are embedded). +- **Signal separation** — End-of-signal gap increased from 20 ms to **80 ms** so one button press (multiple bursts with 25–50 ms gaps) produces a single capture instead of 3–4. +- **Short signals** — Demodulator now emits captures with **5+** level-duration pairs (was 10), so short or weak unknown keyfob bursts are no longer dropped (RSSI spike but no capture). + +### Fixed + +- **Export filename** — Unknown-protocol .fob exports always get the 8-hex suffix (including when filename ends with `Unknown`); suffix is uppercase. + +--- + ## [1.1.2] - 2026-02-20 ### Added diff --git a/Cargo.toml b/Cargo.toml index 97845a6..1493db8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kat" -version = "1.1.2" +version = "1.1.3" edition = "2021" description = "Keyfob Analysis Toolkit - HackRF/RTL-SDR signal capture, decode, and transmit (HackRF only)" authors = ["KAT Team"] diff --git a/src/app.rs b/src/app.rs index f13447d..5fa132d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -125,13 +125,16 @@ pub enum InputMode { FobMetaModel, /// Fob export metadata: editing region field FobMetaRegion, + /// Fob export metadata: editing command field + FobMetaCommand, /// Fob export metadata: editing notes field FobMetaNotes, - /// Capture metadata (Year/Make/Model/Region) for vuln lookup — press i on a capture + /// Capture metadata (Year/Make/Model/Region/Command) for vuln lookup — press i on a capture CaptureMetaYear, CaptureMetaMake, CaptureMetaModel, CaptureMetaRegion, + CaptureMetaCommand, /// License overlay (centered box) License, /// Credits overlay (centered box) @@ -357,14 +360,17 @@ pub struct App { pub fob_meta_model: String, /// Region input buffer (e.g. NA, EU, APAC, etc.) pub fob_meta_region: String, + /// Command input buffer (e.g. Unlock, Lock) + pub fob_meta_command: String, /// Notes input buffer pub fob_meta_notes: String, - // -- Capture metadata (Year/Make/Model/Region for vuln lookup, set via 'i') -- + // -- Capture metadata (Year/Make/Model/Region/Command for vuln lookup, set via 'i') -- pub capture_meta_year: String, pub capture_meta_make: String, pub capture_meta_model: String, pub capture_meta_region: String, + pub capture_meta_command: String, /// Which capture is being edited (when in CaptureMeta* modes) pub capture_meta_capture_id: Option, @@ -486,11 +492,13 @@ impl App { fob_meta_make: String::new(), fob_meta_model: String::new(), fob_meta_region: String::new(), + fob_meta_command: String::new(), fob_meta_notes: String::new(), capture_meta_year: String::new(), capture_meta_make: String::new(), capture_meta_model: String::new(), capture_meta_region: String::new(), + capture_meta_command: String::new(), capture_meta_capture_id: None, pending_transmit_queue: Vec::new(), pending_transmit_restore: None, @@ -1299,12 +1307,52 @@ impl App { .unwrap_or_else(|_| path.to_string_lossy().to_string()) } + /// For unknown protocol: Year_Make_Model_Region_Command (user can edit; we append _<8 hex> on save). + /// For known protocol: protocol_serial as before. fn default_export_filename(capture: &Capture) -> String { - format!( - "{}_{}", - capture.protocol_name().replace(' ', "_").to_lowercase(), - capture.serial_hex() - ) + if capture.protocol_name().eq_ignore_ascii_case("unknown") { + let year = capture + .year + .as_deref() + .unwrap_or("Unknown") + .trim() + .replace(' ', "_"); + let make = capture + .make + .as_deref() + .unwrap_or("Unknown") + .trim() + .replace(' ', "_"); + let model = capture + .model + .as_deref() + .unwrap_or("Unknown") + .trim() + .replace(' ', "_"); + let region = capture + .region + .as_deref() + .unwrap_or("Unknown") + .trim() + .replace(' ', "_"); + let cmd_str = capture + .command + .as_deref() + .unwrap_or_else(|| capture.button_name()) + .trim(); + let command = if cmd_str.is_empty() || cmd_str == "-" { + "Unknown".to_string() + } else { + cmd_str.replace(' ', "_") + }; + format!("{}_{}_{}_{}_{}", year, make, model, region, command) + } else { + format!( + "{}_{}", + capture.protocol_name().replace(' ', "_").to_lowercase(), + capture.serial_hex() + ) + } } /// Start .fob export by entering filename input mode @@ -1314,13 +1362,23 @@ impl App { return Ok(()); } - // Pre-fill filename from protocol + serial - let default_name = self.captures.iter().find(|c| c.id == id) + // Pre-fill filename from protocol + serial; for unknown captures include 8-hex suffix so user sees it + let capture = self.captures.iter().find(|c| c.id == id); + let default_name = capture .map(|c| Self::default_export_filename(c)) .unwrap_or_else(|| format!("capture_{}", id)); + self.export_filename = if capture.map(|c| c.protocol.is_none()).unwrap_or(false) { + let suffix_nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + let suffix = (suffix_nanos.wrapping_add(id as u64 * 2654435761) % 0x100_000_000) as u32; + format!("{}_{:08X}", default_name, suffix) + } else { + default_name.clone() + }; // Pre-fill metadata from capture if set, otherwise make from protocol - let capture = self.captures.iter().find(|c| c.id == id); let make = capture .and_then(|c| c.make.as_ref().map(String::clone)) .filter(|s| !s.is_empty()) @@ -1330,7 +1388,6 @@ impl App { .unwrap_or_default() }); self.export_capture_id = Some(id); - self.export_filename = default_name; self.export_format = Some(ExportFormat::Fob); self.fob_meta_year = capture .and_then(|c| c.year.as_ref()) @@ -1345,6 +1402,16 @@ impl App { .and_then(|c| c.region.as_ref()) .map(String::clone) .unwrap_or_default(); + self.fob_meta_command = capture + .and_then(|c| c.command.clone()) + .unwrap_or_else(|| { + let b = capture.map(|c| c.button_name().to_string()).unwrap_or_default(); + if b.is_empty() || b == "-" { + String::new() + } else { + b + } + }); self.fob_meta_notes = String::new(); self.input_mode = InputMode::ExportFilename; Ok(()) @@ -1378,10 +1445,37 @@ impl App { make: self.fob_meta_make.clone(), model: self.fob_meta_model.clone(), region: self.fob_meta_region.clone(), + command: self.fob_meta_command.clone(), notes: self.fob_meta_notes.clone(), }; - let filename = format!("{}.fob", self.export_filename); + // Unknown captures or user-edited vehicle-style filenames always get 8-hex suffix to avoid overwrites. + let use_hex_suffix = capture.protocol.is_none() + || self.export_filename.ends_with("_Unknown") + || self.export_filename == "Unknown"; + let already_has_8hex = self.export_filename.len() >= 9 + && self.export_filename.as_bytes()[self.export_filename.len() - 9] == b'_' + && self.export_filename[self.export_filename.len() - 8..] + .chars() + .all(|c| c.is_ascii_hexdigit()); + let filename = if use_hex_suffix && already_has_8hex { + format!("{}.fob", self.export_filename.trim()) + } else if use_hex_suffix { + let base = self.export_filename.trim(); + let suffix_nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + let suffix = (suffix_nanos.wrapping_add(id as u64 * 2654435761) % 0x100_000_000) as u32; + let hex_suffix = format!("{:08X}", suffix); + if base.is_empty() { + format!("unknown_{}_{}.fob", id, hex_suffix) + } else { + format!("{}_{}.fob", base, hex_suffix) + } + } else { + format!("{}.fob", self.export_filename) + }; let path = export_dir.join(&filename); crate::export::fob::export_fob( @@ -1416,6 +1510,16 @@ impl App { .and_then(|c| c.region.as_ref()) .map(|s| s.to_string()) .unwrap_or_default(); + self.capture_meta_command = capture + .and_then(|c| c.command.clone()) + .unwrap_or_else(|| { + let b = capture.map(|c| c.button_name().to_string()).unwrap_or_default(); + if b.is_empty() || b == "-" { + String::new() + } else { + b + } + }); self.capture_meta_capture_id = Some(capture_id); self.input_mode = InputMode::CaptureMetaYear; } @@ -1435,6 +1539,7 @@ impl App { capture.make = Some(self.capture_meta_make.clone()).filter(|s| !s.is_empty()); capture.model = Some(self.capture_meta_model.clone()).filter(|s| !s.is_empty()); capture.region = Some(self.capture_meta_region.clone()).filter(|s| !s.is_empty()); + capture.command = Some(self.capture_meta_command.clone()).filter(|s| !s.is_empty()); } self.input_mode = InputMode::Normal; self.capture_meta_capture_id = None; @@ -1835,6 +1940,7 @@ impl App { make: None, model: None, region: None, + command: None, source_file: None, }; self.next_capture_id += 1; diff --git a/src/capture.rs b/src/capture.rs index 370b7fb..06942c0 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -76,6 +76,9 @@ pub struct Capture { /// Region (e.g. NA, EU) for vulnerability lookup and .fob export. #[serde(default)] pub region: Option, + /// User-editable command label (e.g. Unlock, Lock) for .fob export and filename; set via 'i' or export form. + #[serde(default)] + pub command: Option, /// Source file path when imported from .sub or .fob; None for live captures. #[serde(default)] pub source_file: Option, @@ -157,6 +160,7 @@ impl Capture { make: None, model: None, region: None, + command: None, source_file: None, } } diff --git a/src/export/fob.rs b/src/export/fob.rs index d133837..53c6bb6 100644 --- a/src/export/fob.rs +++ b/src/export/fob.rs @@ -14,6 +14,8 @@ pub struct FobMetadata { pub make: String, pub model: String, pub region: String, + /// Command label (e.g. Unlock, Lock) for export filename and .fob vehicle info. + pub command: String, pub notes: String, } @@ -61,6 +63,9 @@ pub struct FobVehicleInfo { pub model: Option, #[serde(default)] pub region: Option, + /// Command label (e.g. Unlock, Lock); optional for backwards compatibility. + #[serde(default)] + pub command: Option, #[serde(default)] pub notes: Option, } @@ -119,6 +124,14 @@ pub fn export_fob( Some(m.notes.clone()) } }); + let command = metadata.and_then(|m| { + let s = m.command.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }); let raw_pairs = if include_raw && !capture.raw_pairs.is_empty() { Some( @@ -163,6 +176,7 @@ pub fn export_fob( make, model, region, + command, notes, }, capture: FobCapture { @@ -287,6 +301,7 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result { CaptureStatus::Unknown }; + let vehicle = &fob.vehicle; Ok(Capture { id: next_id, timestamp, @@ -302,10 +317,11 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result { raw_pairs, status, received_rf: None, - year: None, - make: None, - model: None, - region: None, + year: vehicle.year.map(|y| y.to_string()), + make: Some(vehicle.make.clone()).filter(|s| !s.is_empty()), + model: vehicle.model.clone().filter(|s| !s.is_empty()), + region: vehicle.region.clone().filter(|s| !s.is_empty()), + command: vehicle.command.clone().filter(|s| !s.is_empty()), source_file: None, }) } @@ -377,6 +393,7 @@ fn import_fob_v1(fob: &FobFileV1, next_id: u32) -> Result { make: None, model: None, region: None, + command: None, source_file: None, }) } diff --git a/src/main.rs b/src/main.rs index 9cb0c42..ed63e62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -388,10 +388,10 @@ fn run_app(terminal: &mut Terminal, app: &mut A _ => {} }, - // .fob export metadata: Region -> Notes + // .fob export metadata: Region -> Command InputMode::FobMetaRegion => match key.code { KeyCode::Enter => { - app.input_mode = InputMode::FobMetaNotes; + app.input_mode = InputMode::FobMetaCommand; } KeyCode::Char(c) => { app.fob_meta_region.push(c); @@ -406,6 +406,24 @@ fn run_app(terminal: &mut Terminal, app: &mut A _ => {} }, + // .fob export metadata: Command -> Notes + InputMode::FobMetaCommand => match key.code { + KeyCode::Enter => { + app.input_mode = InputMode::FobMetaNotes; + } + KeyCode::Char(c) => { + app.fob_meta_command.push(c); + } + KeyCode::Backspace => { + app.fob_meta_command.pop(); + } + KeyCode::Esc => { + app.export_capture_id = None; + app.input_mode = InputMode::Normal; + } + _ => {} + }, + // .fob export metadata: Notes -> Export InputMode::FobMetaNotes => match key.code { KeyCode::Enter => { @@ -425,7 +443,7 @@ fn run_app(terminal: &mut Terminal, app: &mut A _ => {} }, - // Capture metadata (Year/Make/Model/Region for vuln lookup) + // Capture metadata (Year/Make/Model/Region/Command for vuln lookup) InputMode::CaptureMetaYear => match key.code { KeyCode::Enter => { app.input_mode = InputMode::CaptureMetaMake; @@ -475,7 +493,7 @@ fn run_app(terminal: &mut Terminal, app: &mut A }, InputMode::CaptureMetaRegion => match key.code { KeyCode::Enter => { - app.save_capture_meta(); + app.input_mode = InputMode::CaptureMetaCommand; } KeyCode::Char(c) => { app.capture_meta_region.push(c); @@ -488,6 +506,21 @@ fn run_app(terminal: &mut Terminal, app: &mut A } _ => {} }, + InputMode::CaptureMetaCommand => match key.code { + KeyCode::Enter => { + app.save_capture_meta(); + } + KeyCode::Char(c) => { + app.capture_meta_command.push(c); + } + KeyCode::Backspace => { + app.capture_meta_command.pop(); + } + KeyCode::Esc => { + app.cancel_capture_meta(); + } + _ => {} + }, InputMode::LoadFileBrowser => { const VISIBLE: usize = 16; diff --git a/src/radio/demodulator.rs b/src/radio/demodulator.rs index aaf8c69..cf93a72 100644 --- a/src/radio/demodulator.rs +++ b/src/radio/demodulator.rs @@ -113,7 +113,7 @@ impl Demodulator { pairs: Vec::with_capacity(2048), total_samples: 0, min_duration_us: 40, // 40µs debounce (was 50 — slightly more permissive) - max_gap_us: 20_000, // 20ms gap = end of signal (was 10ms — wider to avoid splitting signals with internal gaps) + max_gap_us: 80_000, // 80ms gap = end of signal; keeps multi-burst keyfob presses (e.g. 3–4 bursts with 25–50ms gaps) as one capture samples_since_edge: 0, } } @@ -253,11 +253,11 @@ impl Demodulator { .push(LevelDuration::new(self.current_level, duration_us)); } - // Return the pairs and reset + // Return the pairs and reset (min 5 pairs so short/unknown keyfob bursts still show) let result = std::mem::take(&mut self.pairs); self.reset_state(); - if result.len() >= 10 { + if result.len() >= 5 { return Some(result); } } @@ -405,7 +405,7 @@ impl FmDemodulator { pending_count: 0, pairs: Vec::with_capacity(2048), min_duration_us: 40, - max_gap_us: 20_000, + max_gap_us: 80_000, // match AM: 80ms so one button press (multi-burst) stays one capture samples_since_edge: 0, } } @@ -495,7 +495,7 @@ impl FmDemodulator { } let result = std::mem::take(&mut self.pairs); self.fm_reset_state(); - if result.len() >= 10 { + if result.len() >= 5 { return Some(result); } } @@ -559,8 +559,8 @@ mod tests { // Process (won't return signal yet since no long gap) let _ = demod.process_samples(&buf); - // Add a long gap to flush - let gap_buf: Vec = vec![1, 0].repeat(50_000); // 25ms LOW + // Add a long gap to flush (>= max_gap_us: 80ms at 2MHz = 160k samples) + let gap_buf: Vec = vec![1, 0].repeat(80_000); // 80ms LOW if let Some(pairs) = demod.process_samples(&gap_buf) { // Verify no consecutive same-level pairs for window in pairs.windows(2) { diff --git a/src/storage.rs b/src/storage.rs index 6cb78d2..f783cdd 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,6 +1,7 @@ -//! Storage management for configuration, exports, and keystores. +//! Storage management for configuration and exports. //! -//! All application data lives under `~/.config/KAT/`: +//! All application data lives under `~/.config/KAT/`. **No keystore directory is created** +//! — keys are embedded in the binary (see [crate::protocols::keys] and [crate::keystore]). //! //! ```text //! ~/.config/KAT/ @@ -57,7 +58,7 @@ impl Config { export_directory: config_dir.join("exports"), import_directory: config_dir.join("import"), max_captures: 100, - research_mode: false, + research_mode: true, // show unknown signals by default (researchers need to see them) default_frequency: 433_920_000, default_lna_gain: 24, default_vga_gain: 20, @@ -160,6 +161,7 @@ impl Config { ; Location: {path} ; ; Edit this file to change default settings. +; Keys are embedded in the program — no keystore directory is used or created. ; Lines starting with ; or # are comments. [general] diff --git a/src/ui/command.rs b/src/ui/command.rs index 2920e58..c44c199 100644 --- a/src/ui/command.rs +++ b/src/ui/command.rs @@ -53,6 +53,7 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) { | InputMode::FobMetaMake | InputMode::FobMetaModel | InputMode::FobMetaRegion + | InputMode::FobMetaCommand | InputMode::FobMetaNotes => ( String::new(), "EXPORT", @@ -61,7 +62,8 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) { InputMode::CaptureMetaYear | InputMode::CaptureMetaMake | InputMode::CaptureMetaModel - | InputMode::CaptureMetaRegion => ( + | InputMode::CaptureMetaRegion + | InputMode::CaptureMetaCommand => ( String::new(), "META", Style::default().fg(Color::Cyan), diff --git a/src/ui/layout.rs b/src/ui/layout.rs index c12df6a..e7aa634 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -105,6 +105,7 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { | InputMode::FobMetaMake | InputMode::FobMetaModel | InputMode::FobMetaRegion + | InputMode::FobMetaCommand | InputMode::FobMetaNotes ) { render_export_form(frame, app); @@ -116,6 +117,7 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { | InputMode::CaptureMetaMake | InputMode::CaptureMetaModel | InputMode::CaptureMetaRegion + | InputMode::CaptureMetaCommand ) { render_capture_meta_form(frame, app); } @@ -250,12 +252,14 @@ fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) { InputMode::FobMetaYear | InputMode::FobMetaMake | InputMode::FobMetaModel - | InputMode::FobMetaRegion => "Enter: Next Field | Esc: Cancel Export", + | InputMode::FobMetaRegion + | InputMode::FobMetaCommand => "Enter: Next Field | Esc: Cancel Export", InputMode::FobMetaNotes => "Enter: Save & Export | Esc: Cancel Export", InputMode::CaptureMetaYear | InputMode::CaptureMetaMake - | InputMode::CaptureMetaModel => "Enter: Next Field | Esc: Cancel", - InputMode::CaptureMetaRegion => "Enter: Save | Esc: Cancel", + | InputMode::CaptureMetaModel + | InputMode::CaptureMetaRegion => "Enter: Next Field | Esc: Cancel", + InputMode::CaptureMetaCommand => "Enter: Save | Esc: Cancel", InputMode::License | InputMode::Credits => "Esc/Enter: Close | Up/Down: Scroll", InputMode::LoadFileBrowser => "Up/Down: Navigate | Enter: Open/Import | Esc: Close", }; @@ -464,6 +468,7 @@ fn render_export_form(frame: &mut Frame, app: &App) { InputMode::FobMetaMake, InputMode::FobMetaModel, InputMode::FobMetaRegion, + InputMode::FobMetaCommand, InputMode::FobMetaNotes, ] } else { @@ -566,11 +571,17 @@ fn render_export_form(frame: &mut Frame, app: &App) { placeholder: "(e.g. NA, EU, APAC, MEA)", idx: 4, }, + FormField { + label: " Command: ", + value: &app.fob_meta_command, + placeholder: "(e.g. Unlock, Lock, Trunk, Panic)", + idx: 5, + }, FormField { label: " Notes: ", value: &app.fob_meta_notes, placeholder: "(optional — color, trim, VIN, etc.)", - idx: 5, + idx: 6, }, ]); } @@ -670,6 +681,7 @@ fn render_capture_meta_form(frame: &mut Frame, app: &App) { InputMode::CaptureMetaMake, InputMode::CaptureMetaModel, InputMode::CaptureMetaRegion, + InputMode::CaptureMetaCommand, ]; let current_idx = field_modes .iter() @@ -726,7 +738,7 @@ fn render_capture_meta_form(frame: &mut Frame, app: &App) { placeholder: &'a str, idx: usize, } - let fields: [FormField; 4] = [ + let fields: [FormField; 5] = [ FormField { label: " Year: ", value: &app.capture_meta_year, @@ -751,6 +763,12 @@ fn render_capture_meta_form(frame: &mut Frame, app: &App) { placeholder: "(e.g. NA, EU, or ALL)", idx: 3, }, + FormField { + label: " Command: ", + value: &app.capture_meta_command, + placeholder: "(e.g. Unlock, Lock, Trunk, Panic)", + idx: 4, + }, ]; for field in &fields {