|
| 1 | +use rand::distr::{Alphanumeric, SampleString}; |
| 2 | +use secrecy::{ExposeSecret, SecretString}; |
| 3 | +use sha2::digest::Output; |
| 4 | +use sha2::{Digest, Sha256}; |
| 5 | + |
| 6 | +/// A temporary access token used to publish crates to crates.io using |
| 7 | +/// the "Trusted Publishing" feature. |
| 8 | +/// |
| 9 | +/// The token consists of a prefix, a random alphanumeric string (31 characters), |
| 10 | +/// and a single-character checksum. |
| 11 | +#[derive(Debug)] |
| 12 | +pub struct AccessToken(SecretString); |
| 13 | + |
| 14 | +impl AccessToken { |
| 15 | + /// The prefix used for the temporary access token. |
| 16 | + /// |
| 17 | + /// This overlaps with the `cio` prefix used for other tokens, but since |
| 18 | + /// the regular tokens don't use `_` characters, they can easily be |
| 19 | + /// distinguished. |
| 20 | + pub const PREFIX: &str = "cio_tp_"; |
| 21 | + |
| 22 | + /// The length of the random alphanumeric string in the token, without |
| 23 | + /// the checksum. |
| 24 | + const RAW_LENGTH: usize = 31; |
| 25 | + |
| 26 | + /// Generate a new random access token. |
| 27 | + pub fn generate() -> Self { |
| 28 | + let raw = Alphanumeric.sample_string(&mut rand::rng(), Self::RAW_LENGTH); |
| 29 | + Self(raw.into()) |
| 30 | + } |
| 31 | + |
| 32 | + /// Parse a byte string into an access token. |
| 33 | + /// |
| 34 | + /// This can be used to convert an HTTP header value into an access token. |
| 35 | + pub fn from_byte_str(byte_str: &[u8]) -> Result<Self, AccessTokenError> { |
| 36 | + let suffix = byte_str |
| 37 | + .strip_prefix(Self::PREFIX.as_bytes()) |
| 38 | + .ok_or(AccessTokenError::MissingPrefix)?; |
| 39 | + |
| 40 | + if suffix.len() != Self::RAW_LENGTH + 1 { |
| 41 | + return Err(AccessTokenError::InvalidLength); |
| 42 | + } |
| 43 | + |
| 44 | + let suffix = std::str::from_utf8(suffix).map_err(|_| AccessTokenError::InvalidCharacter)?; |
| 45 | + if !suffix.chars().all(|c| char::is_ascii_alphanumeric(&c)) { |
| 46 | + return Err(AccessTokenError::InvalidCharacter); |
| 47 | + } |
| 48 | + |
| 49 | + let raw = suffix.chars().take(Self::RAW_LENGTH).collect::<String>(); |
| 50 | + let claimed_checksum = suffix.chars().nth(Self::RAW_LENGTH).unwrap(); |
| 51 | + let actual_checksum = checksum(raw.as_bytes()); |
| 52 | + if claimed_checksum != actual_checksum { |
| 53 | + return Err(AccessTokenError::InvalidChecksum { |
| 54 | + claimed: claimed_checksum, |
| 55 | + actual: actual_checksum, |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + Ok(Self(raw.into())) |
| 60 | + } |
| 61 | + |
| 62 | + /// Wrap the raw access token with the token prefix and a checksum. |
| 63 | + /// |
| 64 | + /// This turns e.g. `ABC` into `cio_tp_ABC{checksum}`. |
| 65 | + pub fn finalize(&self) -> SecretString { |
| 66 | + let raw = self.0.expose_secret(); |
| 67 | + let checksum = checksum(raw.as_bytes()); |
| 68 | + format!("{}{raw}{checksum}", Self::PREFIX).into() |
| 69 | + } |
| 70 | + |
| 71 | + /// Generate a SHA256 hash of the access token. |
| 72 | + /// |
| 73 | + /// This is used to create a hashed version of the token for storage in |
| 74 | + /// the database to avoid storing the plaintext token. |
| 75 | + pub fn sha256(&self) -> Output<Sha256> { |
| 76 | + Sha256::digest(self.0.expose_secret()) |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +/// The error type for parsing access tokens. |
| 81 | +#[derive(Debug, Clone, PartialEq, Eq)] |
| 82 | +pub enum AccessTokenError { |
| 83 | + MissingPrefix, |
| 84 | + InvalidLength, |
| 85 | + InvalidCharacter, |
| 86 | + InvalidChecksum { claimed: char, actual: char }, |
| 87 | +} |
| 88 | + |
| 89 | +/// Generate a single-character checksum for the given raw token. |
| 90 | +/// |
| 91 | +/// Note that this checksum is not cryptographically secure and should not be |
| 92 | +/// used for security purposes. It should only be used to detect invalid tokens. |
| 93 | +fn checksum(raw: &[u8]) -> char { |
| 94 | + const ALPHANUMERIC: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; |
| 95 | + |
| 96 | + let checksum = raw.iter().fold(0, |acc, &b| acc ^ b); |
| 97 | + |
| 98 | + ALPHANUMERIC |
| 99 | + .chars() |
| 100 | + .nth(checksum as usize % ALPHANUMERIC.len()) |
| 101 | + .unwrap_or('0') |
| 102 | +} |
| 103 | + |
| 104 | +#[cfg(test)] |
| 105 | +mod tests { |
| 106 | + use super::*; |
| 107 | + use claims::{assert_err_eq, assert_ok}; |
| 108 | + use insta::{assert_compact_debug_snapshot, assert_snapshot}; |
| 109 | + |
| 110 | + const EXAMPLE_TOKEN: &str = "gGK6jurSwKyl9V3Az19z7YEFQI9aoOO"; |
| 111 | + |
| 112 | + #[test] |
| 113 | + fn test_generate() { |
| 114 | + let token = AccessToken::generate(); |
| 115 | + assert_eq!(token.0.expose_secret().len(), AccessToken::RAW_LENGTH); |
| 116 | + } |
| 117 | + |
| 118 | + #[test] |
| 119 | + fn test_finalize() { |
| 120 | + let token = AccessToken(SecretString::from(EXAMPLE_TOKEN)); |
| 121 | + assert_snapshot!(token.finalize().expose_secret(), @"cio_tp_gGK6jurSwKyl9V3Az19z7YEFQI9aoOOd"); |
| 122 | + } |
| 123 | + |
| 124 | + #[test] |
| 125 | + fn test_sha256() { |
| 126 | + let token = AccessToken(SecretString::from(EXAMPLE_TOKEN)); |
| 127 | + let hash = token.sha256(); |
| 128 | + assert_compact_debug_snapshot!(hash.as_slice(), @"[11, 102, 58, 175, 81, 174, 38, 227, 173, 48, 158, 96, 20, 130, 99, 78, 7, 16, 241, 211, 195, 166, 110, 74, 193, 126, 53, 125, 42, 21, 23, 124]"); |
| 129 | + } |
| 130 | + |
| 131 | + #[test] |
| 132 | + fn test_from_byte_str() { |
| 133 | + let token = AccessToken::generate().finalize(); |
| 134 | + let token = token.expose_secret(); |
| 135 | + let token2 = assert_ok!(AccessToken::from_byte_str(token.as_bytes())); |
| 136 | + assert_eq!(token2.finalize().expose_secret(), token); |
| 137 | + |
| 138 | + let bytes = b"cio_tp_0000000000000000000000000000000w"; |
| 139 | + assert_ok!(AccessToken::from_byte_str(bytes)); |
| 140 | + |
| 141 | + let bytes = b"invalid_token"; |
| 142 | + assert_err_eq!( |
| 143 | + AccessToken::from_byte_str(bytes), |
| 144 | + AccessTokenError::MissingPrefix |
| 145 | + ); |
| 146 | + |
| 147 | + let bytes = b"cio_tp_invalid_token"; |
| 148 | + assert_err_eq!( |
| 149 | + AccessToken::from_byte_str(bytes), |
| 150 | + AccessTokenError::InvalidLength |
| 151 | + ); |
| 152 | + |
| 153 | + let bytes = b"cio_tp_00000000000000000000000000"; |
| 154 | + assert_err_eq!( |
| 155 | + AccessToken::from_byte_str(bytes), |
| 156 | + AccessTokenError::InvalidLength |
| 157 | + ); |
| 158 | + |
| 159 | + let bytes = b"cio_tp_000000@0000000000000000000000000"; |
| 160 | + assert_err_eq!( |
| 161 | + AccessToken::from_byte_str(bytes), |
| 162 | + AccessTokenError::InvalidCharacter |
| 163 | + ); |
| 164 | + |
| 165 | + let bytes = b"cio_tp_00000000000000000000000000000000"; |
| 166 | + assert_err_eq!( |
| 167 | + AccessToken::from_byte_str(bytes), |
| 168 | + AccessTokenError::InvalidChecksum { |
| 169 | + claimed: '0', |
| 170 | + actual: 'w', |
| 171 | + } |
| 172 | + ); |
| 173 | + } |
| 174 | +} |
0 commit comments