Skip to main content

keyhog_scanner/checksum/
github.rs

1use super::{ChecksumResult, ChecksumValidator};
2
3const BASE62_DIGITS: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
4
5/// Compute the standard CRC32 checksum of `data`.
6pub(super) fn crc32(data: &[u8]) -> u32 {
7    const TABLE: [u32; 256] = {
8        let mut table = [0u32; 256];
9        let mut i = 0;
10        while i < 256 {
11            let mut crc = i as u32;
12            let mut j = 0;
13            while j < 8 {
14                if crc & 1 != 0 {
15                    crc = 0xEDB88320 ^ (crc >> 1);
16                } else {
17                    crc >>= 1;
18                }
19                j += 1;
20            }
21            table[i] = crc;
22            i += 1;
23        }
24        table
25    };
26
27    let mut crc: u32 = 0xFFFF_FFFF;
28    for &byte in data {
29        crc = TABLE[((crc ^ (byte as u32)) & 0xFF) as usize] ^ (crc >> 8);
30    }
31    crc ^ 0xFFFF_FFFF
32}
33
34/// Encode a `u32` as base62, left-padded with `'0'` to `width` characters.
35pub(super) fn base62_encode_u32(mut value: u32, width: usize) -> String {
36    if value == 0 {
37        return "0".repeat(width);
38    }
39    let mut rev = Vec::with_capacity(width.max(6));
40    while value > 0 {
41        rev.push(BASE62_DIGITS[(value % 62) as usize] as char);
42        value /= 62;
43    }
44    while rev.len() < width {
45        rev.push('0');
46    }
47    rev.reverse();
48    rev.into_iter().collect()
49}
50
51/// Decode a base62 string. Returns `None` if any character is outside the digit alphabet.
52#[allow(dead_code)]
53pub(super) fn base62_decode(s: &str) -> Option<u32> {
54    let mut value: u32 = 0;
55    for ch in s.chars() {
56        let digit = BASE62_DIGITS.iter().position(|&d| d == ch as u8)? as u32;
57        value = value.checked_mul(62)?.checked_add(digit)?;
58    }
59    Some(value)
60}
61
62/// Validates GitHub classic personal access tokens.
63///
64/// Format: `ghp_` + 30-character entropy + 6-character base62 CRC32 checksum.
65/// The CRC32 is computed over the 30-character entropy portion only.
66pub struct GithubClassicPatValidator;
67
68impl ChecksumValidator for GithubClassicPatValidator {
69    fn validator_id(&self) -> &str {
70        "github-classic-pat"
71    }
72
73    fn validate(&self, credential: &str) -> ChecksumResult {
74        let payload = match credential.strip_prefix("ghp_") {
75            Some(p) => p,
76            None => return ChecksumResult::NotApplicable,
77        };
78        if payload.len() != 36 {
79            return ChecksumResult::NotApplicable;
80        }
81        if !payload.chars().all(|c| c.is_ascii_alphanumeric()) {
82            return ChecksumResult::Invalid;
83        }
84        let entropy = &payload[..30];
85        let checksum_str = &payload[30..];
86        let expected = base62_encode_u32(crc32(entropy.as_bytes()), 6);
87        if expected == checksum_str {
88            ChecksumResult::Valid
89        } else {
90            ChecksumResult::Invalid
91        }
92    }
93}
94
95/// Validates GitHub fine-grained personal access tokens.
96///
97/// Format: `github_pat_` + 22 alphanumeric chars + `_` + 59 alphanumeric chars.
98pub struct GithubFineGrainedPatValidator;
99
100impl GithubFineGrainedPatValidator {
101    fn try_payload(payload: &str) -> ChecksumResult {
102        if payload.len() < 7 {
103            return ChecksumResult::Invalid;
104        }
105        let entropy = &payload[..payload.len() - 6];
106        let checksum_str = &payload[payload.len() - 6..];
107        let expected = base62_encode_u32(crc32(entropy.as_bytes()), 6);
108        if expected == checksum_str {
109            ChecksumResult::Valid
110        } else {
111            ChecksumResult::Invalid
112        }
113    }
114}
115
116impl ChecksumValidator for GithubFineGrainedPatValidator {
117    fn validator_id(&self) -> &str {
118        "github-fine-grained-pat"
119    }
120
121    fn validate(&self, credential: &str) -> ChecksumResult {
122        let Some(payload) = credential.strip_prefix("github_pat_") else {
123            return ChecksumResult::NotApplicable;
124        };
125        let parts: Vec<&str> = payload.split('_').collect();
126        if parts.len() != 2 {
127            return ChecksumResult::Invalid;
128        }
129        let (left, right) = (parts[0], parts[1]);
130        if left.len() != 22 || right.len() != 59 {
131            return ChecksumResult::Invalid;
132        }
133        if !left.chars().all(|c| c.is_ascii_alphanumeric())
134            || !right.chars().all(|c| c.is_ascii_alphanumeric())
135        {
136            return ChecksumResult::Invalid;
137        }
138
139        if Self::try_payload(payload) == ChecksumResult::Valid {
140            return ChecksumResult::Valid;
141        }
142        if Self::try_payload(right) == ChecksumResult::Valid {
143            return ChecksumResult::Valid;
144        }
145        ChecksumResult::Invalid
146    }
147}