happy_cracking/crypto/
autodecode.rs1use anyhow::Result;
2use clap::Subcommand;
3use std::collections::{HashSet, VecDeque};
4
5use super::{base32, base58, base64, binary, hex, morse, url};
6
7const MAX_TREE_NODES: usize = 256;
8const MAX_OUTPUT_CHARS: usize = 1_000_000;
9
10#[derive(Subcommand)]
11pub enum AutoDecodeAction {
12 #[command(about = "Automatically detect and decode")]
13 Decode {
14 #[arg(help = "Input to decode")]
15 input: String,
16 #[arg(short, long, help = "Recursively decode", default_value = "false")]
17 recursive: bool,
18 #[arg(short, long, help = "Maximum recursion depth", default_value = "5")]
19 max_depth: usize,
20 #[arg(
21 long,
22 help = "Aggressive BFS decode tree with scoring and flag detection"
23 )]
24 aggressive: bool,
25 #[arg(
26 long,
27 default_value_t = 32,
28 help = "Max nodes to expand in aggressive mode"
29 )]
30 max_nodes: usize,
31 },
32}
33
34pub fn run(action: AutoDecodeAction) -> Result<()> {
35 match action {
36 AutoDecodeAction::Decode {
37 input,
38 recursive,
39 max_depth,
40 aggressive,
41 max_nodes,
42 } => {
43 if aggressive {
44 let results = decode_tree(&input, max_depth, max_nodes);
45 if results.is_empty() {
46 println!("No encoding path found");
47 } else {
48 for (path, decoded) in results {
49 let flag = if looks_like_flag(&decoded) {
50 " [FLAG?]"
51 } else {
52 ""
53 };
54 println!("[{}]{} {}", path, flag, decoded);
55 }
56 }
57 } else if recursive {
58 decode_recursive(&input, 0, max_depth);
59 } else {
60 let results = detect_and_decode(&input);
61 if results.is_empty() {
62 println!("No encoding detected");
63 } else {
64 for (encoding, decoded) in results {
65 println!("[{}] {}", encoding, decoded);
66 }
67 }
68 }
69 }
70 }
71 Ok(())
72}
73
74pub fn detect_and_decode(input: &str) -> Vec<(&'static str, String)> {
75 let input = input.trim();
76 let mut results = Vec::new();
77
78 if looks_like_base64(input)
80 && let Ok(decoded) = base64::decode(input)
81 && is_printable(&decoded)
82 {
83 results.push(("Base64", decoded));
84 }
85
86 if looks_like_base32(input)
88 && let Ok(decoded) = base32::decode(input)
89 && is_printable(&decoded)
90 {
91 results.push(("Base32", decoded));
92 }
93
94 if looks_like_base58(input)
96 && let Ok(decoded) = base58::decode(input)
97 && is_printable(&decoded)
98 {
99 results.push(("Base58", decoded));
100 }
101
102 if looks_like_hex(input)
104 && let Ok(decoded) = hex::decode(input)
105 && is_printable(&decoded)
106 {
107 results.push(("Hex", decoded));
108 }
109
110 if input.contains('%')
112 && let Ok(decoded) = url::decode(input)
113 && decoded != input
114 {
115 results.push(("URL", decoded));
116 }
117
118 if looks_like_binary(input)
120 && let Ok(decoded) = binary::decode(input)
121 && is_printable(&decoded)
122 {
123 results.push(("Binary", decoded));
124 }
125
126 if looks_like_morse(input)
128 && let Ok(decoded) = morse::decode(input)
129 {
130 results.push(("Morse", decoded));
131 }
132
133 results
134}
135
136fn decode_recursive(input: &str, depth: usize, max_depth: usize) {
137 let indent = " ".repeat(depth);
138
139 if depth >= max_depth {
140 println!("{}[Max depth reached]", indent);
141 return;
142 }
143
144 let results = detect_and_decode(input);
145
146 if results.is_empty() {
147 if depth == 0 {
148 println!("No encoding detected");
149 }
150 return;
151 }
152
153 for (encoding, decoded) in results {
154 println!("{}[{}] {}", indent, encoding, decoded);
155
156 if decoded != input && !decoded.is_empty() {
158 decode_recursive(&decoded, depth + 1, max_depth);
159 }
160 }
161}
162
163fn is_printable(s: &str) -> bool {
164 s.chars()
165 .all(|c| !c.is_control() || c == '\n' || c == '\t' || c == '\r')
166}
167
168fn looks_like_flag(s: &str) -> bool {
169 let lower = s.to_ascii_lowercase();
170 lower.contains("flag{")
171 || lower.contains("ctf{")
172 || lower.contains("picoctf{")
173 || (s.contains('{') && s.contains('}') && s.len() >= 8 && s.len() <= 200)
174}
175
176pub fn score_decode_candidate(s: &str) -> f64 {
178 if s.is_empty() || s.len() > MAX_OUTPUT_CHARS {
179 return f64::NEG_INFINITY;
180 }
181 let mut score = 0.0f64;
182 if looks_like_flag(s) {
183 score += 500.0;
184 }
185 let printable = s
186 .chars()
187 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t' || *c == '\r')
188 .count();
189 score += (printable as f64 / s.len() as f64) * 40.0;
190 if s.is_ascii() {
192 score += 5.0;
193 }
194 let spaces = s.chars().filter(|c| *c == ' ').count() as f64;
196 score += (spaces / s.len() as f64 * 30.0).min(10.0);
197 score
198}
199
200pub fn decode_tree(input: &str, max_depth: usize, max_nodes: usize) -> Vec<(String, String)> {
203 let max_depth = max_depth.min(12);
204 let max_nodes = max_nodes.clamp(1, MAX_TREE_NODES);
205
206 let mut queue: VecDeque<(String, String, usize)> = VecDeque::new();
207 let mut seen: HashSet<String> = HashSet::new();
208 let mut leaves: Vec<(String, String, f64)> = Vec::new();
209
210 let root = input.trim().to_string();
211 if root.is_empty() {
212 return Vec::new();
213 }
214 queue.push_back((String::new(), root.clone(), 0));
215 seen.insert(root);
216
217 let mut expanded = 0usize;
218 while let Some((path, current, depth)) = queue.pop_front() {
219 if expanded >= max_nodes {
220 break;
221 }
222 expanded += 1;
223
224 let results = detect_and_decode(¤t);
225 if results.is_empty() || depth >= max_depth {
226 let score = score_decode_candidate(¤t);
227 if score.is_finite() {
228 let label = if path.is_empty() {
229 "identity".to_string()
230 } else {
231 path.clone()
232 };
233 leaves.push((label, current, score));
234 }
235 continue;
236 }
237
238 let mut progressed = false;
239 for (encoding, decoded) in results {
240 if decoded == current || decoded.is_empty() || decoded.len() > MAX_OUTPUT_CHARS {
241 continue;
242 }
243 if !seen.insert(decoded.clone()) {
244 continue;
245 }
246 progressed = true;
247 let next_path = if path.is_empty() {
248 encoding.to_string()
249 } else {
250 format!("{}->{}", path, encoding)
251 };
252 let score = score_decode_candidate(&decoded);
253 leaves.push((next_path.clone(), decoded.clone(), score));
254 if depth + 1 < max_depth {
255 queue.push_back((next_path, decoded, depth + 1));
256 }
257 }
258
259 if !progressed {
260 let score = score_decode_candidate(¤t);
261 let label = if path.is_empty() {
262 "identity".to_string()
263 } else {
264 path
265 };
266 leaves.push((label, current, score));
267 }
268 }
269
270 leaves.sort_by(|a, b| {
271 b.2.partial_cmp(&a.2)
272 .unwrap_or(std::cmp::Ordering::Equal)
273 .then_with(|| a.0.cmp(&b.0))
274 });
275
276 let mut best: Vec<(String, String)> = Vec::new();
278 let mut seen_text: HashSet<String> = HashSet::new();
279 for (path, text, _) in leaves {
280 if seen_text.insert(text.clone()) {
281 best.push((path, text));
282 }
283 }
284 best
285}
286
287fn looks_like_base64(s: &str) -> bool {
288 if s.len() < 2 {
289 return false;
290 }
291 let valid_chars = s
292 .chars()
293 .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=');
294 valid_chars && (s.len().is_multiple_of(4) || s.ends_with('='))
295}
296
297fn looks_like_base32(s: &str) -> bool {
298 if s.len() < 2 {
299 return false;
300 }
301 let valid_chars = s
302 .chars()
303 .all(|c| c.is_ascii_alphabetic() || matches!(c, '2'..='7' | '='));
304 valid_chars && s.len().is_multiple_of(8)
305}
306
307fn looks_like_base58(s: &str) -> bool {
308 if s.len() < 2 {
309 return false;
310 }
311 s.chars()
313 .all(|c| c.is_ascii_alphanumeric() && !matches!(c, '0' | 'O' | 'I' | 'l'))
314}
315
316fn looks_like_hex(s: &str) -> bool {
317 s.len() >= 2 && s.len().is_multiple_of(2) && s.chars().all(|c| c.is_ascii_hexdigit())
318}
319
320fn looks_like_binary(s: &str) -> bool {
321 let non_ws_count = s.chars().filter(|c| !c.is_whitespace()).count();
322 non_ws_count >= 8 && s.chars().all(|c| c == '0' || c == '1' || c.is_whitespace())
323}
324
325fn looks_like_morse(s: &str) -> bool {
326 s.len() >= 2
327 && s.chars()
328 .all(|c| c == '.' || c == '-' || c == ' ' || c == '/')
329}