Skip to main content

keyhog_core/
encoding.rs

1//! Standard Base64 (RFC 4648) decode for wire formats and structured data.
2//!
3//! Scan-time variant base64 (URL-safe, unpadded) lives in `keyhog-scanner`.
4
5/// Maximum input length for [`decode_standard_base64`]. Matches the scanner's
6/// byte limit so credential serde and K8s secret parsing stay consistent.
7pub(crate) const MAX_STANDARD_BASE64_INPUT_BYTES: usize = 16 * 1024 * 1024;
8
9/// Encode bytes with the standard RFC 4648 alphabet and canonical `=` padding.
10pub(crate) fn encode_standard_base64(input: &[u8]) -> String {
11    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
12    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
13    for chunk in input.chunks(3) {
14        let b0 = chunk[0];
15        let b1 = match chunk.get(1) {
16            Some(byte) => *byte,
17            None => 0,
18        };
19        let b2 = match chunk.get(2) {
20            Some(byte) => *byte,
21            None => 0,
22        };
23        out.push(TABLE[(b0 >> 2) as usize] as char);
24        out.push(TABLE[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
25        if chunk.len() > 1 {
26            out.push(TABLE[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
27        } else {
28            out.push('=');
29        }
30        if chunk.len() > 2 {
31            out.push(TABLE[(b2 & 0x3F) as usize] as char);
32        } else {
33            out.push('=');
34        }
35    }
36    out
37}
38
39/// Decode standard-alphabet base64 (with optional `=` padding).
40pub fn decode_standard_base64(input: &str) -> Result<Vec<u8>, String> {
41    if input.len() > MAX_STANDARD_BASE64_INPUT_BYTES {
42        return Err(format!(
43            "base64 input exceeds {} bytes",
44            MAX_STANDARD_BASE64_INPUT_BYTES
45        ));
46    }
47    fn val(c: u8) -> Result<u8, String> {
48        match c {
49            b'A'..=b'Z' => Ok(c - b'A'),
50            b'a'..=b'z' => Ok(c - b'a' + 26),
51            b'0'..=b'9' => Ok(c - b'0' + 52),
52            b'+' => Ok(62),
53            b'/' => Ok(63),
54            _ => Err(format!("invalid base64 char: {c:#x}")),
55        }
56    }
57    let bytes = input.as_bytes();
58    // `=` is only legal as TRAILING padding in standard base64. The previous
59    // `take_while(|c| c != b'=')` silently TRUNCATED at the first `=`, so
60    // `"AB=CD"` decoded as `"AB"` and dropped `"CD"` with no error, corrupting
61    // historical tagged binary credential input. Split at the first `=`:
62    // everything before it is data, and everything FROM it onward must be
63    // padding-only (`=`). A non-`=` byte after an `=` is malformed; reject it
64    // loudly instead of swallowing the tail.
65    let first_pad = bytes.iter().position(|&c| c == b'=');
66    let stripped: &[u8] = match first_pad {
67        Some(idx) => {
68            if idx == 0 {
69                // Pad-only input (`"="`, `"=="`) carries zero data bytes: it is
70                // malformed base64, not an empty-string encoding. Reject loudly
71                // instead of returning Ok(vec![]) (silent-accept, Law 10).
72                return Err("invalid base64: padding '=' with no preceding data".to_string());
73            }
74            if bytes[idx..].iter().any(|&c| c != b'=') {
75                return Err(
76                    "invalid base64: data after padding '=' (padding may only appear at the end)"
77                        .to_string(),
78                );
79            }
80            // At most 2 padding chars are well-formed; more than 2, or padding
81            // that does not align the data to a 4-char-quad boundary, is
82            // malformed. `idx % 4` is the data length within the final quad:
83            // 0 => no quad in progress (only valid with zero padding),
84            // 1 => impossible to encode (1 base64 char carries <6 bits of a byte),
85            // 2 => one data byte, needs `==`,
86            // 3 => two data bytes, needs `=`.
87            let pad_len = bytes.len() - idx;
88            let rem = idx % 4;
89            let pad_ok = match rem {
90                2 => pad_len == 2 || pad_len == 1, // tolerate `QQ=`/`QQ==`
91                3 => pad_len == 1,
92                0 => pad_len <= 2, // trailing `=`/`==` after a whole quad ("QUJD==")
93                _ => false,        // rem == 1: no valid encoding produces a lone char
94            };
95            if !pad_ok {
96                return Err(format!(
97                    "invalid base64: {pad_len} padding char(s) do not align the {idx} data char(s) to a quad"
98                ));
99            }
100            &bytes[..idx]
101        }
102        None => bytes,
103    };
104    let mut out = Vec::with_capacity(stripped.len() * 3 / 4);
105    for chunk in stripped.chunks(4) {
106        let v0 = val(chunk[0])?;
107        let v1 = val(*chunk.get(1).ok_or_else(|| "truncated base64".to_string())?)?;
108        out.push((v0 << 2) | (v1 >> 4));
109        if let Some(&c2) = chunk.get(2) {
110            let v2 = val(c2)?;
111            out.push(((v1 & 0x0F) << 4) | (v2 >> 2));
112            if let Some(&c3) = chunk.get(3) {
113                let v3 = val(c3)?;
114                out.push(((v2 & 0x03) << 6) | v3);
115            }
116        }
117    }
118    Ok(out)
119}