From 9961563993678970856c3cc83a62aac97f61971a Mon Sep 17 00:00:00 2001 From: leviathan Date: Sun, 15 Feb 2026 18:53:25 -0500 Subject: [PATCH] Added a generic keeloq decode against manufacure keys --- src/capture.rs | 3 + src/keystore/mod.rs | 53 +++++++--- src/protocols/keeloq_generic.rs | 180 ++++++++++++++++++++++++++++++++ src/protocols/kia_v3_v4.rs | 96 +++++++++++++++++ src/protocols/mod.rs | 7 +- src/protocols/star_line.rs | 91 ++++++++++++++++ 6 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 src/protocols/keeloq_generic.rs diff --git a/src/capture.rs b/src/capture.rs index abcfe32..bcb641e 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -212,6 +212,7 @@ impl Capture { "Subaru" => ModulationType::Pwm, "Suzuki" => ModulationType::Pwm, "Star Line" => ModulationType::Pwm, + p if p.starts_with("Keeloq (") => ModulationType::Pwm, "Scher-Khan" => ModulationType::Pwm, // Unknown _ => ModulationType::Unknown, @@ -237,6 +238,7 @@ impl Capture { "Subaru" => RfModulation::AM, "Suzuki" => RfModulation::AM, "Star Line" => RfModulation::AM, + p if p.starts_with("Keeloq (") => RfModulation::AM, // Both AM and FM (Kia V3/V4) p if p.starts_with("Kia V3") || p.starts_with("Kia V4") => RfModulation::Both, _ => RfModulation::Unknown, @@ -248,6 +250,7 @@ impl Capture { match self.protocol_name() { p if p.starts_with("Kia V3") || p.starts_with("Kia V4") => "KeeLoq", "Star Line" => "KeeLoq", + p if p.starts_with("Keeloq (") => "KeeLoq", "PSA" => "XTEA/XOR", "VAG" => "AUT64/XTEA", "Scher-Khan" => "Magic Code", diff --git a/src/keystore/mod.rs b/src/keystore/mod.rs index 7c523f6..b3b8376 100644 --- a/src/keystore/mod.rs +++ b/src/keystore/mod.rs @@ -5,6 +5,22 @@ mod embedded; use std::convert::TryInto; +/// Type IDs that are KeeLoq manufacturer keys (64-bit keys used for keeloq_decrypt). +const KEELOQ_MF_TYPES: &[u32] = &[0, 1, 2, 10, 20]; + +/// Names for each key entry in the blob (same order as entries before VAG). Used for fallback decode display. +const KEY_ENTRY_NAMES: &[&str] = &[ + "KIA", "KIAV6A", "KIAV6B", "KIAV5", "Alligator", "Mongoose", + "SL_A6-A9/Tomahawk_9010", "Pantera", "SL_A2-A4", "Cenmax_St-5", "SL_B6,B9_dop", + "Harpoon", "Tomahawk_TZ-9030", "Tomahawk_Z,X_3-5", "Cenmax_St-7", "Sheriff", + "Pantera_CLK", "Cenmax", "Alligator_S-275", "Guard_RF-311A", "Partisan_RX", + "APS-1100_APS-2550", "Pantera_XS/Jaguar", "Teco", "Leopard", "Faraon", "Reff", + "ZX-730-750-1055", "Star Line", + "Pandora_M101", "Pandora_PRO", "Pandora_PRO2", "Pandora_SUBARU", "Pandora_SUZUKI", + "Pandora_DEA", "Pandora_GIBIDI", "Pandora_MCODE", "Pandora_Unknown_1", "Pandora_Unknown_2", + "Pandora_Test_Debug_2", +]; + const MAGIC: &[u8; 4] = b"KATK"; const VAG_TAG: &[u8; 4] = b"VAG "; const VAG_SIZE: usize = 64; @@ -47,6 +63,30 @@ pub fn parse_blob(blob: &[u8]) -> Option { }) } +/// Return all KeeLoq manufacturer keys from the embedded blob with display names. +/// Used when an unknown signal is tried as KeeLoq with every key; on success we show "Keeloq (name)". +/// Only includes entry types that are KeeLoq MF keys (0, 1, 2, 10, 20). +pub fn keeloq_mf_keys_with_names() -> Vec<(String, u64)> { + let blob = embedded_blob(); + let Some(parsed) = parse_blob(blob) else { + return Vec::new(); + }; + parsed + .entries + .iter() + .enumerate() + .filter(|(_, (ty, _))| KEELOQ_MF_TYPES.contains(ty)) + .filter_map(|(i, (_, key))| { + let name = KEY_ENTRY_NAMES.get(i).copied().unwrap_or("?").to_string(); + if *key == 0 { + None + } else { + Some((name, *key)) + } + }) + .collect() +} + /// Return the embedded keystore blob for loading. pub fn embedded_blob() -> &'static [u8] { embedded::KEYSTORE_BLOB @@ -66,20 +106,9 @@ mod tests { #[test] fn dump_keystore_keys_for_pandora_compare() { let entries = embedded_entries_for_compare(); - const NAMES: &[&str] = &[ - "KIA", "KIAV6A", "KIAV6B", "KIAV5", "Alligator", "Mongoose", - "SL_A6-A9/Tomahawk_9010", "Pantera", "SL_A2-A4", "Cenmax_St-5", "SL_B6,B9_dop", - "Harpoon", "Tomahawk_TZ-9030", "Tomahawk_Z,X_3-5", "Cenmax_St-7", "Sheriff", - "Pantera_CLK", "Cenmax", "Alligator_S-275", "Guard_RF-311A", "Partisan_RX", - "APS-1100_APS-2550", "Pantera_XS/Jaguar", "Teco", "Leopard", "Faraon", "Reff", - "ZX-730-750-1055", "Star Line", - "Pandora_M101", "Pandora_PRO", "Pandora_PRO2", "Pandora_SUBARU", "Pandora_SUZUKI", - "Pandora_DEA", "Pandora_GIBIDI", "Pandora_MCODE", "Pandora_Unknown_1", "Pandora_Unknown_2", - "Pandora_Test_Debug_2", - ]; eprintln!("KAT embedded keystore keys (type, hex, name):"); for (i, (ty, key)) in entries.iter().enumerate() { - let name = NAMES.get(i).copied().unwrap_or("?"); + let name = KEY_ENTRY_NAMES.get(i).copied().unwrap_or("?"); eprintln!(" type {} {} {}", ty, format!("{:016X}", key), name); } } diff --git a/src/protocols/keeloq_generic.rs b/src/protocols/keeloq_generic.rs new file mode 100644 index 0000000..94e26c7 --- /dev/null +++ b/src/protocols/keeloq_generic.rs @@ -0,0 +1,180 @@ +//! Generic KeeLoq fallback decoder. +//! +//! When no known protocol decodes a signal, we try to decode it as KeeLoq using every +//! manufacturer key in the keystore. We support two common air formats (bit layout + timing): +//! - **Kia V3/V4 format**: 68 bits, 400/800µs PWM, 315/433 MHz +//! - **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. + +use super::common::DecodedSignal; +use super::keeloq_common::{keeloq_decrypt, keeloq_normal_learning, reverse_key, reverse8}; +use super::kia_v3_v4; +use super::star_line; +use crate::keystore; +use crate::radio::demodulator::LevelDuration; + +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). +/// Returns `("Keeloq (keystore name)", decoded)` on first successful decrypt. +pub fn try_decode( + pairs: &[LevelDuration], + 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)); + } + } + } + } + + // 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)); + } + } + } + } + + None +} + +/// Try Kia V3/V4 68-bit format with each key. Uses [keeloq_common::keeloq_decrypt] for validation. +fn try_kia_v3_v4_format( + raw_bits: &[u8; 9], + is_v3_sync: bool, + keys: &[(String, u64)], +) -> Option<(String, DecodedSignal)> { + let mut b = *raw_bits; + if is_v3_sync { + for i in 0..9 { + b[i] = !b[i]; + } + } + let encrypted = ((reverse8(b[3]) as u32) << 24) + | ((reverse8(b[2]) as u32) << 16) + | ((reverse8(b[1]) as u32) << 8) + | (reverse8(b[0]) as u32); + let serial = ((reverse8(b[7] & 0xF0) as u32) << 24) + | ((reverse8(b[6]) as u32) << 16) + | ((reverse8(b[5]) as u32) << 8) + | (reverse8(b[4]) as u32); + let button = (reverse8(b[7]) & 0xF0) >> 4; + let our_serial_lsb = (serial & 0xFF) as u8; + let key_data = ((b[0] as u64) << 56) + | ((b[1] as u64) << 48) + | ((b[2] as u64) << 40) + | ((b[3] as u64) << 32) + | ((b[4] as u64) << 24) + | ((b[5] as u64) << 16) + | ((b[6] as u64) << 8) + | (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, + }, + )); + } + } + None +} + +/// Try Star Line 64-bit format with each key. Uses [keeloq_common::keeloq_decrypt] and +/// [keeloq_common::keeloq_normal_learning] for validation. +fn try_star_line_format( + data: u64, + keys: &[(String, u64)], +) -> Option<(String, DecodedSignal)> { + let reversed = reverse_key(data, STAR_LINE_BITS); + let key_fix = (reversed >> 32) as u32; + let key_hop = (reversed & 0xFFFFFFFF) as u32; + let serial = key_fix & 0x00FFFFFF; + let btn = (key_fix >> 24) as u8; + 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, + }, + )); + } + } + None +} diff --git a/src/protocols/kia_v3_v4.rs b/src/protocols/kia_v3_v4.rs index 6c3f694..433601d 100644 --- a/src/protocols/kia_v3_v4.rs +++ b/src/protocols/kia_v3_v4.rs @@ -159,6 +159,102 @@ impl KiaV3V4Decoder { } } +/// Collect 68-bit Kia V3/V4 payload from level+duration pairs (for keeloq_generic fallback). +/// Returns (raw_bits[0..9], is_v3_sync) when a valid 68-bit burst is found. +pub fn collect_kia_v3_v4_bits( + pairs: &[LevelDuration], + invert_level: bool, +) -> Option<([u8; 9], bool)> { + let mut step = DecoderStep::Reset; + let mut te_last = 0u32; + let mut header_count = 0u16; + let mut raw_bits = [0u8; 32]; + let mut raw_bit_count = 0u16; + let mut is_v3_sync = false; + + for pair in pairs { + let level = if invert_level { !pair.level } else { pair.level }; + let duration = pair.duration_us; + let is_short = duration_diff!(duration, TE_SHORT) < TE_DELTA; + let is_long = duration_diff!(duration, TE_LONG) < TE_DELTA; + let is_sync = duration > 1000 && duration < 1500; + let is_very_long = duration > 1500; + + match step { + DecoderStep::Reset => { + if level && is_short { + step = DecoderStep::CheckPreamble; + te_last = duration; + header_count = 1; + } + } + DecoderStep::CheckPreamble => { + if level { + if is_short { + te_last = duration; + } else if is_sync && header_count >= 8 { + step = DecoderStep::CollectRawBits; + raw_bit_count = 0; + is_v3_sync = false; + raw_bits = [0; 32]; + } else { + step = DecoderStep::Reset; + } + } else { + if is_sync && header_count >= 8 { + step = DecoderStep::CollectRawBits; + raw_bit_count = 0; + is_v3_sync = true; + raw_bits = [0; 32]; + } else if is_short && duration_diff!(te_last, TE_SHORT) < TE_DELTA { + header_count += 1; + } else if is_very_long { + step = DecoderStep::Reset; + } + } + } + DecoderStep::CollectRawBits => { + if level { + if is_sync || is_very_long { + if raw_bit_count >= 68 { + let mut out = [0u8; 9]; + out.copy_from_slice(&raw_bits[0..9]); + return Some((out, is_v3_sync)); + } + step = DecoderStep::Reset; + } else if is_short { + if raw_bit_count < 256 { + let byte_idx = (raw_bit_count / 8) as usize; + let bit_idx = 7 - (raw_bit_count % 8); + raw_bits[byte_idx] &= !(1 << bit_idx); + raw_bit_count += 1; + } + } else if is_long { + if raw_bit_count < 256 { + let byte_idx = (raw_bit_count / 8) as usize; + let bit_idx = 7 - (raw_bit_count % 8); + raw_bits[byte_idx] |= 1 << bit_idx; + raw_bit_count += 1; + } + } else { + step = DecoderStep::Reset; + } + } else { + if is_sync || is_very_long { + if raw_bit_count >= 68 { + let mut out = [0u8; 9]; + out.copy_from_slice(&raw_bits[0..9]); + return Some((out, is_v3_sync)); + } + step = DecoderStep::Reset; + } + } + } + } + } + None +} + impl ProtocolDecoder for KiaV3V4Decoder { fn name(&self) -> &'static str { "Kia V3/V4" diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 14380c7..4e9dc02 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -11,6 +11,7 @@ mod common; pub mod keeloq_common; +mod keeloq_generic; #[allow(dead_code)] pub mod aut64; #[allow(dead_code)] @@ -117,7 +118,11 @@ impl ProtocolRegistry { return Some(result); } // Try inverted polarity (capture LOW = RF HIGH) - self.process_signal_inner(pairs, frequency, true) + if let Some(result) = self.process_signal_inner(pairs, frequency, true) { + return Some(result); + } + // No known protocol: try KeeLoq generic (uses keeloq_common with every keystore key) + keeloq_generic::try_decode(pairs, frequency) } /// ProtoPirate-style streaming decode: feed the whole stream, on each decode record and reset decoders, continue. diff --git a/src/protocols/star_line.rs b/src/protocols/star_line.rs index 458547d..138f13a 100644 --- a/src/protocols/star_line.rs +++ b/src/protocols/star_line.rs @@ -106,6 +106,97 @@ impl StarLineDecoder { } } +/// Collect 64-bit Star Line payload from level+duration pairs (for keeloq_generic fallback). +pub fn collect_star_line_bits( + pairs: &[LevelDuration], + invert_level: bool, +) -> Option { + let mut step = DecoderStep::Reset; + let mut header_count = 0u16; + let mut decode_data = 0u64; + let mut decode_count_bit = 0usize; + let mut te_last = 0u32; + + for pair in pairs { + let level = if invert_level { !pair.level } else { pair.level }; + let duration = pair.duration_us; + + match step { + DecoderStep::Reset => { + if level { + if duration_diff!(duration, HEADER_DURATION) < TE_DELTA * 2 { + step = DecoderStep::CheckPreamble; + header_count += 1; + } else if header_count > 4 { + decode_data = 0; + decode_count_bit = 0; + te_last = duration; + step = DecoderStep::CheckDuration; + } + } else { + header_count = 0; + } + } + DecoderStep::CheckPreamble => { + if !level && duration_diff!(duration, HEADER_DURATION) < TE_DELTA * 2 { + step = DecoderStep::Reset; + } else { + header_count = 0; + step = DecoderStep::Reset; + } + } + DecoderStep::SaveDuration => { + if level { + if duration >= (TE_LONG + TE_DELTA) { + step = DecoderStep::Reset; + if decode_count_bit >= MIN_COUNT_BIT && decode_count_bit <= MIN_COUNT_BIT + 2 { + return Some(decode_data); + } + decode_data = 0; + decode_count_bit = 0; + header_count = 0; + } else { + te_last = duration; + step = DecoderStep::CheckDuration; + } + } else { + step = DecoderStep::Reset; + } + } + DecoderStep::CheckDuration => { + if !level { + if duration_diff!(te_last, TE_SHORT) < TE_DELTA + && duration_diff!(duration, TE_SHORT) < TE_DELTA + { + if decode_count_bit < MIN_COUNT_BIT { + decode_data = (decode_data << 1) | 0; + decode_count_bit += 1; + } else { + decode_count_bit += 1; + } + step = DecoderStep::SaveDuration; + } else if duration_diff!(te_last, TE_LONG) < TE_DELTA + && duration_diff!(duration, TE_LONG) < TE_DELTA + { + if decode_count_bit < MIN_COUNT_BIT { + decode_data = (decode_data << 1) | 1; + decode_count_bit += 1; + } else { + decode_count_bit += 1; + } + step = DecoderStep::SaveDuration; + } else { + step = DecoderStep::Reset; + } + } else { + step = DecoderStep::Reset; + } + } + } + } + None +} + impl ProtocolDecoder for StarLineDecoder { fn name(&self) -> &'static str { "Star Line"