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/// Validates GitHub classic personal access tokens.
52///
53/// Format: `ghp_` + 30-character entropy + 6-character base62 CRC32 checksum.
54/// The CRC32 is computed over the 30-character entropy portion only.
55pub struct GithubClassicPatValidator;
56
57impl ChecksumValidator for GithubClassicPatValidator {
58    fn validator_id(&self) -> &str {
59        "github-classic-pat"
60    }
61
62    fn validate(&self, credential: &str) -> ChecksumResult {
63        let payload = match credential.strip_prefix("ghp_") {
64            Some(p) => p,
65            None => return ChecksumResult::NotApplicable,
66        };
67        if payload.len() != 36 {
68            return ChecksumResult::NotApplicable;
69        }
70        if !payload.chars().all(|c| c.is_ascii_alphanumeric()) {
71            return ChecksumResult::Invalid;
72        }
73        let entropy = &payload[..30];
74        let checksum_str = &payload[30..];
75        let expected = base62_encode_u32(crc32(entropy.as_bytes()), 6);
76        if expected == checksum_str {
77            ChecksumResult::Valid
78        } else {
79            // A well-formed `ghp_` + 36-alnum token whose trailing 6-char
80            // base62 CRC32 does not match its 30-char body is fabricated or
81            // corrupted - exactly what the checksum exists to reject. The
82            // algorithm is proven correct by the `github_classic_valid` /
83            // `_all_as_valid` oracles, so a mismatch is `Invalid` (capped to
84            // low confidence), not `NotApplicable`. Mirrors the fine-grained
85            // validator, which already rejects on CRC mismatch.
86            ChecksumResult::Invalid
87        }
88    }
89}
90
91/// Validates GitHub fine-grained personal access tokens.
92///
93/// Format: `github_pat_` + 22 alphanumeric chars + `_` + 59 alphanumeric chars.
94pub struct GithubFineGrainedPatValidator;
95
96impl GithubFineGrainedPatValidator {
97    fn try_payload(payload: &str) -> ChecksumResult {
98        if payload.len() < 7 {
99            return ChecksumResult::Invalid;
100        }
101        let entropy = &payload[..payload.len() - 6];
102        let checksum_str = &payload[payload.len() - 6..];
103        let expected = base62_encode_u32(crc32(entropy.as_bytes()), 6);
104        if expected == checksum_str {
105            ChecksumResult::Valid
106        } else {
107            ChecksumResult::Invalid
108        }
109    }
110}
111
112impl ChecksumValidator for GithubFineGrainedPatValidator {
113    fn validator_id(&self) -> &str {
114        "github-fine-grained-pat"
115    }
116
117    fn validate(&self, credential: &str) -> ChecksumResult {
118        let Some(payload) = credential.strip_prefix("github_pat_") else {
119            return ChecksumResult::NotApplicable;
120        };
121        let parts: Vec<&str> = payload.split('_').collect();
122        if parts.len() != 2 {
123            return ChecksumResult::Invalid;
124        }
125        let (left, right) = (parts[0], parts[1]);
126        if left.len() != 22 || right.len() != 59 {
127            return ChecksumResult::Invalid;
128        }
129        if !left.chars().all(|c| c.is_ascii_alphanumeric())
130            || !right.chars().all(|c| c.is_ascii_alphanumeric())
131        {
132            return ChecksumResult::Invalid;
133        }
134
135        if Self::try_payload(payload) == ChecksumResult::Valid {
136            return ChecksumResult::Valid;
137        }
138        if Self::try_payload(right) == ChecksumResult::Valid {
139            return ChecksumResult::Valid;
140        }
141        ChecksumResult::Invalid
142    }
143}