Skip to main content

keyhog_scanner/decode/
base64.rs

1use super::limits::{
2    MAX_BASE64_INPUT_LEN, MAX_Z85_INPUT_LEN, MIN_BASE64_CANDIDATE_LEN, MIN_Z85_CANDIDATE_LEN,
3};
4use super::pipeline::{
5    push_batched_decoded_replacements, push_decoded_text_chunk_spliced_at,
6    with_extracted_value_spans, ExtractedValue,
7};
8use super::{DecodeAdmissionSketch, Decoder, EncodedString};
9use keyhog_core::Chunk;
10
11pub(super) struct Base64Decoder;
12
13impl Decoder for Base64Decoder {
14    fn name(&self) -> &'static str {
15        "base64"
16    }
17
18    fn admission_sketch(&self, chunk: &Chunk) -> DecodeAdmissionSketch {
19        base64_admission_sketch(&chunk.data)
20    }
21
22    fn decode_chunk(&self, chunk: &Chunk) -> Vec<Chunk> {
23        let mut replacements = Vec::new();
24        visit_classified_base64_string_spans(
25            &chunk.data,
26            MIN_BASE64_CANDIDATE_LEN,
27            |b64_match, variant| {
28                let Ok(decoded) = base64_decode_with_variant(&b64_match.value, variant) else {
29                    // LAW10: failed trial decode is recall-preserving; the original candidate-bearing chunk stays scanned unchanged.
30                    return;
31                };
32                // Compressed payloads must inflate before the UTF-8 gate.
33                let text = crate::decode::inflate::try_inflate_to_text(&decoded)
34                    .or_else(|| String::from_utf8(decoded).ok());
35                // LAW10: non-UTF-8 output is not source text; the encoded span
36                // remains scanned unchanged.
37                if let Some(text) = text {
38                    let (start, end) = b64_match.span();
39                    replacements.push((start, end, text));
40                }
41            },
42        );
43        push_batched_decoded_replacements(chunk, replacements, self.name())
44    }
45}
46
47pub(super) struct Z85Decoder;
48
49impl Decoder for Z85Decoder {
50    fn name(&self) -> &'static str {
51        "z85"
52    }
53
54    fn admission_sketch(&self, chunk: &Chunk) -> DecodeAdmissionSketch {
55        z85_admission_sketch(&chunk.data)
56    }
57
58    fn decode_chunk(&self, chunk: &Chunk) -> Vec<Chunk> {
59        let mut decoded_chunks = Vec::new();
60        visit_z85_string_spans(&chunk.data, MIN_Z85_CANDIDATE_LEN, |z_match, value| {
61            if let Ok(decoded) = z85_decode(value.as_ref()) {
62                // LAW10: failed trial decode means this span is not valid z85; recall-preserving (the original chunk stays scanned unchanged).
63                if let Ok(text) = String::from_utf8(decoded) {
64                    // LAW10: non-UTF8 decoded bytes are not source text; recall-preserving (the original encoded text stays scanned unchanged).
65                    push_decoded_text_chunk_spliced_at(
66                        &mut decoded_chunks,
67                        chunk,
68                        Some(z_match.span()),
69                        value.as_ref(),
70                        text.trim_end_matches('\0').to_string(),
71                        self.name(),
72                    );
73                }
74            }
75        });
76        decoded_chunks
77    }
78}
79
80#[derive(Clone, Copy)]
81enum Base64Variant {
82    Standard,
83    StandardNoPad,
84    UrlSafe,
85    UrlSafeNoPad,
86}
87
88#[derive(Clone, Copy)]
89pub(crate) struct StandardBase64Shape {
90    pub(crate) has_padding: bool,
91    pub(crate) length_multiple_of_four: bool,
92    pub(crate) has_plus: bool,
93    pub(crate) has_slash: bool,
94    pub(crate) distinct_alnum: u32,
95}
96
97/// Whether a byte can appear in a standard or URL-safe base64 string: ASCII
98/// alphanumeric or one of `+ / = - _`.
99pub fn is_base64_candidate_byte(byte: u8) -> bool {
100    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'-' | b'_')
101}
102
103pub(crate) fn is_standard_base64_byte(byte: u8) -> bool {
104    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
105}
106
107/// `true` iff `value` contains an `=` that is NOT valid base64 padding.
108///
109/// In base64 the only legal `=` is trailing padding, of which there are at most
110/// two. So a non-padding `=` is either a third (or later) trailing `=`, or any
111/// `=` that appears before the trailing padding run, both signal that the `=`
112/// is an assignment / key-value separator rather than base64 padding. This is
113/// the discriminator the isolated-bare entropy path uses to tell an opaque
114/// base64 token (`AbC123…==`, kept) from an embedded `key=value` fragment
115/// (rejected). `=` is single-byte ASCII, so the prefix slice is always on a char
116/// boundary regardless of any multibyte content before the padding run.
117pub(crate) fn contains_non_padding_equals(value: &str) -> bool {
118    let padding = value.bytes().rev().take_while(|&b| b == b'=').count();
119    padding > 2 || value[..value.len() - padding].contains('=')
120}
121
122pub(crate) fn standard_base64_shape(candidate: &str) -> Option<StandardBase64Shape> {
123    let facts = scan_base64_candidate(candidate)?;
124    let has_urlsafe = facts.has_urlsafe;
125    if facts.has_standard && has_urlsafe {
126        return None;
127    }
128    let remainder = candidate.len() % 4;
129    if has_urlsafe || (facts.padded && remainder != 0) || (!facts.padded && remainder == 1) {
130        return None;
131    }
132
133    Some(StandardBase64Shape {
134        has_padding: facts.padded,
135        length_multiple_of_four: candidate.len().is_multiple_of(4),
136        has_plus: facts.has_plus,
137        has_slash: facts.has_slash,
138        distinct_alnum: facts.distinct_alnum,
139    })
140}
141
142/// Find every base64/base64url substring of at least `min_length` bytes in
143/// `text`, returned as decodable [`EncodedString`] spans.
144pub fn find_base64_strings(text: &str, min_length: usize) -> Vec<EncodedString> {
145    find_base64_string_spans(text, min_length)
146        .into_iter()
147        .map(|candidate| EncodedString {
148            value: candidate.value,
149        })
150        .collect()
151}
152
153fn find_base64_string_spans(text: &str, min_length: usize) -> Vec<ExtractedValue> {
154    let mut results = Vec::new();
155
156    visit_classified_base64_string_spans(text, min_length, |candidate, _variant| {
157        results.push(candidate.clone());
158    });
159    results
160}
161
162fn visit_classified_base64_string_spans(
163    text: &str,
164    min_length: usize,
165    mut visit: impl FnMut(&ExtractedValue, Base64Variant),
166) {
167    with_extracted_value_spans(text, |candidates| {
168        for candidate in candidates {
169            if candidate.value.len() < min_length
170                || !candidate.value.bytes().all(is_base64_candidate_byte)
171            {
172                continue;
173            }
174            if let Some(variant) = classify_base64(&candidate.value) {
175                visit(candidate, variant);
176            }
177        }
178    });
179}
180
181fn base64_admission_sketch(text: &str) -> DecodeAdmissionSketch {
182    let mut count = 0usize;
183    let mut bytes = 0usize;
184    let mut compressed_count = 0usize;
185    let mut compressed_bytes = 0usize;
186    visit_classified_base64_string_spans(text, MIN_BASE64_CANDIDATE_LEN, |candidate, variant| {
187        count = count.saturating_add(1);
188        bytes = bytes.saturating_add(candidate.value.len());
189        let prefix_len = candidate.value.len().min(4);
190        if prefix_len == 4
191            && base64_decode_with_variant(&candidate.value[..prefix_len], variant)
192                .is_ok_and(|decoded| crate::decode::inflate::has_container_magic(&decoded))
193        {
194            compressed_count = compressed_count.saturating_add(1);
195            compressed_bytes = compressed_bytes.saturating_add(candidate.value.len());
196        }
197    });
198    if count == 0 {
199        DecodeAdmissionSketch::NONE
200    } else {
201        let mut sketch =
202            DecodeAdmissionSketch::possible(DecodeAdmissionSketch::BASE64, count, bytes);
203        if compressed_count > 0 {
204            sketch.merge(DecodeAdmissionSketch::possible(
205                DecodeAdmissionSketch::COMPRESSED_CONTAINER,
206                compressed_count,
207                compressed_bytes,
208            ));
209        }
210        sketch
211    }
212}
213
214fn classify_base64(candidate: &str) -> Option<Base64Variant> {
215    let facts = scan_base64_candidate(candidate)?;
216    let has_standard = facts.has_standard;
217    let has_urlsafe = facts.has_urlsafe;
218    if has_standard && has_urlsafe {
219        return None;
220    }
221
222    match (has_urlsafe, facts.padded, candidate.len() % 4) {
223        (_, true, 0) => Some(if has_urlsafe {
224            Base64Variant::UrlSafe
225        } else {
226            Base64Variant::Standard
227        }),
228        (_, true, _) => None,
229        (_, false, 1) => None,
230        (true, false, _) => Some(Base64Variant::UrlSafeNoPad),
231        (false, false, 0) => Some(Base64Variant::Standard),
232        (false, false, _) => Some(Base64Variant::StandardNoPad),
233    }
234}
235
236#[derive(Clone, Copy)]
237struct Base64CandidateFacts {
238    has_standard: bool,
239    has_urlsafe: bool,
240    padded: bool,
241    has_plus: bool,
242    has_slash: bool,
243    distinct_alnum: u32,
244}
245
246fn scan_base64_candidate(candidate: &str) -> Option<Base64CandidateFacts> {
247    let mut facts = Base64CandidateFacts {
248        has_standard: false,
249        has_urlsafe: false,
250        padded: false,
251        has_plus: false,
252        has_slash: false,
253        distinct_alnum: 0,
254    };
255    let mut seen_alnum = [false; 256];
256    let mut padding_len = 0usize;
257    for (index, byte) in candidate.bytes().enumerate() {
258        match byte {
259            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' if !facts.padded => {
260                if !seen_alnum[byte as usize] {
261                    seen_alnum[byte as usize] = true;
262                    facts.distinct_alnum += 1;
263                }
264            }
265            b'+' if !facts.padded => {
266                facts.has_standard = true;
267                facts.has_plus = true;
268            }
269            b'/' if !facts.padded => {
270                facts.has_standard = true;
271                facts.has_slash = true;
272            }
273            b'-' | b'_' if !facts.padded => facts.has_urlsafe = true,
274            b'=' => {
275                if index == 0 {
276                    return None;
277                }
278                facts.padded = true;
279                padding_len += 1;
280                if padding_len > 2 {
281                    return None;
282                }
283            }
284            _ if facts.padded => return None,
285            _ => return None,
286        }
287    }
288    Some(facts)
289}
290
291/// Decode a standard or URL-safe base64 string, bounded to
292/// `MAX_BASE64_INPUT_LEN` bytes for DoS safety. `Err(())` on invalid or
293/// over-length input.
294#[allow(clippy::result_unit_err)]
295pub fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
296    if input.len() > MAX_BASE64_INPUT_LEN {
297        return Err(());
298    }
299
300    let variant = classify_base64(input).ok_or(())?;
301    base64_decode_with_variant(input, variant)
302}
303
304#[allow(clippy::result_unit_err)]
305fn base64_decode_with_variant(input: &str, variant: Base64Variant) -> Result<Vec<u8>, ()> {
306    match variant {
307        Base64Variant::Standard => base64_simd::STANDARD.decode_to_vec(input.as_bytes()),
308        Base64Variant::StandardNoPad => {
309            base64_simd::STANDARD_NO_PAD.decode_to_vec(input.as_bytes())
310        }
311        Base64Variant::UrlSafe => base64_simd::URL_SAFE.decode_to_vec(input.as_bytes()),
312        Base64Variant::UrlSafeNoPad => base64_simd::URL_SAFE_NO_PAD.decode_to_vec(input.as_bytes()),
313    }
314    .map_err(|_| ())
315}
316
317fn visit_z85_string_spans(
318    text: &str,
319    min_length: usize,
320    mut visit: impl FnMut(&ExtractedValue, std::borrow::Cow<'_, str>),
321) {
322    let is_z85_char =
323        |ch: char| ch.is_ascii_alphanumeric() || ".-:+=^!/*?&<>()[]{}@%$#".contains(ch);
324    with_extracted_value_spans(text, |candidates| {
325        for candidate in candidates {
326            let value = if candidate.value.chars().any(char::is_whitespace) {
327                std::borrow::Cow::Owned(
328                    candidate
329                        .value
330                        .chars()
331                        .filter(|ch| !ch.is_whitespace())
332                        .collect(),
333                )
334            } else {
335                std::borrow::Cow::Borrowed(candidate.value.as_str())
336            };
337            if value.len() >= min_length
338                && value.len().is_multiple_of(5)
339                && value.chars().all(is_z85_char)
340            {
341                visit(candidate, value);
342            }
343        }
344    });
345}
346
347fn z85_admission_sketch(text: &str) -> DecodeAdmissionSketch {
348    let mut count = 0usize;
349    let mut bytes = 0usize;
350    visit_z85_string_spans(text, MIN_Z85_CANDIDATE_LEN, |_, value| {
351        count = count.saturating_add(1);
352        bytes = bytes.saturating_add(value.len());
353    });
354    if count == 0 {
355        DecodeAdmissionSketch::NONE
356    } else {
357        DecodeAdmissionSketch::possible(DecodeAdmissionSketch::Z85, count, bytes)
358    }
359}
360
361/// Decode a Z85-encoded string (length must be a multiple of 5), bounded to
362/// `MAX_Z85_INPUT_LEN` bytes for DoS safety. `Err(())` on invalid input.
363#[allow(clippy::result_unit_err)]
364pub fn z85_decode(input: &str) -> Result<Vec<u8>, ()> {
365    if !input.len().is_multiple_of(5) || input.len() > MAX_Z85_INPUT_LEN {
366        return Err(());
367    }
368    let mut decoded = Vec::with_capacity(input.len() * 4 / 5);
369    let bytes = input.as_bytes();
370    for chunk in bytes.chunks_exact(5) {
371        let mut value = 0u64;
372        for &byte in chunk {
373            value = value * 85 + z85_val(byte)? as u64;
374        }
375        if value > u32::MAX as u64 {
376            return Err(());
377        }
378        let value = value as u32;
379        decoded.push((value >> 24) as u8);
380        decoded.push((value >> 16) as u8);
381        decoded.push((value >> 8) as u8);
382        decoded.push(value as u8);
383    }
384    Ok(decoded)
385}
386
387fn z85_val(byte: u8) -> Result<u8, ()> {
388    match byte {
389        b'0'..=b'9' => Ok(byte - b'0'),
390        b'a'..=b'f' => Ok(byte - b'a' + 10),
391        b'g'..=b'z' => Ok(byte - b'g' + 16),
392        b'A'..=b'Z' => Ok(byte - b'A' + 36),
393        b'.' => Ok(62),
394        b'-' => Ok(63),
395        b':' => Ok(64),
396        b'+' => Ok(65),
397        b'=' => Ok(66),
398        b'^' => Ok(67),
399        b'!' => Ok(68),
400        b'/' => Ok(69),
401        b'*' => Ok(70),
402        b'?' => Ok(71),
403        b'&' => Ok(72),
404        b'<' => Ok(73),
405        b'>' => Ok(74),
406        b'(' => Ok(75),
407        b')' => Ok(76),
408        b'[' => Ok(77),
409        b']' => Ok(78),
410        b'{' => Ok(79),
411        b'}' => Ok(80),
412        b'@' => Ok(81),
413        b'%' => Ok(82),
414        b'$' => Ok(83),
415        b'#' => Ok(84),
416        _ => Err(()),
417    }
418}