Skip to main content

happy_cracking/crypto/
autodecode.rs

1use 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    // Try Base64
79    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    // Try Base32
87    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    // Try Base58
95    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    // Try Hex
103    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    // Try URL encoding
111    if input.contains('%')
112        && let Ok(decoded) = url::decode(input)
113        && decoded != input
114    {
115        results.push(("URL", decoded));
116    }
117
118    // Try Binary
119    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    // Try Morse
127    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        // Try to decode further
157        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
176/// Score a candidate decode result. Higher is better.
177pub 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    // Prefer shorter expansions (decoding usually shrinks encoded text ratio-wise, but not always)
191    if s.is_ascii() {
192        score += 5.0;
193    }
194    // Mild preference for spaces (English)
195    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
200/// Breadth-first multi-path decode tree for aggressive mode.
201/// Returns (path, decoded) pairs sorted by score descending.
202pub 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(&current);
225        if results.is_empty() || depth >= max_depth {
226            let score = score_decode_candidate(&current);
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(&current);
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    // Deduplicate by decoded text, keep best path
277    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    // Base58 excludes 0, O, I, l
312    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}