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 return;
31 };
32 let text = crate::decode::inflate::try_inflate_to_text(&decoded)
34 .or_else(|| String::from_utf8(decoded).ok());
35 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 if let Ok(text) = String::from_utf8(decoded) {
64 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
97pub 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
107pub(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
142pub 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#[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#[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}