Added a generic keeloq decode against manufacure keys

This commit is contained in:
leviathan
2026-02-15 18:53:25 -05:00
parent 67c9a1bb98
commit 9961563993
6 changed files with 417 additions and 13 deletions
+3
View File
@@ -212,6 +212,7 @@ impl Capture {
"Subaru" => ModulationType::Pwm, "Subaru" => ModulationType::Pwm,
"Suzuki" => ModulationType::Pwm, "Suzuki" => ModulationType::Pwm,
"Star Line" => ModulationType::Pwm, "Star Line" => ModulationType::Pwm,
p if p.starts_with("Keeloq (") => ModulationType::Pwm,
"Scher-Khan" => ModulationType::Pwm, "Scher-Khan" => ModulationType::Pwm,
// Unknown // Unknown
_ => ModulationType::Unknown, _ => ModulationType::Unknown,
@@ -237,6 +238,7 @@ impl Capture {
"Subaru" => RfModulation::AM, "Subaru" => RfModulation::AM,
"Suzuki" => RfModulation::AM, "Suzuki" => RfModulation::AM,
"Star Line" => RfModulation::AM, "Star Line" => RfModulation::AM,
p if p.starts_with("Keeloq (") => RfModulation::AM,
// Both AM and FM (Kia V3/V4) // Both AM and FM (Kia V3/V4)
p if p.starts_with("Kia V3") || p.starts_with("Kia V4") => RfModulation::Both, p if p.starts_with("Kia V3") || p.starts_with("Kia V4") => RfModulation::Both,
_ => RfModulation::Unknown, _ => RfModulation::Unknown,
@@ -248,6 +250,7 @@ impl Capture {
match self.protocol_name() { match self.protocol_name() {
p if p.starts_with("Kia V3") || p.starts_with("Kia V4") => "KeeLoq", p if p.starts_with("Kia V3") || p.starts_with("Kia V4") => "KeeLoq",
"Star Line" => "KeeLoq", "Star Line" => "KeeLoq",
p if p.starts_with("Keeloq (") => "KeeLoq",
"PSA" => "XTEA/XOR", "PSA" => "XTEA/XOR",
"VAG" => "AUT64/XTEA", "VAG" => "AUT64/XTEA",
"Scher-Khan" => "Magic Code", "Scher-Khan" => "Magic Code",
+41 -12
View File
@@ -5,6 +5,22 @@ mod embedded;
use std::convert::TryInto; 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 MAGIC: &[u8; 4] = b"KATK";
const VAG_TAG: &[u8; 4] = b"VAG "; const VAG_TAG: &[u8; 4] = b"VAG ";
const VAG_SIZE: usize = 64; const VAG_SIZE: usize = 64;
@@ -47,6 +63,30 @@ pub fn parse_blob(blob: &[u8]) -> Option<ParsedKeystore> {
}) })
} }
/// 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. /// Return the embedded keystore blob for loading.
pub fn embedded_blob() -> &'static [u8] { pub fn embedded_blob() -> &'static [u8] {
embedded::KEYSTORE_BLOB embedded::KEYSTORE_BLOB
@@ -66,20 +106,9 @@ mod tests {
#[test] #[test]
fn dump_keystore_keys_for_pandora_compare() { fn dump_keystore_keys_for_pandora_compare() {
let entries = embedded_entries_for_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):"); eprintln!("KAT embedded keystore keys (type, hex, name):");
for (i, (ty, key)) in entries.iter().enumerate() { 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); eprintln!(" type {} {} {}", ty, format!("{:016X}", key), name);
} }
} }
+180
View File
@@ -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
}
+96
View File
@@ -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 { impl ProtocolDecoder for KiaV3V4Decoder {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Kia V3/V4" "Kia V3/V4"
+6 -1
View File
@@ -11,6 +11,7 @@
mod common; mod common;
pub mod keeloq_common; pub mod keeloq_common;
mod keeloq_generic;
#[allow(dead_code)] #[allow(dead_code)]
pub mod aut64; pub mod aut64;
#[allow(dead_code)] #[allow(dead_code)]
@@ -117,7 +118,11 @@ impl ProtocolRegistry {
return Some(result); return Some(result);
} }
// Try inverted polarity (capture LOW = RF HIGH) // 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. /// ProtoPirate-style streaming decode: feed the whole stream, on each decode record and reset decoders, continue.
+91
View File
@@ -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<u64> {
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 { impl ProtocolDecoder for StarLineDecoder {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Star Line" "Star Line"