From e5005c9083801a68e49e42e5d615054938cb1da0 Mon Sep 17 00:00:00 2001 From: KaraZajac Date: Sun, 15 Feb 2026 19:52:21 -0500 Subject: [PATCH] minor updates --- Cargo.lock | 2 +- docs/keeloq_generic.md | 10 +- src/app.rs | 13 ++- src/keystore/embedded.rs | 1 + src/keystore/mod.rs | 4 + src/main.rs | 9 ++ src/protocols/keeloq_generic.rs | 168 ++++++++++++++++---------------- src/ui/command.rs | 5 + src/ui/layout.rs | 45 +++++++++ 9 files changed, 164 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad564d7..5391576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,7 +402,7 @@ dependencies = [ [[package]] name = "kat" -version = "1.0.0" +version = "1.0.1" dependencies = [ "anyhow", "atty", diff --git a/docs/keeloq_generic.md b/docs/keeloq_generic.md index b7d35b6..ab0bb5a 100644 --- a/docs/keeloq_generic.md +++ b/docs/keeloq_generic.md @@ -5,12 +5,12 @@ ## Overview -When no known protocol decodes a captured signal, KAT tries to decode it as **KeeLoq** using every manufacturer key in the embedded keystore. All decryption is done via the **keeloq_common** helper (no protocol-specific decrypt code). Two air formats are supported: +When no known protocol decodes a captured signal, KAT tries to decode it as **KeeLoq** using **every** manufacturer key in the embedded keystore, **regardless of frequency**. All decryption is done via the **keeloq_common** helper (no protocol-specific decrypt code). Two air formats are tried (in order): -1. **Kia V3/V4 format** — 68 bits, 400/800 µs PWM, 315 or 433.92 MHz -2. **Star Line format** — 64 bits, 250/500 µs PWM, 433.92 MHz +1. **Kia V3/V4 format** — 68 bits, 400/800 µs PWM +2. **Star Line format** — 64 bits, 250/500 µs PWM -Bit collection reuses the existing Kia V3/V4 and Star Line state machines; decryption and validation use only **keeloq_common** (`keeloq_decrypt`, `keeloq_normal_learning`, `reverse_key`, `reverse8`). +Bit collection reuses the existing Kia V3/V4 and Star Line state machines; decryption and validation use only **keeloq_common** (`keeloq_decrypt`, `keeloq_normal_learning`, `reverse_key`, `reverse8`). Each key is tried in **both byte orders** (as stored in the keystore, and byte-swapped) so that either big-endian or little-endian key sources can match. ## When it runs @@ -26,7 +26,7 @@ On first successful decrypt with a given key, the capture is shown in the list w ## Keystore -Uses **all** KeeLoq manufacturer keys from the embedded blob (types 0, 1, 2, 10, 20). Key names come from `keystore::KEY_ENTRY_NAMES` and are used only for the displayed protocol label. +Uses **all** KeeLoq manufacturer keys from the embedded blob (types 0, 1, 2, 10, 20). Key names come from `keystore::KEY_ENTRY_NAMES` and are used only for the displayed protocol label. Keys are stored in the blob as 8 bytes little-endian; the resulting u64 matches reference/Pandora hex (MSB-first notation). ## Encoding diff --git a/src/app.rs b/src/app.rs index a082fe6..da37034 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,6 +21,8 @@ pub enum InputMode { SettingsSelect, /// Editing a radio setting value SettingsEdit, + /// Startup warning: HackRF not detected (dismiss to continue) + HackRfNotDetected, /// Startup prompt: found .fob files, import? (y/n) StartupImport, /// Export: editing filename (before format-specific steps) @@ -279,7 +281,12 @@ impl App { } }; - let radio_state = if hackrf.is_some() { + // HackRF controller always returns Ok; when no device is present it has is_available() == false + let hackrf_detected = hackrf + .as_ref() + .map_or(false, |h| h.is_available()); + + let radio_state = if hackrf_detected { RadioState::Idle } else { RadioState::Disconnected @@ -299,7 +306,9 @@ impl App { // Recursively scan import directory for .fob and .sub at startup (separate from export dir) let pending_fob_files = crate::export::scan_import_files_recursive(storage.import_dir()); - let initial_mode = if !pending_fob_files.is_empty() { + let initial_mode = if !hackrf_detected { + InputMode::HackRfNotDetected + } else if !pending_fob_files.is_empty() { tracing::info!( "Found {} importable file(s) in import dir (recursive)", pending_fob_files.len() diff --git a/src/keystore/embedded.rs b/src/keystore/embedded.rs index da54f93..78ffae9 100644 --- a/src/keystore/embedded.rs +++ b/src/keystore/embedded.rs @@ -1,6 +1,7 @@ //! Embedded keystore blob (standard encrypted + VAG raw). //! Data is stored as binary to avoid plain-text keys in config. //! Format: "KATK" magic, n_entries (u16 LE), then per entry: type_id (u32 LE), key (u64 LE), then "VAG " + 64 bytes. +//! Key bytes are little-endian (LSB first); the resulting u64 matches reference hex (MSB-first notation). //! All manufacture keys from the encrypted keystore list are included for current and future protocols. //! Type 20 (Star Line) is also included so KeyStore.star_line_mf_key is populated from the same key as SL_A2-A4. diff --git a/src/keystore/mod.rs b/src/keystore/mod.rs index b3b8376..6d1fb38 100644 --- a/src/keystore/mod.rs +++ b/src/keystore/mod.rs @@ -33,6 +33,10 @@ pub struct ParsedKeystore { } /// Parse the embedded keystore blob. Returns KIA/Star Line entries and VAG raw bytes. +/// +/// Keys are stored as 8 bytes **little-endian** per entry; we load with `u64::from_le_bytes`. +/// The resulting u64 value matches the reference/Pandora hex (e.g. KIA = 0xA8F5DFFC8DAA5CDB), +/// which is typically written MSB-first in docs; KeeLoq uses this value as a 64-bit key. pub fn parse_blob(blob: &[u8]) -> Option { if blob.len() < 4 || &blob[0..4] != MAGIC { return None; diff --git a/src/main.rs b/src/main.rs index e13df1d..0e23bc7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -252,6 +252,15 @@ fn run_app(terminal: &mut Terminal, app: &mut A _ => {} }, + // Startup: HackRF not detected — any key to dismiss + InputMode::HackRfNotDetected => { + app.input_mode = if app.pending_fob_files.is_empty() { + InputMode::Normal + } else { + InputMode::StartupImport + }; + } + // Startup: found .fob files, y/n to import InputMode::StartupImport => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { diff --git a/src/protocols/keeloq_generic.rs b/src/protocols/keeloq_generic.rs index 94e26c7..3e33224 100644 --- a/src/protocols/keeloq_generic.rs +++ b/src/protocols/keeloq_generic.rs @@ -6,7 +6,8 @@ //! - **Star Line format**: 64 bits, 250/500µs PWM, 433 MHz //! //! All decryption is done via [keeloq_common]; this module only orchestrates bit collection -//! (delegated to the format-specific collectors) and tries each key. +//! (delegated to the format-specific collectors) and tries each key in both byte orders +//! (as stored, and byte-swapped) in case the source used big- or little-endian. use super::common::DecodedSignal; use super::keeloq_common::{keeloq_decrypt, keeloq_normal_learning, reverse_key, reverse8}; @@ -19,39 +20,32 @@ const KIA_V3_V4_BITS: usize = 68; const STAR_LINE_BITS: usize = 64; /// Try to decode an unknown signal as KeeLoq using every keystore manufacturer key. -/// Tries Kia V3/V4 format first (315/433 MHz), then Star Line format (433 MHz). +/// Tries both Kia V3/V4 format (68-bit, 400/800µs) and Star Line format (64-bit, 250/500µs) +/// regardless of frequency; each key is tried in both byte orders (LE and BE) until one validates. /// Returns `("Keeloq (keystore name)", decoded)` on first successful decrypt. pub fn try_decode( pairs: &[LevelDuration], - frequency: u32, + _frequency: u32, ) -> Option<(String, DecodedSignal)> { let keys = keystore::keeloq_mf_keys_with_names(); if keys.is_empty() { return None; } - // Kia V3/V4 format: 400/800µs, 315/433 MHz - let kia_freq_ok = [315_000_000u32, 433_920_000].iter().any(|&f| { - let diff = if f > frequency { f - frequency } else { frequency - f }; - diff < (f / 50) - }); - if kia_freq_ok { - for invert in [false, true] { - if let Some((buf, is_v3)) = kia_v3_v4::collect_kia_v3_v4_bits(pairs, invert) { - if let Some((name, decoded)) = try_kia_v3_v4_format(&buf, is_v3, &keys) { - return Some((format!("Keeloq ({})", name), decoded)); - } + // Kia V3/V4 format: 400/800µs, 68 bits (try both polarities, all keys) + for invert in [false, true] { + if let Some((buf, is_v3)) = kia_v3_v4::collect_kia_v3_v4_bits(pairs, invert) { + if let Some((name, decoded)) = try_kia_v3_v4_format(&buf, is_v3, &keys) { + return Some((format!("Keeloq ({})", name), decoded)); } } } - // Star Line format: 250/500µs, 433 MHz - if frequency.abs_diff(433_920_000) < (433_920_000 / 50) { - for invert in [false, true] { - if let Some(data) = star_line::collect_star_line_bits(pairs, invert) { - if let Some((name, decoded)) = try_star_line_format(data, &keys) { - return Some((format!("Keeloq ({})", name), decoded)); - } + // Star Line format: 250/500µs, 64 bits (try both polarities, all keys) + for invert in [false, true] { + if let Some(data) = star_line::collect_star_line_bits(pairs, invert) { + if let Some((name, decoded)) = try_star_line_format(data, &keys) { + return Some((format!("Keeloq ({})", name), decoded)); } } } @@ -91,27 +85,29 @@ fn try_kia_v3_v4_format( | (b[7] as u64); for (name, mf_key) in keys { - if *mf_key == 0 { - continue; - } - let decrypted = keeloq_decrypt(encrypted, *mf_key); - let dec_btn = ((decrypted >> 28) & 0x0F) as u8; - let dec_serial_lsb = ((decrypted >> 16) & 0xFF) as u8; - if dec_btn == button && dec_serial_lsb == our_serial_lsb { - let counter = (decrypted & 0xFFFF) as u16; - return Some(( - name.clone(), - DecodedSignal { - serial: Some(serial), - button: Some(button), - counter: Some(counter), - crc_valid: true, - data: key_data, - data_count_bit: KIA_V3_V4_BITS, - encoder_capable: true, - extra: None, - }, - )); + for key in [*mf_key, mf_key.swap_bytes()] { + if key == 0 { + continue; + } + let decrypted = keeloq_decrypt(encrypted, key); + let dec_btn = ((decrypted >> 28) & 0x0F) as u8; + let dec_serial_lsb = ((decrypted >> 16) & 0xFF) as u8; + if dec_btn == button && dec_serial_lsb == our_serial_lsb { + let counter = (decrypted & 0xFFFF) as u16; + return Some(( + name.clone(), + DecodedSignal { + serial: Some(serial), + button: Some(button), + counter: Some(counter), + crc_valid: true, + data: key_data, + data_count_bit: KIA_V3_V4_BITS, + encoder_capable: true, + extra: None, + }, + )); + } } } None @@ -131,49 +127,51 @@ fn try_star_line_format( let serial_lsb = (serial & 0xFF) as u8; for (name, mf_key) in keys { - if *mf_key == 0 { - continue; - } - // Simple learning - let decrypt = keeloq_decrypt(key_hop, *mf_key); - let dec_btn = (decrypt >> 24) as u8; - let dec_serial_lsb = ((decrypt >> 16) & 0xFF) as u8; - if dec_btn == btn && dec_serial_lsb == serial_lsb { - let counter = (decrypt & 0xFFFF) as u16; - return Some(( - name.clone(), - DecodedSignal { - serial: Some(serial), - button: Some(btn), - counter: Some(counter), - crc_valid: true, - data, - data_count_bit: STAR_LINE_BITS, - encoder_capable: true, - extra: None, - }, - )); - } - // Normal learning - let man_key = keeloq_normal_learning(key_fix, *mf_key); - let decrypt = keeloq_decrypt(key_hop, man_key); - let dec_btn = (decrypt >> 24) as u8; - let dec_serial_lsb = ((decrypt >> 16) & 0xFF) as u8; - if dec_btn == btn && dec_serial_lsb == serial_lsb { - let counter = (decrypt & 0xFFFF) as u16; - return Some(( - name.clone(), - DecodedSignal { - serial: Some(serial), - button: Some(btn), - counter: Some(counter), - crc_valid: true, - data, - data_count_bit: STAR_LINE_BITS, - encoder_capable: true, - extra: None, - }, - )); + for key in [*mf_key, mf_key.swap_bytes()] { + if key == 0 { + continue; + } + // Simple learning + let decrypt = keeloq_decrypt(key_hop, key); + let dec_btn = (decrypt >> 24) as u8; + let dec_serial_lsb = ((decrypt >> 16) & 0xFF) as u8; + if dec_btn == btn && dec_serial_lsb == serial_lsb { + let counter = (decrypt & 0xFFFF) as u16; + return Some(( + name.clone(), + DecodedSignal { + serial: Some(serial), + button: Some(btn), + counter: Some(counter), + crc_valid: true, + data, + data_count_bit: STAR_LINE_BITS, + encoder_capable: true, + extra: None, + }, + )); + } + // Normal learning + let man_key = keeloq_normal_learning(key_fix, key); + let decrypt = keeloq_decrypt(key_hop, man_key); + let dec_btn = (decrypt >> 24) as u8; + let dec_serial_lsb = ((decrypt >> 16) & 0xFF) as u8; + if dec_btn == btn && dec_serial_lsb == serial_lsb { + let counter = (decrypt & 0xFFFF) as u16; + return Some(( + name.clone(), + DecodedSignal { + serial: Some(serial), + button: Some(btn), + counter: Some(counter), + crc_valid: true, + data, + data_count_bit: STAR_LINE_BITS, + encoder_capable: true, + extra: None, + }, + )); + } } } None diff --git a/src/ui/command.rs b/src/ui/command.rs index 6668cc6..50bae70 100644 --- a/src/ui/command.rs +++ b/src/ui/command.rs @@ -38,6 +38,11 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) { "EDIT", Style::default().fg(Color::Green), ), + InputMode::HackRfNotDetected => ( + String::new(), + "WARNING", + Style::default().fg(Color::Red), + ), InputMode::StartupImport => ( String::new(), "IMPORT", diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 96671ec..8d6c78b 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -77,6 +77,10 @@ pub fn draw_ui(frame: &mut Frame, app: &App) { render_settings_dropdown(frame, app); } + if app.input_mode == InputMode::HackRfNotDetected { + render_hackrf_not_detected(frame, app); + } + if app.input_mode == InputMode::StartupImport { render_startup_import_prompt(frame, app); } @@ -160,6 +164,7 @@ fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) { InputMode::SignalMenu => "Up/Down: Navigate | Enter: Select | Esc: Close", InputMode::SettingsSelect => "Left/Right: Select | Tab: Cycle | Enter: Edit | Esc: Back", InputMode::SettingsEdit => "Up/Down: Change Value | Enter: Apply | Esc: Cancel", + InputMode::HackRfNotDetected => "Press any key to continue", InputMode::StartupImport => "y: Import | n: Skip", InputMode::ExportFilename => { match app.export_format { @@ -191,6 +196,46 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { Rect::new(x, y, width.min(area.width), height.min(area.height)) } +/// Render the HackRF not detected warning (red box at startup) +fn render_hackrf_not_detected(frame: &mut Frame, _app: &App) { + let area = frame.area(); + let popup = centered_rect(52, 7, area); + + frame.render_widget(Clear, popup); + + let red = Color::Red; + let text = vec![ + Line::from(""), + Line::from(Span::styled( + " Your HackRF was not detected.", + Style::default().fg(red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " Connect a HackRF One and restart, or continue in demo mode.", + Style::default().fg(red), + )), + Line::from(""), + Line::from(Span::styled( + " Press any key to continue.", + Style::default().fg(Color::White), + )), + ]; + + let block = Block::default() + .title(" Warning ") + .borders(Borders::ALL) + .border_style(Style::default().fg(red)) + .style(Style::default().fg(red)); + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + frame.render_widget(paragraph, popup); +} + /// Render the startup import prompt overlay fn render_startup_import_prompt(frame: &mut Frame, app: &App) { let count = app.pending_fob_files.len();