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}