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}