Skip to main content

keyhog_scanner/decode/
hex.rs

1use super::limits::{MAX_HEX_INPUT_LEN, MIN_HEX_CANDIDATE_LEN};
2use super::pipeline::{
3    push_batched_decoded_replacements, with_extracted_value_spans, ExtractedValue,
4};
5use super::{DecodeAdmissionSketch, Decoder, EncodedString};
6use keyhog_core::Chunk;
7
8pub(super) struct HexDecoder;
9
10impl Decoder for HexDecoder {
11    fn name(&self) -> &'static str {
12        "hex"
13    }
14
15    fn admission_sketch(&self, chunk: &Chunk) -> DecodeAdmissionSketch {
16        with_extracted_value_spans(&chunk.data, |candidates| {
17            let mut count = 0usize;
18            let mut bytes = 0usize;
19            for candidate in candidates
20                .iter()
21                .filter(|candidate| is_hex_candidate(candidate, MIN_HEX_CANDIDATE_LEN))
22            {
23                count = count.saturating_add(1);
24                bytes = bytes.saturating_add(candidate.value.len());
25            }
26            if count == 0 {
27                DecodeAdmissionSketch::NONE
28            } else {
29                DecodeAdmissionSketch::possible(DecodeAdmissionSketch::HEX, count, bytes)
30            }
31        })
32    }
33
34    fn decode_chunk(&self, chunk: &Chunk) -> Vec<Chunk> {
35        with_extracted_value_spans(&chunk.data, |candidates| {
36            let replacements = candidates
37                .iter()
38                .filter(|candidate| is_hex_candidate(candidate, MIN_HEX_CANDIDATE_LEN))
39                .filter_map(|candidate| {
40                    let decoded = hex_decode(&candidate.value).ok()?;
41                    // LAW10: binary output is not source text; the encoded span
42                    // remains scanned unchanged.
43                    let text = String::from_utf8(decoded).ok()?;
44                    let (start, end) = candidate.span();
45                    Some((start, end, text))
46                })
47                .collect();
48            push_batched_decoded_replacements(chunk, replacements, self.name())
49        })
50    }
51}
52
53/// Find every hex substring of at least `min_length` bytes in `text`, returned
54/// as decodable [`EncodedString`] spans.
55pub fn find_hex_strings(text: &str, min_length: usize) -> Vec<EncodedString> {
56    find_hex_string_spans(text, min_length)
57        .into_iter()
58        .map(|candidate| EncodedString {
59            value: candidate.value,
60        })
61        .collect()
62}
63
64fn find_hex_string_spans(text: &str, min_length: usize) -> Vec<ExtractedValue> {
65    let mut results = Vec::new();
66    with_extracted_value_spans(text, |candidates| {
67        for candidate in candidates {
68            if is_hex_candidate(candidate, min_length) {
69                results.push(candidate.clone());
70            }
71        }
72    });
73    results
74}
75
76fn is_hex_candidate(candidate: &ExtractedValue, min_length: usize) -> bool {
77    // Hex literals in firmware dumps and config files commonly use `_`
78    // every 2/4/8 chars for readability (`A1_B2_C3_...`). Tolerate those
79    // when validating - audit class #5 (release-2026-04-26) noted the
80    // previous all-hex check missed this evasion entirely. Validate over
81    // the raw bytes (hex digits and `_` are all single-byte ASCII, so the
82    // non-`_` byte count equals the decoded-input char count) instead of
83    // allocating a throwaway cleaned `String` per candidate on the hot
84    // decode path; `hex_decode` does the final underscore stripping.
85    let hex_len = candidate.value.bytes().filter(|byte| *byte != b'_').count();
86    hex_len >= min_length
87        && hex_len.is_multiple_of(2)
88        && candidate
89            .value
90            .bytes()
91            .all(|byte| byte == b'_' || byte.is_ascii_hexdigit())
92}
93
94/// Decode a hex string (optionally `_`-separated), bounded to
95/// `MAX_HEX_INPUT_LEN` bytes for DoS safety. `Err(())` on odd length or
96/// non-hex input.
97#[allow(clippy::result_unit_err)]
98pub fn hex_decode(input: &str) -> Result<Vec<u8>, ()> {
99    if !input.as_bytes().contains(&b'_') {
100        if !input.len().is_multiple_of(2) || input.len() > MAX_HEX_INPUT_LEN {
101            return Err(());
102        }
103        return hex_simd::decode_to_vec(input.as_bytes()).map_err(|_| ());
104    }
105
106    let cleaned: String = input.chars().filter(|c| *c != '_').collect();
107    if !cleaned.len().is_multiple_of(2) || cleaned.len() > MAX_HEX_INPUT_LEN {
108        return Err(());
109    }
110    hex_simd::decode_to_vec(cleaned.as_bytes()).map_err(|_| ())
111}