Skip to content

Commit 3356889

Browse files
authored
Release v.10 (#6)
* build to invoke as bitkit * Add skip option to symbol chunking functions that way you can skip prefixes that muddy up the actual symbols * Fill in README and get ready to publish --------- Co-authored-by: eay <eay>
1 parent 3052c04 commit 3356889

4 files changed

Lines changed: 193 additions & 30 deletions

File tree

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ name = "rf-bitkit"
33
version = "0.1.0"
44
edition = "2024"
55
license = "MIT OR Apache-2.0"
6+
description = "A collection of analysis functions for reverse engineering RF protocols"
7+
repository = "https://github.com/emclrk/rf-bitkit"
8+
readme = "README.md"
9+
keywords = ["rf", "sdr", "protocol", "bitstream", "reverse-engineering"]
10+
categories = ["command-line-utilities", "algorithms"]
11+
12+
[[bin]]
13+
name = "bitkit"
14+
path = "src/main.rs"
615

716
[dependencies]
817
clap = { version = "4.6.1", features = ["derive"] }

README.md

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,110 @@
1-
A Rust library with utilities for RF protocol reverse engineering
1+
# rf-bitkit
2+
3+
A Rust library and CLI tool for reverse engineering RF protocols.
4+
5+
I wrote this after finding URH's protocol analysis tab a little clunky and limited. rf-bitkit provides a collection of analysis functions for working with demodulated bitstreams - finding fixed and varying fields, identifying symbol alphabets, measuring entropy, and correlating captures. It accepts plain text files (one bitstream per line) or XML exports directly from URH.
6+
7+
## Installation
8+
9+
```
10+
cargo install rf-bitkit
11+
```
12+
13+
Or build from source:
14+
15+
```
16+
git clone https://github.com/emclrk/rf-bitkit
17+
cd rf-bitkit
18+
cargo install --path .
19+
```
20+
21+
## Quick Example
22+
23+
```
24+
$ bitkit infer my_captures.txt
25+
26+
Inferred Structure:
27+
Fixed(3) | Varying(2) | Fixed(2) | Varying(2) | Fixed(2) | Varying(2) | Fixed(1)
28+
```
29+
30+
## CLI Tool
31+
32+
The `bitkit` binary provides the following subcommands. All commands accept either a `.txt` file (one bitstream per line) or a URH `.xml` export.
33+
34+
### `info`
35+
Show basic stats and a hex representation of each bitstream.
36+
```
37+
bitkit info <file> [-s <symlen>] [--skip <n>]
38+
```
39+
40+
### `infer`
41+
Compute positionwise entropy and infer the protocol field structure. This is the key command — given a series of bitstreams, it identifies which bit positions are fixed across all captures and which vary.
42+
```
43+
bitkit infer <file> [--eps <tolerance>]
44+
```
45+
46+
### `prefix`
47+
Find the common prefix across all bitstreams. A long common prefix is a preamble or sync word candidate.
48+
```
49+
bitkit prefix <file>
50+
```
51+
52+
### `sweep`
53+
Show normalized entropy at each symbol length to help identify the correct symbol size. Look for a sudden drop in entropy — that's a signal that the chunking is aligning with the actual symbol boundaries.
54+
```
55+
bitkit sweep <file> [--max-symlen <n>] [--skip <n>]
56+
```
57+
58+
### `alphabet`
59+
Show the symbol alphabet and frequency counts across all bitstreams at a given symbol length.
60+
```
61+
bitkit alphabet <file> [-s <symlen>] [--skip <n>]
62+
```
63+
64+
### `substrings`
65+
Show the most frequently occurring substrings of a given length. Useful for finding sync word candidates.
66+
```
67+
bitkit substrings <file> [-l <len>] [-t <top>] [--skip <n>]
68+
```
69+
70+
### `correlate`
71+
Cross-correlate two bitstreams from a file by index. Useful for identifying misalignment between captures.
72+
```
73+
bitkit correlate <file> -a <index> -b <index> [-t <top>]
74+
```
75+
76+
## Library
77+
78+
rf-bitkit is also a Rust library. Add it to your `Cargo.toml`:
79+
80+
```toml
81+
[dependencies]
82+
rf-bitkit = "0.1.0"
83+
```
84+
85+
Key functions:
86+
87+
- `from_txt` / `from_urh` — load bitstreams from a text file or URH XML export
88+
- `positionwise_entropy` — compute per-bit-position entropy across a set of bitstreams
89+
- `ProtocolStructure::infer_structure` — infer fixed/varying field layout from entropy values
90+
- `get_alphabet_counts` — count symbol occurrences at a given symbol length
91+
- `get_substr_counts` — count substring occurrences across all bitstreams
92+
- `get_cross_correlation` — cross-correlate two bitstreams across all offsets
93+
- `get_hamming_dist` — compute Hamming distance between two bitstreams
94+
- `find_common_prefix` — find the longest prefix shared by all bitstreams
95+
96+
## Status and Roadmap
97+
98+
This is an early release. Current planned work includes:
99+
100+
- Sync word detection in the presence of misaligned packets (cross-correlation is implemented; evaluating Smith-Waterman for handling bit insertions/deletions)
101+
- CRC/checksum detection
102+
- User-defined tags for labeling bitstream families (e.g. Frame A vs Frame B)
103+
- JSON/TOML config file support for scripting multi-step analyses
104+
- Visualizations
105+
106+
Longer term, I'd like to build a DSP layer and work toward a standalone URH replacement in Rust.
107+
108+
## License
109+
110+
Licensed under either of [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE) at your option.

src/lib.rs

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ impl Bitstream {
9797
.collect::<String>(),
9898
})
9999
}
100+
pub fn skip(&self, nbits: usize) -> Self {
101+
Self::new(self.bits[nbits..].to_string()).unwrap()
102+
}
100103
/// Number of bits in the Bitstream
101104
pub fn len(&self) -> usize {
102105
self.bits.len()
@@ -315,30 +318,56 @@ pub fn positionwise_entropy(bitstrs: &[Bitstream]) -> Vec<f32> {
315318
}
316319
/// Find the symbol alphabet (the full set of symbols that actually occur)
317320
/// across multiple Bitstreams.
318-
pub fn get_alphabet(bitstrs: &[Bitstream], symlen: usize) -> HashSet<String> {
321+
pub fn get_alphabet(bitstrs: &[Bitstream], symlen: usize, skip_bits: usize) -> HashSet<String> {
319322
let mut counts: HashMap<String, u32> = HashMap::new();
320323
for bitstr in bitstrs {
321-
bitstr.accumulate_sym_counts(symlen, &mut counts);
324+
if skip_bits > 0 {
325+
bitstr
326+
.skip(skip_bits)
327+
.accumulate_sym_counts(symlen, &mut counts);
328+
} else {
329+
bitstr.accumulate_sym_counts(symlen, &mut counts);
330+
}
322331
}
323332
counts.into_keys().collect::<HashSet<String>>()
324333
}
325334

326335
/// Return frequency counts for the symbol alphabet (the full set of symbols that actually occur)
327336
/// across multiple Bitstreams.
328-
pub fn get_alphabet_counts(bitstrs: &[Bitstream], symlen: usize) -> HashMap<String, u32> {
337+
pub fn get_alphabet_counts(
338+
bitstrs: &[Bitstream],
339+
symlen: usize,
340+
skip_bits: usize,
341+
) -> HashMap<String, u32> {
329342
let mut counts: HashMap<String, u32> = HashMap::new();
330343
for bitstr in bitstrs {
331-
bitstr.accumulate_sym_counts(symlen, &mut counts);
344+
if skip_bits > 0 {
345+
bitstr
346+
.skip(skip_bits)
347+
.accumulate_sym_counts(symlen, &mut counts);
348+
} else {
349+
bitstr.accumulate_sym_counts(symlen, &mut counts);
350+
}
332351
}
333352
counts
334353
}
335354

336355
/// Return frequency counts for all possible substrings of length `strlen` across multiple
337356
/// Bitstreams.
338-
pub fn get_substr_counts(bitstrs: &[Bitstream], strlen: usize) -> HashMap<String, u32> {
357+
pub fn get_substr_counts(
358+
bitstrs: &[Bitstream],
359+
strlen: usize,
360+
skip_bits: usize,
361+
) -> HashMap<String, u32> {
339362
let mut counts: HashMap<String, u32> = HashMap::new();
340363
for bitstr in bitstrs {
341-
bitstr.accumulate_substr_counts(strlen, &mut counts);
364+
if skip_bits > 0 {
365+
bitstr
366+
.skip(skip_bits)
367+
.accumulate_substr_counts(strlen, &mut counts);
368+
} else {
369+
bitstr.accumulate_substr_counts(strlen, &mut counts);
370+
}
342371
}
343372
counts
344373
}
@@ -452,7 +481,7 @@ mod tests {
452481
.entry("100".to_string())
453482
.and_modify(|ct| *ct += 1);
454483
hash_result.insert("000".to_string(), 2);
455-
assert_eq!(hash_result, get_substr_counts(&vec![bs, bs2], 3));
484+
assert_eq!(hash_result, get_substr_counts(&vec![bs, bs2], 3, 0));
456485
}
457486
#[test]
458487
fn test_bit_pcts() {
@@ -471,21 +500,21 @@ mod tests {
471500
.iter()
472501
.map(|s| s.to_string())
473502
.collect::<HashSet<String>>(),
474-
get_alphabet(&vec![bs_1.clone()], 1)
503+
get_alphabet(&vec![bs_1.clone()], 1, 0)
475504
);
476505
assert_eq!(
477506
["110", "101", "011"]
478507
.iter()
479508
.map(|s| s.to_string())
480509
.collect::<HashSet<String>>(),
481-
get_alphabet(&vec![bs_1.clone()], 3)
510+
get_alphabet(&vec![bs_1.clone()], 3, 0)
482511
);
483512
assert_eq!(
484513
["1101", "0110", "1011", "0101", "0011"]
485514
.iter()
486515
.map(|s| s.to_string())
487516
.collect::<HashSet<String>>(),
488-
get_alphabet(&vec![bs_1.clone(), bs_2.clone()], 4)
517+
get_alphabet(&vec![bs_1.clone(), bs_2.clone()], 4, 0)
489518
);
490519
let hash_result = HashMap::from([
491520
(String::from("1101"), 1),
@@ -494,7 +523,7 @@ mod tests {
494523
(String::from("0101"), 1),
495524
(String::from("0011"), 1),
496525
]);
497-
assert_eq!(hash_result, get_alphabet_counts(&vec![bs_1, bs_2], 4));
526+
assert_eq!(hash_result, get_alphabet_counts(&vec![bs_1, bs_2], 4, 0));
498527
}
499528
#[test]
500529
fn test_get_total_entropy() {

src/main.rs

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ enum Commands {
2020
file: String,
2121
#[arg(short, long, default_value_t = 4)]
2222
symlen: usize,
23+
#[arg(long, default_value_t = 0)]
24+
skip: usize,
2325
},
2426
/// Find the common prefix across all bitstreams (preamble candidate)
2527
Prefix { file: String },
@@ -35,12 +37,16 @@ enum Commands {
3537
file: String,
3638
#[arg(long, default_value_t = 8)]
3739
max_symlen: usize,
40+
#[arg(long, default_value_t = 0)]
41+
skip: usize,
3842
},
3943
/// Show symbol alphabet and frequency counts across all bitstreams
4044
Alphabet {
4145
file: String,
4246
#[arg(short, long, default_value_t = 1)]
4347
symlen: usize,
48+
#[arg(long, default_value_t = 0)]
49+
skip: usize,
4450
},
4551
/// Show the most frequent substrings of a given length
4652
Substrings {
@@ -51,6 +57,8 @@ enum Commands {
5157
/// Number of results to show
5258
#[arg(short, long, default_value_t = 10)]
5359
top: usize,
60+
#[arg(long, default_value_t = 0)]
61+
skip: usize,
5462
},
5563
/// Cross-correlate two bitstreams from a file by index
5664
Correlate {
@@ -62,7 +70,7 @@ enum Commands {
6270
#[arg(short)]
6371
b: usize,
6472
/// Number of top results to show
65-
#[arg(long, default_value_t = 10)]
73+
#[arg(short, long, default_value_t = 10)]
6674
top: usize,
6775
},
6876
}
@@ -92,7 +100,7 @@ fn main() {
92100

93101
fn run(cli: Cli) -> Result<(), BitkitError> {
94102
match cli.command {
95-
Commands::Info { file, symlen } => {
103+
Commands::Info { file, symlen, skip } => {
96104
let bitstrs = load_file(&file)?;
97105
let lengths: Vec<usize> = bitstrs.iter().map(|b| b.len()).collect();
98106
let min_len = lengths.iter().min().copied().unwrap_or(0);
@@ -104,7 +112,11 @@ fn run(cli: Cli) -> Result<(), BitkitError> {
104112
println!("Lengths: min={min_len}, max={max_len}, avg={avg_len:.1}");
105113
println!();
106114
for (i, bs) in bitstrs.iter().enumerate() {
107-
println!("[{i:3}] {} ({} bits)", bs.to_hex(symlen), bs.len());
115+
println!(
116+
"[{i:3}] {} ({} bits)",
117+
bs.skip(skip).to_hex(symlen),
118+
bs.len()
119+
);
108120
}
109121
}
110122

@@ -152,8 +164,13 @@ fn run(cli: Cli) -> Result<(), BitkitError> {
152164
}
153165
}
154166

155-
Commands::Sweep { file, max_symlen } => {
167+
Commands::Sweep {
168+
file,
169+
max_symlen,
170+
skip,
171+
} => {
156172
let bitstrs = load_file(&file)?;
173+
let skipped: Vec<Bitstream> = bitstrs.iter().map(|bs| bs.skip(skip)).collect();
157174
println!("=== Entropy Sweep: {file} ===");
158175
println!();
159176
println!(
@@ -163,28 +180,22 @@ fn run(cli: Cli) -> Result<(), BitkitError> {
163180

164181
let mut results = Vec::new();
165182
for symlen in 1..=max_symlen {
166-
let avg = bitstrs
183+
let avg = skipped
167184
.iter()
168185
.map(|bs| bs.get_normed_entropy(symlen))
169186
.sum::<f32>()
170-
/ bitstrs.len() as f32;
171-
let unique = get_alphabet_counts(&bitstrs, symlen).len();
187+
/ skipped.len() as f32;
188+
let unique = get_alphabet_counts(&bitstrs, symlen, skip).len();
172189
results.push((symlen, avg, unique));
173190
}
174-
let min_entropy = results.iter().map(|(_, e, _)| *e).fold(f32::MAX, f32::min);
175191
for (symlen, entropy, unique) in &results {
176-
let marker = if (*entropy - min_entropy).abs() < 1e-6 {
177-
" <-- minimum"
178-
} else {
179-
""
180-
};
181-
println!("{:>8} {:>14.4} {:>12}{marker}", symlen, entropy, unique);
192+
println!("{:>8} {:>14.4} {:>12}", symlen, entropy, unique);
182193
}
183194
}
184195

185-
Commands::Alphabet { file, symlen } => {
196+
Commands::Alphabet { file, symlen, skip } => {
186197
let bitstrs = load_file(&file)?;
187-
let counts = get_alphabet_counts(&bitstrs, symlen);
198+
let counts = get_alphabet_counts(&bitstrs, symlen, skip);
188199
let mut sorted: Vec<_> = counts.iter().collect();
189200
sorted.sort_by(|a, b| b.1.cmp(a.1));
190201

@@ -196,9 +207,14 @@ fn run(cli: Cli) -> Result<(), BitkitError> {
196207
}
197208
}
198209

199-
Commands::Substrings { file, len, top } => {
210+
Commands::Substrings {
211+
file,
212+
len,
213+
top,
214+
skip,
215+
} => {
200216
let bitstrs = load_file(&file)?;
201-
let counts = get_substr_counts(&bitstrs, len);
217+
let counts = get_substr_counts(&bitstrs, len, skip);
202218
let mut sorted: Vec<_> = counts.iter().collect();
203219
sorted.sort_by(|a, b| b.1.cmp(a.1));
204220

0 commit comments

Comments
 (0)