Skip to main content

happy_cracking/crypto/
cipherid.rs

1use anyhow::Result;
2use clap::Subcommand;
3
4use super::frequency;
5
6#[derive(Subcommand)]
7pub enum CipheridAction {
8    #[command(about = "Heuristically identify the encoding or cipher of a text")]
9    Analyze {
10        #[arg(help = "Ciphertext / encoded text to analyze")]
11        input: String,
12    },
13}
14
15pub fn run(action: CipheridAction) -> Result<()> {
16    match action {
17        CipheridAction::Analyze { input } => {
18            let candidates = analyze(&input);
19            print_candidates(&candidates);
20        }
21    }
22    Ok(())
23}
24
25#[derive(Debug, Clone)]
26pub struct Candidate {
27    pub name: String,
28    pub confidence: f64,
29    pub reason: String,
30}
31
32const STRONG_THRESHOLD: f64 = 0.3;
33
34const COMMON_WORDS: &[&str] = &[
35    "the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with",
36    "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her",
37    "she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "flag", "is", "are",
38    "was", "were", "what", "when", "where", "who", "how",
39];
40
41const NATO_WORDS: &[&str] = &[
42    "alfa", "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
43    "juliet", "juliett", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo",
44    "sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu", "zero", "one",
45    "two", "three", "four", "five", "six", "seven", "eight", "nine",
46];
47
48pub fn analyze(input: &str) -> Vec<Candidate> {
49    let mut candidates: Vec<Candidate> = Vec::new();
50    let trimmed = input.trim();
51
52    if trimmed.is_empty() {
53        return candidates;
54    }
55
56    detect_formats(trimmed, &mut candidates);
57    detect_statistics(trimmed, &mut candidates);
58
59    candidates.sort_by(|a, b| {
60        b.confidence
61            .partial_cmp(&a.confidence)
62            .unwrap_or(std::cmp::Ordering::Equal)
63            .then_with(|| a.name.cmp(&b.name))
64    });
65
66    candidates
67}
68
69fn detect_formats(s: &str, out: &mut Vec<Candidate>) {
70    let len = s.chars().count();
71
72    if let Some(reason) = looks_like_flag(s) {
73        out.push(Candidate {
74            name: "flag / plaintext marker".to_string(),
75            confidence: 0.97,
76            reason,
77        });
78    }
79
80    if len >= 2 && len.is_multiple_of(2) && s.chars().all(|c| c.is_ascii_hexdigit()) {
81        let has_hex_letter = s.chars().any(|c| c.is_ascii_alphabetic());
82        let confidence = if has_hex_letter { 0.9 } else { 0.55 };
83        out.push(Candidate {
84            name: "Hex".to_string(),
85            confidence,
86            reason: format!("{} hex digits, even length", len),
87        });
88    }
89
90    let non_ws: Vec<char> = s.chars().filter(|c| !c.is_whitespace()).collect();
91    if non_ws.len() >= 8 && non_ws.iter().all(|&c| c == '0' || c == '1') {
92        let confidence = if non_ws.len().is_multiple_of(8) {
93            0.92
94        } else {
95            0.7
96        };
97        out.push(Candidate {
98            name: "Binary".to_string(),
99            confidence,
100            reason: format!("{} bits of 0/1 only", non_ws.len()),
101        });
102    }
103
104    if s.chars()
105        .all(|c| c == '.' || c == '-' || c == '/' || c == ' ')
106        && s.chars().any(|c| c == '.' || c == '-')
107    {
108        out.push(Candidate {
109            name: "Morse".to_string(),
110            confidence: 0.93,
111            reason: "only dots, dashes, slashes and spaces".to_string(),
112        });
113    }
114
115    if let Some(c) = detect_decimal(s) {
116        out.push(c);
117    }
118
119    let braille = s
120        .chars()
121        .filter(|&c| ('\u{2800}'..='\u{28FF}').contains(&c))
122        .count();
123    if braille > 0
124        && s.chars()
125            .all(|c| ('\u{2800}'..='\u{28FF}').contains(&c) || c.is_whitespace())
126    {
127        out.push(Candidate {
128            name: "Braille".to_string(),
129            confidence: 0.95,
130            reason: format!("{} characters in the Braille unicode block", braille),
131        });
132    }
133
134    if let Some(c) = detect_nato(s) {
135        out.push(c);
136    }
137
138    if len >= 4
139        && s.chars()
140            .all(|c| c.is_ascii_alphanumeric() && !matches!(c, '0' | 'O' | 'I' | 'l'))
141    {
142        let confidence = if s.chars().any(|c| !c.is_ascii_hexdigit()) {
143            0.45
144        } else {
145            0.2
146        };
147        out.push(Candidate {
148            name: "Base58".to_string(),
149            confidence,
150            reason: "alphanumeric without 0/O/I/l".to_string(),
151        });
152    }
153
154    let b32_body = s.trim_end_matches('=');
155    if len >= 8
156        && len.is_multiple_of(8)
157        && !b32_body.is_empty()
158        && b32_body
159            .chars()
160            .all(|c| c.is_ascii_uppercase() || matches!(c, '2'..='7'))
161    {
162        out.push(Candidate {
163            name: "Base32".to_string(),
164            confidence: 0.82,
165            reason: "A-Z and 2-7 alphabet, padded length multiple of 8".to_string(),
166        });
167    }
168
169    let b64_body = s.trim_end_matches('=');
170    if len >= 4
171        && len.is_multiple_of(4)
172        && !b64_body.is_empty()
173        && b64_body
174            .chars()
175            .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')
176    {
177        let distinctive = s.contains('=')
178            || s.chars()
179                .any(|c| c.is_ascii_lowercase() || c == '+' || c == '/');
180        let confidence = if distinctive { 0.9 } else { 0.6 };
181        out.push(Candidate {
182            name: "Base64".to_string(),
183            confidence,
184            reason: "Base64 alphabet, length multiple of 4".to_string(),
185        });
186    }
187
188    if len >= 5
189        && s.chars().all(|c| ('\u{21}'..='\u{75}').contains(&c))
190        && s.chars().any(|c| !c.is_ascii_alphanumeric())
191    {
192        out.push(Candidate {
193            name: "Base85".to_string(),
194            confidence: 0.4,
195            reason: "printable ASCII in the Base85 ('!'..'u') range".to_string(),
196        });
197    }
198}
199
200fn looks_like_flag(s: &str) -> Option<String> {
201    let lower = s.to_lowercase();
202
203    if let Some(open) = lower.find('{')
204        && lower[open..].contains('}')
205    {
206        let prefix = &lower[..open];
207        if !prefix.is_empty()
208            && prefix
209                .chars()
210                .all(|c| c.is_ascii_alphanumeric() || c == '_')
211        {
212            return Some(format!("contains '{}{{...}}' CTF flag marker", prefix));
213        }
214    }
215    None
216}
217
218fn detect_decimal(s: &str) -> Option<Candidate> {
219    let parts: Vec<&str> = s
220        .split(['-', ' ', ',', '.', '\t', '\n'])
221        .filter(|p| !p.is_empty())
222        .collect();
223
224    if parts.len() < 2 {
225        return None;
226    }
227    if !parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) {
228        return None;
229    }
230
231    let values: Vec<u32> = parts.iter().filter_map(|p| p.parse::<u32>().ok()).collect();
232    if values.len() == parts.len() && values.iter().all(|&v| (1..=26).contains(&v)) {
233        Some(Candidate {
234            name: "A1Z26 / decimal".to_string(),
235            confidence: 0.9,
236            reason: format!(
237                "{} groups, all within 1..=26 (letter positions)",
238                values.len()
239            ),
240        })
241    } else {
242        Some(Candidate {
243            name: "Decimal / numeric".to_string(),
244            confidence: 0.6,
245            reason: format!("{} separator-delimited decimal groups", parts.len()),
246        })
247    }
248}
249
250fn detect_nato(s: &str) -> Option<Candidate> {
251    let tokens: Vec<&str> = s.split_whitespace().collect();
252    if tokens.len() < 2 {
253        return None;
254    }
255    let lower_tokens: Vec<String> = tokens.iter().map(|t| t.to_lowercase()).collect();
256    let matched = lower_tokens
257        .iter()
258        .filter(|t| NATO_WORDS.contains(&t.as_str()))
259        .count();
260    let ratio = matched as f64 / tokens.len() as f64;
261    if ratio >= 0.6 {
262        Some(Candidate {
263            name: "NATO phonetic".to_string(),
264            confidence: 0.5 + 0.45 * ratio,
265            reason: format!("{}/{} tokens are NATO words", matched, tokens.len()),
266        })
267    } else {
268        None
269    }
270}
271
272fn detect_statistics(s: &str, out: &mut Vec<Candidate>) {
273    let alpha_count = s.chars().filter(|c| c.is_ascii_alphabetic()).count();
274
275    if alpha_count < 20 {
276        if let Some(c) = detect_english(s) {
277            out.push(c);
278        }
279        return;
280    }
281
282    let ioc = frequency::index_of_coincidence(s);
283
284    if ioc >= 0.058 {
285        out.push(Candidate {
286            name: "Monoalphabetic (substitution / Caesar / Atbash) or plaintext".to_string(),
287            confidence: 0.75,
288            reason: format!(
289                "IoC = {:.4} (~0.067 expected for English/monoalphabetic)",
290                ioc
291            ),
292        });
293    } else if ioc <= 0.05 {
294        out.push(Candidate {
295            name: "Polyalphabetic (Vigenere / Beaufort)".to_string(),
296            confidence: 0.75,
297            reason: format!(
298                "IoC = {:.4} (~0.038 expected for polyalphabetic/random)",
299                ioc
300            ),
301        });
302    } else {
303        out.push(Candidate {
304            name: "Polyalphabetic or short monoalphabetic".to_string(),
305            confidence: 0.45,
306            reason: format!(
307                "IoC = {:.4} (ambiguous, between mono- and polyalphabetic)",
308                ioc
309            ),
310        });
311    }
312
313    if let Some(c) = detect_english(s) {
314        out.push(c);
315    }
316}
317
318fn detect_english(s: &str) -> Option<Candidate> {
319    let lower = s.to_lowercase();
320    let words: Vec<&str> = lower
321        .split(|c: char| !c.is_ascii_alphabetic())
322        .filter(|w| !w.is_empty())
323        .collect();
324
325    if words.is_empty() {
326        return None;
327    }
328
329    let common_hits = words.iter().filter(|w| COMMON_WORDS.contains(w)).count();
330    let chi = frequency::chi_squared(s);
331
332    if common_hits >= 2 {
333        let confidence = (0.6 + 0.1 * common_hits as f64).min(0.95);
334        return Some(Candidate {
335            name: "English plaintext".to_string(),
336            confidence,
337            reason: format!(
338                "{} common English words found, chi-squared = {:.1}",
339                common_hits, chi
340            ),
341        });
342    }
343
344    if chi.is_finite() && chi < 50.0 {
345        return Some(Candidate {
346            name: "English plaintext".to_string(),
347            confidence: 0.5,
348            reason: format!(
349                "letter distribution close to English (chi-squared = {:.1})",
350                chi
351            ),
352        });
353    }
354
355    None
356}
357
358fn print_candidates(candidates: &[Candidate]) {
359    if candidates.is_empty() {
360        println!("No candidates: input is empty.");
361        return;
362    }
363
364    let strong = candidates.iter().any(|c| c.confidence >= STRONG_THRESHOLD);
365    if !strong {
366        println!("No strong match. Showing the top guesses:");
367    }
368
369    for c in candidates {
370        println!(
371            "{}  (confidence {:.2})  - {}",
372            c.name, c.confidence, c.reason
373        );
374    }
375}