Skip to main content

api_scanner/scanner/
jwt.rs

1// src/scanner/jwt.rs
2//
3// JWT-specific security checks.
4
5use std::collections::HashSet;
6
7use async_trait::async_trait;
8use base64::engine::general_purpose::{STANDARD as BASE64_STD, URL_SAFE_NO_PAD};
9use base64::Engine;
10use chrono::Utc;
11use hmac::{Hmac, Mac};
12use once_cell::sync::Lazy;
13use regex::Regex;
14use serde_json::Value;
15use sha2::Sha256;
16use url::Url;
17
18use crate::{
19    config::Config,
20    error::CapturedError,
21    http_client::HttpClient,
22    reports::{Finding, Severity},
23};
24
25use super::{common::http_utils::is_json_content_type, Scanner};
26
27type HmacSha256 = Hmac<Sha256>;
28
29static JWT_RE: Lazy<Regex> =
30    Lazy::new(|| Regex::new(r"eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+").unwrap());
31
32static SENSITIVE_CLAIMS: &[&str] = &[
33    "email",
34    "role",
35    "roles",
36    "is_admin",
37    "admin",
38    "permissions",
39    "scope",
40];
41
42const LONG_LIVED_SECS: i64 = 60 * 60 * 24 * 30; // 30 days
43
44static WEAK_SECRET_LIST: Lazy<Vec<String>> = Lazy::new(|| {
45    include_str!("../../assets/jwt_secrets.txt")
46        .lines()
47        .map(|s| s.trim())
48        .filter(|s| !s.is_empty())
49        .map(|s| s.to_string())
50        .collect()
51});
52
53pub struct JwtScanner;
54
55impl JwtScanner {
56    pub fn new(_config: &Config) -> Self {
57        Self
58    }
59}
60
61#[async_trait]
62impl Scanner for JwtScanner {
63    fn name(&self) -> &'static str {
64        "jwt"
65    }
66
67    async fn scan(
68        &self,
69        url: &str,
70        client: &HttpClient,
71        config: &Config,
72    ) -> (Vec<Finding>, Vec<CapturedError>) {
73        let mut findings = Vec::new();
74        let mut errors = Vec::new();
75
76        let resp = match client.get(url).await {
77            Ok(r) => r,
78            Err(e) => {
79                errors.push(e);
80                return (findings, errors);
81            }
82        };
83        let baseline_status = resp.status;
84
85        let mut seen = HashSet::new();
86
87        // Scan response headers for JWTs (common in Set-Cookie or auth headers).
88        for (header_name, header_value) in &resp.headers {
89            if matches!(
90                header_name.as_str(),
91                "set-cookie" | "authorization" | "x-auth-token" | "x-access-token" | "x-id-token"
92            ) {
93                for m in JWT_RE.find_iter(header_value) {
94                    let token = m.as_str().to_string();
95                    if seen.insert(token.clone()) {
96                        analyze_jwt(
97                            url,
98                            &token,
99                            client,
100                            config,
101                            baseline_status,
102                            &mut findings,
103                            &mut errors,
104                        )
105                        .await;
106                    }
107                }
108            }
109        }
110
111        let ct = resp
112            .headers
113            .get("content-type")
114            .map(|s| s.as_str())
115            .unwrap_or("");
116        let scannable = ct.is_empty()
117            || is_json_content_type(ct)
118            || ct.contains("text/")
119            || ct.contains("javascript")
120            || ct.contains("xml");
121        if !scannable {
122            return (findings, errors);
123        }
124
125        for m in JWT_RE.find_iter(&resp.body) {
126            let token = m.as_str().to_string();
127            if !seen.insert(token.clone()) {
128                continue;
129            }
130
131            analyze_jwt(
132                url,
133                &token,
134                client,
135                config,
136                baseline_status,
137                &mut findings,
138                &mut errors,
139            )
140            .await;
141        }
142
143        (findings, errors)
144    }
145}
146
147async fn analyze_jwt(
148    url: &str,
149    token: &str,
150    client: &HttpClient,
151    config: &Config,
152    baseline_status: u16,
153    findings: &mut Vec<Finding>,
154    errors: &mut Vec<CapturedError>,
155) {
156    let parts: Vec<&str> = token.split('.').collect();
157    if parts.len() != 3 {
158        return;
159    }
160
161    let header = match decode_json(parts[0]) {
162        Some(v) => v,
163        None => {
164            errors.push(CapturedError::from_str(
165                "jwt/decode",
166                Some(url.to_string()),
167                "Failed to decode JWT header segment as JSON",
168            ));
169            return;
170        }
171    };
172    let payload = match decode_json(parts[1]) {
173        Some(v) => v,
174        None => {
175            errors.push(CapturedError::from_str(
176                "jwt/decode",
177                Some(url.to_string()),
178                "Failed to decode JWT payload segment as JSON",
179            ));
180            return;
181        }
182    };
183    let signature = match decode_segment(parts[2]) {
184        Some(v) => v,
185        None => {
186            errors.push(CapturedError::from_str(
187                "jwt/decode",
188                Some(url.to_string()),
189                "Failed to decode JWT signature segment",
190            ));
191            return;
192        }
193    };
194
195    let alg = header.get("alg").and_then(Value::as_str).unwrap_or("");
196
197    // Suspicious kid patterns (path traversal / URL fetch).
198    if let Some(kid) = header.get("kid").and_then(Value::as_str) {
199        if kid.contains("..")
200            || kid.starts_with("http://")
201            || kid.starts_with("https://")
202            || kid.starts_with("file:")
203        {
204            findings.push(
205                Finding::new(
206                    url,
207                    "jwt/suspicious-kid",
208                    "JWT kid header looks suspicious",
209                    Severity::Low,
210                    "The JWT `kid` header contains a path or URL-like value. Some implementations\n                     load key material from filesystem/URLs and may be vulnerable to injection.",
211                    "jwt",
212                )
213                .with_evidence(format!("kid: {kid}"))
214                .with_remediation(
215                    "Treat `kid` as an opaque identifier and disallow path/URL resolution.",
216                ),
217            );
218        }
219    }
220
221    if alg.eq_ignore_ascii_case("none") {
222        findings.push(
223            Finding::new(
224                url,
225                "jwt/alg-none",
226                "JWT uses alg=none",
227                Severity::Critical,
228                "JWTs signed with alg=none can be forged without a secret key.",
229                "jwt",
230            )
231            .with_evidence(format!("Token: {}", redact_token(token)))
232            .with_remediation(
233                "Disallow alg=none and enforce signature verification with a strong key.",
234            ),
235        );
236    }
237
238    let mut hits = Vec::new();
239    for key in SENSITIVE_CLAIMS {
240        if payload.get(*key).is_some() {
241            hits.push(*key);
242        }
243    }
244
245    if !hits.is_empty() {
246        findings.push(
247            Finding::new(
248                url,
249                "jwt/sensitive-claims",
250                "JWT contains sensitive claims",
251                Severity::Medium,
252                format!(
253                    "JWT payload exposes potentially sensitive claims: {}",
254                    hits.join(", ")
255                ),
256                "jwt",
257            )
258            .with_evidence(format!("Token: {}", redact_token(token)))
259            .with_remediation(
260                "Minimize sensitive data in JWTs and prefer opaque tokens when possible.",
261            ),
262        );
263    }
264
265    match payload.get("exp").and_then(Value::as_i64) {
266        Some(exp) => {
267            let now = Utc::now().timestamp();
268            if exp - now > LONG_LIVED_SECS {
269                findings.push(
270                    Finding::new(
271                        url,
272                        "jwt/long-lived",
273                        "JWT has a long expiration window",
274                        Severity::Medium,
275                        "JWT expiration is far in the future; long-lived tokens increase risk if leaked.",
276                        "jwt",
277                    )
278                    .with_evidence(format!("exp: {exp}, now: {now}"))
279                    .with_remediation(
280                        "Use short-lived access tokens and rotate/refresh them frequently.",
281                    ),
282                );
283            }
284        }
285        None => {
286            findings.push(
287                Finding::new(
288                    url,
289                    "jwt/no-exp",
290                    "JWT missing exp claim",
291                    Severity::Medium,
292                    "JWT has no exp claim; tokens without expiration increase risk if leaked.",
293                    "jwt",
294                )
295                .with_evidence(format!("Token: {}", redact_token(token)))
296                .with_remediation("Include a short-lived exp claim and rotate tokens regularly."),
297            );
298        }
299    }
300
301    if alg.eq_ignore_ascii_case("hs256") {
302        let url_owned = url.to_string();
303        let header_owned = parts[0].to_string();
304        let payload_owned = parts[1].to_string();
305        let signature_owned = signature.clone();
306        match tokio::task::spawn_blocking(move || {
307            weak_secret_match(&url_owned, &header_owned, &payload_owned, &signature_owned)
308        })
309        .await
310        {
311            Ok(Some(secret)) => {
312                findings.push(
313                    Finding::new(
314                        url,
315                        "jwt/weak-secret",
316                        "JWT signed with weak HS256 secret",
317                        Severity::Critical,
318                        format!(
319                            "JWT signature verifies with a weak secret candidate: '{secret}'.",
320                        ),
321                        "jwt",
322                    )
323                    .with_evidence(format!("Token: {}", redact_token(token)))
324                    .with_remediation(
325                        "Use a strong, high-entropy secret for HS256 or move to asymmetric signing (RS256/ES256).",
326                    ),
327                );
328            }
329            Ok(None) => {}
330            Err(e) => errors.push(CapturedError::from_str(
331                "jwt/weak_secret_probe",
332                Some(url.to_string()),
333                format!("weak-secret blocking task failed: {e}"),
334            )),
335        }
336    }
337
338    if config.active_checks && alg.eq_ignore_ascii_case("rs256") {
339        if let Some(finding) = attempt_alg_confusion(
340            url,
341            Some(&header),
342            parts[1],
343            client,
344            baseline_status,
345            errors,
346        )
347        .await
348        {
349            findings.push(finding);
350        }
351    }
352}
353
354fn decode_segment(seg: &str) -> Option<Vec<u8>> {
355    URL_SAFE_NO_PAD.decode(seg).ok()
356}
357
358fn decode_json(seg: &str) -> Option<Value> {
359    let bytes = decode_segment(seg)?;
360    serde_json::from_slice(&bytes).ok()
361}
362
363fn weak_secret_match(url: &str, header_b64: &str, payload_b64: &str, sig: &[u8]) -> Option<String> {
364    let mut host_candidates: Vec<String> = Vec::new();
365
366    if let Ok(parsed) = Url::parse(url) {
367        if let Some(host) = parsed.host_str() {
368            host_candidates.push(host.to_string());
369            if let Some(root) = host.split('.').next() {
370                if !root.is_empty() {
371                    host_candidates.push(root.to_string());
372                }
373            }
374        }
375    }
376
377    let signing_input = format!("{header_b64}.{payload_b64}");
378
379    for secret in WEAK_SECRET_LIST
380        .iter()
381        .map(String::as_str)
382        .chain(host_candidates.iter().map(String::as_str))
383    {
384        if let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) {
385            mac.update(signing_input.as_bytes());
386            if mac.verify_slice(sig).is_ok() {
387                return Some(secret.to_string());
388            }
389        }
390    }
391
392    None
393}
394
395fn redact_token(token: &str) -> String {
396    let chars: Vec<char> = token.chars().collect();
397    if chars.len() <= 16 {
398        return token.to_string();
399    }
400    let head: String = chars[..8].iter().collect();
401    let tail: String = chars[chars.len() - 8..].iter().collect();
402    format!("{head}…{tail}")
403}
404
405async fn attempt_alg_confusion(
406    url: &str,
407    header: Option<&Value>,
408    payload_b64: &str,
409    client: &HttpClient,
410    baseline_status: u16,
411    errors: &mut Vec<CapturedError>,
412) -> Option<Finding> {
413    if baseline_status >= 400 {
414        return None;
415    }
416
417    let header = header?;
418    let has_key_hint = header.get("x5c").is_some() || header.get("jwk").is_some();
419    if !has_key_hint {
420        return None;
421    }
422
423    // For RS256->HS256 confusion the forged HS256 token is signed with
424    // attacker-controlled public key material. Try realistic encodings a
425    // vulnerable verifier may consume from JWT hints (`jwk`/`x5c`).
426    let secret_candidates = derive_alg_confusion_secrets(header);
427    if secret_candidates.is_empty() {
428        errors.push(CapturedError::from_str(
429            "jwt/alg_confusion",
430            Some(url.to_string()),
431            "Unable to derive RSA key material from JWT header (expected valid jwk {kty,n,e} or x5c certificate)",
432        ));
433        return None;
434    }
435
436    let unauth_status = match client.get_without_auth(url).await {
437        Ok(r) => Some(r.status),
438        Err(mut e) => {
439            e.context = "jwt/alg_confusion_baseline".to_string();
440            errors.push(e);
441            None
442        }
443    };
444
445    for (secret_source, secret) in secret_candidates {
446        let mut new_header = header.clone();
447        if let Some(obj) = new_header.as_object_mut() {
448            obj.insert("alg".to_string(), Value::String("HS256".to_string()));
449        }
450
451        let header_json = match serde_json::to_vec(&new_header) {
452            Ok(v) => v,
453            Err(_) => continue,
454        };
455        let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
456
457        let signing_input = format!("{header_b64}.{payload_b64}");
458        let mut mac = match HmacSha256::new_from_slice(&secret) {
459            Ok(m) => m,
460            Err(_) => continue,
461        };
462        mac.update(signing_input.as_bytes());
463        let sig = mac.finalize().into_bytes();
464        let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
465
466        let forged = format!("{header_b64}.{payload_b64}.{sig_b64}");
467        let auth_header = format!("Bearer {forged}");
468
469        let extra = vec![("Authorization".to_string(), auth_header)];
470        let resp = match client.get_with_headers(url, &extra).await {
471            Ok(r) => r,
472            Err(mut e) => {
473                e.message = format!("alg_confusion_probe[{secret_source}]: {}", e.message);
474                errors.push(e);
475                continue;
476            }
477        };
478
479        if resp.status < 400 && matches!(unauth_status, Some(status) if status >= 400) {
480            return Some(
481                Finding::new(
482                    url,
483                    "jwt/alg-confusion",
484                    "JWT RS256 -> HS256 confusion",
485                    Severity::Critical,
486                    "A forged HS256 token signed with derived public key material appears to be accepted.",
487                    "jwt",
488                )
489                .with_evidence(format!(
490                    "baseline_status: {baseline_status}, unauth_status: {}, forged_status: {}, key_source: {secret_source}",
491                    unauth_status
492                        .map(|s| s.to_string())
493                        .unwrap_or_else(|| "unknown".to_string()),
494                    resp.status,
495                ))
496                .with_remediation(
497                    "Reject HS256 tokens when using RS256 keys; ensure key type matches algorithm.",
498                ),
499            );
500        }
501    }
502
503    None
504}
505
506fn derive_alg_confusion_secrets(header: &Value) -> Vec<(String, Vec<u8>)> {
507    let mut candidates: Vec<(String, Vec<u8>)> = Vec::new();
508    let mut seen: HashSet<Vec<u8>> = HashSet::new();
509
510    let mut push_candidate = |label: &str, secret: Vec<u8>| {
511        if secret.is_empty() {
512            return;
513        }
514        if seen.insert(secret.clone()) {
515            candidates.push((label.to_string(), secret));
516        }
517    };
518
519    if let Some((modulus, exponent)) = header.get("jwk").and_then(extract_rsa_components_from_jwk) {
520        if let Some(spki_der) = build_rsa_spki_der(&modulus, &exponent) {
521            push_candidate("jwk-spki-der", spki_der.clone());
522            push_candidate(
523                "jwk-spki-pem",
524                pem_encode("PUBLIC KEY", &spki_der).into_bytes(),
525            );
526        }
527    }
528
529    if let Some(x5c_der) = header
530        .get("x5c")
531        .and_then(Value::as_array)
532        .and_then(|arr| arr.first())
533        .and_then(Value::as_str)
534        .and_then(|s| BASE64_STD.decode(s.as_bytes()).ok())
535    {
536        push_candidate("x5c-cert-der", x5c_der.clone());
537        push_candidate(
538            "x5c-cert-pem",
539            pem_encode("CERTIFICATE", &x5c_der).into_bytes(),
540        );
541
542        if let Some(spki_der) = extract_subject_public_key_info_der_from_certificate(&x5c_der) {
543            push_candidate("x5c-spki-der", spki_der.clone());
544            push_candidate(
545                "x5c-spki-pem",
546                pem_encode("PUBLIC KEY", &spki_der).into_bytes(),
547            );
548        }
549    }
550
551    candidates
552}
553
554fn extract_rsa_components_from_jwk(jwk: &Value) -> Option<(Vec<u8>, Vec<u8>)> {
555    let obj = jwk.as_object()?;
556    let kty = obj.get("kty").and_then(Value::as_str).unwrap_or_default();
557    if !kty.eq_ignore_ascii_case("RSA") {
558        return None;
559    }
560
561    let n = obj.get("n").and_then(Value::as_str)?;
562    let e = obj.get("e").and_then(Value::as_str)?;
563    let mut modulus = URL_SAFE_NO_PAD.decode(n.as_bytes()).ok()?;
564    let mut exponent = URL_SAFE_NO_PAD.decode(e.as_bytes()).ok()?;
565    trim_leading_zeros(&mut modulus);
566    trim_leading_zeros(&mut exponent);
567    if modulus.is_empty() || exponent.is_empty() {
568        return None;
569    }
570    Some((modulus, exponent))
571}
572
573fn trim_leading_zeros(bytes: &mut Vec<u8>) {
574    if bytes.len() <= 1 {
575        return;
576    }
577    let first_non_zero = bytes
578        .iter()
579        .position(|&b| b != 0)
580        .unwrap_or(bytes.len().saturating_sub(1));
581    if first_non_zero > 0 {
582        bytes.drain(0..first_non_zero);
583    }
584}
585
586fn build_rsa_spki_der(modulus: &[u8], exponent: &[u8]) -> Option<Vec<u8>> {
587    let rsa_pkcs1 = build_rsa_pkcs1_der(modulus, exponent)?;
588    let alg_id_value: Vec<u8> = vec![
589        0x06, 0x09, // OBJECT IDENTIFIER length 9
590        0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01,
591        0x01, // 1.2.840.113549.1.1.1 (rsaEncryption)
592        0x05, 0x00, // NULL
593    ];
594    let alg_id = der_tlv(0x30, &alg_id_value);
595
596    let mut bit_string = Vec::with_capacity(1 + rsa_pkcs1.len());
597    bit_string.push(0x00); // unused bits
598    bit_string.extend_from_slice(&rsa_pkcs1);
599    let subject_public_key = der_tlv(0x03, &bit_string);
600
601    let mut spki_value = Vec::with_capacity(alg_id.len() + subject_public_key.len());
602    spki_value.extend_from_slice(&alg_id);
603    spki_value.extend_from_slice(&subject_public_key);
604    Some(der_tlv(0x30, &spki_value))
605}
606
607fn build_rsa_pkcs1_der(modulus: &[u8], exponent: &[u8]) -> Option<Vec<u8>> {
608    if modulus.is_empty() || exponent.is_empty() {
609        return None;
610    }
611
612    let modulus_tlv = der_integer(modulus);
613    let exponent_tlv = der_integer(exponent);
614
615    let mut value = Vec::with_capacity(modulus_tlv.len() + exponent_tlv.len());
616    value.extend_from_slice(&modulus_tlv);
617    value.extend_from_slice(&exponent_tlv);
618    Some(der_tlv(0x30, &value))
619}
620
621fn der_integer(raw: &[u8]) -> Vec<u8> {
622    let mut v = raw.to_vec();
623    trim_leading_zeros(&mut v);
624    if v.is_empty() {
625        v.push(0);
626    }
627    if (v[0] & 0x80) != 0 {
628        v.insert(0, 0x00);
629    }
630    der_tlv(0x02, &v)
631}
632
633fn der_tlv(tag: u8, value: &[u8]) -> Vec<u8> {
634    let mut out = Vec::with_capacity(1 + value.len() + 5);
635    out.push(tag);
636    out.extend_from_slice(&der_len(value.len()));
637    out.extend_from_slice(value);
638    out
639}
640
641fn der_len(len: usize) -> Vec<u8> {
642    if len < 0x80 {
643        return vec![len as u8];
644    }
645    let mut bytes = Vec::new();
646    let mut n = len;
647    while n > 0 {
648        bytes.push((n & 0xff) as u8);
649        n >>= 8;
650    }
651    bytes.reverse();
652    let mut out = Vec::with_capacity(1 + bytes.len());
653    out.push(0x80 | (bytes.len() as u8));
654    out.extend_from_slice(&bytes);
655    out
656}
657
658fn pem_encode(label: &str, der: &[u8]) -> String {
659    let b64 = BASE64_STD.encode(der);
660    let mut out = String::new();
661    out.push_str("-----BEGIN ");
662    out.push_str(label);
663    out.push_str("-----\n");
664    for chunk in b64.as_bytes().chunks(64) {
665        out.push_str(String::from_utf8_lossy(chunk).as_ref());
666        out.push('\n');
667    }
668    out.push_str("-----END ");
669    out.push_str(label);
670    out.push_str("-----\n");
671    out
672}
673
674fn extract_subject_public_key_info_der_from_certificate(cert_der: &[u8]) -> Option<Vec<u8>> {
675    if let Some(spki) = extract_spki_from_x509_layout(cert_der) {
676        return Some(spki);
677    }
678    extract_spki_sequence_recursive(cert_der, 0)
679}
680
681fn extract_spki_from_x509_layout(cert_der: &[u8]) -> Option<Vec<u8>> {
682    let (cert_tag, cert_value_start, cert_value_end, _) = parse_der_tlv(cert_der, 0)?;
683    if cert_tag != 0x30 {
684        return None;
685    }
686    let cert_seq = &cert_der[cert_value_start..cert_value_end];
687
688    let (tbs_tag, tbs_value_start, tbs_value_end, _) = parse_der_tlv(cert_seq, 0)?;
689    if tbs_tag != 0x30 {
690        return None;
691    }
692    let tbs = &cert_seq[tbs_value_start..tbs_value_end];
693
694    let mut idx = 0usize;
695    let (first_tag, _, _, next_idx) = parse_der_tlv(tbs, idx)?;
696    if first_tag == 0xA0 {
697        idx = next_idx;
698    }
699
700    // serialNumber, signature, issuer, validity, subject
701    for _ in 0..5 {
702        let (_, _, _, next) = parse_der_tlv(tbs, idx)?;
703        idx = next;
704    }
705
706    let (spki_tag, _, _, spki_next) = parse_der_tlv(tbs, idx)?;
707    if spki_tag != 0x30 {
708        return None;
709    }
710
711    Some(tbs[idx..spki_next].to_vec())
712}
713
714fn extract_spki_sequence_recursive(input: &[u8], offset: usize) -> Option<Vec<u8>> {
715    if offset >= input.len() {
716        return None;
717    }
718
719    let (tag, value_start, value_end, next) = parse_der_tlv(input, offset)?;
720
721    if tag == 0x30 && looks_like_spki_sequence(&input[value_start..value_end]) {
722        return Some(input[offset..next].to_vec());
723    }
724
725    // Recurse into constructed values.
726    if (tag & 0x20) != 0 {
727        if let Some(found) = extract_spki_sequence_recursive(&input[value_start..value_end], 0) {
728            return Some(found);
729        }
730    }
731
732    // BIT STRING often wraps an encoded key structure. First octet is
733    // "unused bits" count, then nested DER payload.
734    if tag == 0x03 && value_start < value_end {
735        let bit_payload = &input[value_start..value_end];
736        if bit_payload.first() == Some(&0) && bit_payload.len() > 1 {
737            if let Some(found) = extract_spki_sequence_recursive(&bit_payload[1..], 0) {
738                return Some(found);
739            }
740        }
741    }
742
743    extract_spki_sequence_recursive(input, next)
744}
745
746fn looks_like_spki_sequence(seq_value: &[u8]) -> bool {
747    let Some((alg_tag, _, _, alg_next)) = parse_der_tlv(seq_value, 0) else {
748        return false;
749    };
750    if alg_tag != 0x30 {
751        return false;
752    }
753    let Some((key_tag, _, _, key_next)) = parse_der_tlv(seq_value, alg_next) else {
754        return false;
755    };
756    key_tag == 0x03 && key_next == seq_value.len()
757}
758
759fn parse_der_tlv(input: &[u8], offset: usize) -> Option<(u8, usize, usize, usize)> {
760    if offset + 2 > input.len() {
761        return None;
762    }
763
764    let tag = input[offset];
765    let len_first = input[offset + 1];
766    let mut len_idx = offset + 2;
767
768    let len = if (len_first & 0x80) == 0 {
769        len_first as usize
770    } else {
771        let nbytes = (len_first & 0x7f) as usize;
772        if nbytes == 0 || nbytes > 4 || len_idx + nbytes > input.len() {
773            return None;
774        }
775        let mut v = 0usize;
776        for b in &input[len_idx..len_idx + nbytes] {
777            v = (v << 8) | (*b as usize);
778        }
779        len_idx += nbytes;
780        v
781    };
782
783    let value_start = len_idx;
784    let value_end = value_start.checked_add(len)?;
785    if value_end > input.len() {
786        return None;
787    }
788
789    Some((tag, value_start, value_end, value_end))
790}