Skip to main content

happy_cracking/crypto/
solve.rs

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    // Also attack decoded layers that look like cipher text
141    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    // Generic brace flag form like xxx{...}
246    text.chars().any(|c| c == '{')
247        && text.chars().any(|c| c == '}')
248        && text.len() >= 8
249        && text.len() <= 200
250}
251
252/// Higher score = more likely English / CTF plaintext.
253pub 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        // Lower chi-squared is better; map into a positive contribution
283        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    // Penalize high-entropy binary-looking strings
293    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}