Skip to content

Commit a4b8069

Browse files
committed
trustpub: Implement AccessToken struct
1 parent a3318cb commit a4b8069

File tree

4 files changed

+181
-0
lines changed

4 files changed

+181
-0
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/crates_io_trustpub/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ bon = { version = "=3.6.3", optional = true }
1717
chrono = { version = "=0.4.41", features = ["serde"] }
1818
jsonwebtoken = "=9.3.1"
1919
mockall = { version = "=0.13.1", optional = true }
20+
rand = "=0.9.1"
2021
reqwest = { version = "=0.12.15", features = ["gzip", "json"] }
2122
regex = "=1.11.1"
23+
secrecy = "=0.10.3"
2224
serde = { version = "=1.0.219", features = ["derive"] }
2325
serde_json = { version = "=1.0.140", optional = true }
26+
sha2 = "=0.10.9"
2427
thiserror = "=2.0.12"
2528
tokio = { version = "=1.45.0", features = ["sync"] }
2629

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
}

crates/crates_io_trustpub/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#![doc = include_str!("../README.md")]
22

3+
pub mod access_token;
34
pub mod github;
45
pub mod keystore;
56
#[cfg(any(test, feature = "test-helpers"))]

0 commit comments

Comments
 (0)