1use anyhow::Result;
2use clap::Subcommand;
3use std::collections::HashSet;
4
5use super::{
6 affine, atbash, autodecode, cipherid, frequency, railfence, rot, substitution, vigenere,
7};
8
9const FLAG_PATTERNS: &[&str] = &["flag{", "FLAG{", "ctf{", "CTF{", "picoctf{", "HK{", "hk{"];
10
11const COMMON_WORDS: &[&str] = &[
12 "the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with",
13 "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "flag", "is",
14 "are", "was", "were", "hello", "world", "secret", "password", "key", "crypto",
15];
16
17#[derive(Subcommand)]
18pub enum SolveAction {
19 #[command(about = "Aggressively try encodings and classic ciphers against input")]
20 Run {
21 #[arg(help = "Ciphertext or encoded text")]
22 input: String,
23 #[arg(
24 short,
25 long,
26 default_value_t = 15,
27 help = "Maximum candidates to print"
28 )]
29 top: usize,
30 #[arg(long, default_value_t = 8, help = "Max rail-fence rails to try")]
31 max_rails: usize,
32 #[arg(
33 long,
34 default_value_t = 800,
35 help = "Hill-climbing iterations for substitution solve"
36 )]
37 substitution_iters: usize,
38 #[arg(long, help = "Also run deep recursive decoding")]
39 aggressive: bool,
40 #[arg(
41 long,
42 default_value_t = 6,
43 help = "Max recursive decode depth when --aggressive"
44 )]
45 max_depth: usize,
46 },
47}
48
49#[derive(Debug, Clone)]
50pub struct SolveCandidate {
51 pub method: String,
52 pub plaintext: String,
53 pub score: f64,
54 pub is_flag: bool,
55}
56
57pub fn run(action: SolveAction) -> Result<()> {
58 match action {
59 SolveAction::Run {
60 input,
61 top,
62 max_rails,
63 substitution_iters,
64 aggressive,
65 max_depth,
66 } => {
67 let results = solve(
68 &input,
69 SolveOptions {
70 max_rails,
71 substitution_iters,
72 aggressive,
73 max_depth,
74 },
75 );
76 print_results(&results, top);
77 }
78 }
79 Ok(())
80}
81
82#[derive(Debug, Clone)]
83pub struct SolveOptions {
84 pub max_rails: usize,
85 pub substitution_iters: usize,
86 pub aggressive: bool,
87 pub max_depth: usize,
88}
89
90impl Default for SolveOptions {
91 fn default() -> Self {
92 Self {
93 max_rails: 8,
94 substitution_iters: 800,
95 aggressive: false,
96 max_depth: 6,
97 }
98 }
99}
100
101pub fn solve(input: &str, options: SolveOptions) -> Vec<SolveCandidate> {
102 let mut out: Vec<SolveCandidate> = Vec::new();
103 let mut seen: HashSet<String> = HashSet::new();
104
105 let trimmed = input.trim();
106 if trimmed.is_empty() {
107 return out;
108 }
109
110 push_candidate(
111 &mut out,
112 &mut seen,
113 "identity".to_string(),
114 trimmed.to_string(),
115 );
116
117 for c in cipherid::analyze(trimmed) {
118 if c.confidence >= 0.3 {
119 push_candidate(
120 &mut out,
121 &mut seen,
122 format!("cipherid:{}", c.name),
123 format!("{} ({})", c.reason, c.confidence),
124 );
125 }
126 }
127
128 for (enc, decoded) in autodecode::detect_and_decode(trimmed) {
129 push_candidate(&mut out, &mut seen, format!("decode:{}", enc), decoded);
130 }
131
132 if options.aggressive {
133 for (path, decoded) in autodecode::decode_tree(trimmed, options.max_depth, 64) {
134 push_candidate(&mut out, &mut seen, format!("aggressive:{}", path), decoded);
135 }
136 }
137
138 try_classic_ciphers(trimmed, &options, &mut out, &mut seen);
139
140 let decoded_layers: Vec<String> = out
142 .iter()
143 .filter(|c| c.method.starts_with("decode:") || c.method.starts_with("aggressive:"))
144 .map(|c| c.plaintext.clone())
145 .take(12)
146 .collect();
147 for layer in decoded_layers {
148 try_classic_ciphers(&layer, &options, &mut out, &mut seen);
149 }
150
151 out.sort_by(|a, b| {
152 b.is_flag
153 .cmp(&a.is_flag)
154 .then_with(|| {
155 b.score
156 .partial_cmp(&a.score)
157 .unwrap_or(std::cmp::Ordering::Equal)
158 })
159 .then_with(|| a.method.cmp(&b.method))
160 });
161 out
162}
163
164fn push_candidate(
165 out: &mut Vec<SolveCandidate>,
166 seen: &mut HashSet<String>,
167 method: String,
168 plaintext: String,
169) {
170 if plaintext.is_empty() || !seen.insert(format!("{}|{}", method, plaintext)) {
171 return;
172 }
173 let score = score_plaintext(&plaintext);
174 let is_flag = looks_like_flag(&plaintext);
175 out.push(SolveCandidate {
176 method,
177 plaintext,
178 score,
179 is_flag,
180 });
181}
182
183fn try_classic_ciphers(
184 text: &str,
185 options: &SolveOptions,
186 out: &mut Vec<SolveCandidate>,
187 seen: &mut HashSet<String>,
188) {
189 let letters: String = text.chars().filter(|c| c.is_ascii_alphabetic()).collect();
190 if letters.len() < 3 {
191 return;
192 }
193
194 push_candidate(out, seen, "rot13".to_string(), rot::rot13(text));
195 push_candidate(out, seen, "rot47".to_string(), rot::rot47(text));
196 push_candidate(out, seen, "atbash".to_string(), atbash::transform(text));
197
198 for shift in 0..26u8 {
199 let plain = rot::rotate(text, (26 - (shift % 26)) % 26);
200 push_candidate(out, seen, format!("caesar:shift={}", shift), plain);
201 }
202
203 let max_rails = options.max_rails.clamp(2, 20);
204 for rails in 2..=max_rails {
205 if let Ok(plain) = railfence::decrypt(text, rails) {
206 push_candidate(out, seen, format!("railfence:rails={}", rails), plain);
207 }
208 }
209
210 for &a in &[1i32, 3, 5, 7, 9, 11, 15, 17, 19, 21, 23, 25] {
211 for b in 0..26i32 {
212 if let Ok(plain) = affine::decrypt(text, a, b) {
213 push_candidate(out, seen, format!("affine:a={},b={}", a, b), plain);
214 }
215 }
216 }
217
218 if letters.len() >= 20 {
219 let key_lens = vigenere::estimate_key_length(text, 16);
220 for &(key_len, _) in key_lens.iter().take(3) {
221 if key_len == 0 {
222 continue;
223 }
224 let key = vigenere::recover_key(text, key_len);
225 if let Ok(plain) = vigenere::decrypt(text, &key) {
226 push_candidate(out, seen, format!("vigenere:key={}", key), plain);
227 }
228 }
229 }
230
231 if letters.len() >= 40 && options.substitution_iters > 0 {
232 let (key, plain, _score) = substitution::solve(text, options.substitution_iters);
233 push_candidate(out, seen, format!("substitution:key={}", key), plain);
234 }
235}
236
237pub fn looks_like_flag(text: &str) -> bool {
238 let lower = text.to_ascii_lowercase();
239 if FLAG_PATTERNS
240 .iter()
241 .any(|p| lower.contains(&p.to_ascii_lowercase()))
242 {
243 return true;
244 }
245 text.chars().any(|c| c == '{')
247 && text.chars().any(|c| c == '}')
248 && text.len() >= 8
249 && text.len() <= 200
250}
251
252pub fn score_plaintext(text: &str) -> f64 {
254 if text.is_empty() {
255 return f64::NEG_INFINITY;
256 }
257
258 let mut score = 0.0f64;
259
260 if looks_like_flag(text) {
261 score += 1000.0;
262 }
263
264 let printable = text
265 .chars()
266 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t' || *c == '\r')
267 .count();
268 score += (printable as f64 / text.len() as f64) * 50.0;
269
270 let lower = text.to_ascii_lowercase();
271 for word in COMMON_WORDS {
272 if lower
273 .split(|c: char| !c.is_ascii_alphabetic())
274 .any(|w| w == *word)
275 {
276 score += 8.0;
277 }
278 }
279
280 let chi = frequency::chi_squared(text);
281 if chi.is_finite() {
282 score += (200.0 / (1.0 + chi)).min(80.0);
284 }
285
286 let spaces = text.chars().filter(|c| *c == ' ').count() as f64;
287 let letters = text.chars().filter(|c| c.is_ascii_alphabetic()).count() as f64;
288 if letters > 0.0 {
289 score += (spaces / letters * 20.0).min(15.0);
290 }
291
292 let non_print = text.len() - printable;
294 score -= non_print as f64 * 5.0;
295
296 score
297}
298
299fn print_results(results: &[SolveCandidate], top: usize) {
300 if results.is_empty() {
301 println!("No candidates produced");
302 return;
303 }
304
305 let flags: Vec<&SolveCandidate> = results.iter().filter(|c| c.is_flag).collect();
306 if !flags.is_empty() {
307 println!("=== Flag-like hits ===");
308 for c in flags.iter().take(top) {
309 println!("[{:.1}] {} => {}", c.score, c.method, c.plaintext);
310 }
311 println!();
312 }
313
314 println!("=== Top candidates ===");
315 for c in results.iter().take(top) {
316 let mark = if c.is_flag { " FLAG" } else { "" };
317 println!("[{:.1}{}] {} => {}", c.score, mark, c.method, c.plaintext);
318 }
319}