minor updates

This commit is contained in:
KaraZajac
2026-02-15 19:52:21 -05:00
parent daed5321d9
commit e5005c9083
9 changed files with 164 additions and 93 deletions
Generated
+1 -1
View File
@@ -402,7 +402,7 @@ dependencies = [
[[package]]
name = "kat"
version = "1.0.0"
version = "1.0.1"
dependencies = [
"anyhow",
"atty",
+5 -5
View File
@@ -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
+11 -2
View File
@@ -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()
+1
View File
@@ -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.
+4
View File
@@ -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<ParsedKeystore> {
if blob.len() < 4 || &blob[0..4] != MAGIC {
return None;
+9
View File
@@ -252,6 +252,15 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, 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') => {
+83 -85
View File
@@ -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
+5
View File
@@ -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",
+45
View File
@@ -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();