Skip to main content

keyhog_scanner/checksum/
npm.rs

1use super::github::{base62_encode_u32, crc32};
2use super::{ChecksumResult, ChecksumValidator};
3
4/// Validates modern npm access tokens.
5///
6/// New-format npm tokens follow the same design as GitHub tokens:
7/// `npm_` + 30-character entropy + 6-character base62 CRC32 checksum.
8pub struct NpmTokenValidator;
9
10impl ChecksumValidator for NpmTokenValidator {
11    fn validator_id(&self) -> &str {
12        "npm-access-token"
13    }
14
15    fn validate(&self, credential: &str) -> ChecksumResult {
16        let payload = match credential.strip_prefix("npm_") {
17            Some(p) => p,
18            None => return ChecksumResult::NotApplicable,
19        };
20        if payload.len() != 36 {
21            return ChecksumResult::NotApplicable;
22        }
23        if !payload.chars().all(|c| c.is_ascii_alphanumeric()) {
24            return ChecksumResult::Invalid;
25        }
26        let entropy = &payload[..30];
27        let checksum_str = &payload[30..];
28        let expected = base62_encode_u32(crc32(entropy.as_bytes()), 6);
29        if expected == checksum_str {
30            ChecksumResult::Valid
31        } else {
32            ChecksumResult::Invalid
33        }
34    }
35}
36
37/// Validates PyPI API tokens.
38///
39/// PyPI tokens are `pypi-` followed by a base64-encoded macaroon. We cannot
40/// verify the macaroon's HMAC signature without PyPI's secret key, but we can
41/// confirm that the payload is well-formed base64 and decodes to a non-trivial
42/// binary blob.
43pub struct PypiTokenValidator;
44
45impl ChecksumValidator for PypiTokenValidator {
46    fn validator_id(&self) -> &str {
47        "pypi-api-token"
48    }
49
50    fn validate(&self, credential: &str) -> ChecksumResult {
51        let payload = match credential.strip_prefix("pypi-") {
52            Some(p) => p,
53            None => return ChecksumResult::NotApplicable,
54        };
55        if payload.len() < 20 {
56            return ChecksumResult::Invalid;
57        }
58        let decoded =
59            base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, payload)
60                .or_else(|_| {
61                    base64::Engine::decode(
62                        &base64::engine::general_purpose::STANDARD_NO_PAD,
63                        payload,
64                    )
65                })
66                .or_else(|_| {
67                    base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE, payload)
68                })
69                .or_else(|_| {
70                    base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload)
71                });
72
73        match decoded {
74            Ok(bytes) if bytes.len() >= 32 => ChecksumResult::Valid,
75            Ok(_) => ChecksumResult::Invalid,
76            Err(_) => ChecksumResult::Invalid,
77        }
78    }
79}