Skip to content

Commit 30efd24

Browse files
authored
Merge pull request #11131 from Turbo87/trustpub-token-exchange
Add `PUT /api/v1/trusted_publishing/tokens` API endpoint
2 parents 55dc989 + 2c8c845 commit 30efd24

29 files changed

+2165
-7
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,12 @@ crates_io_index = { path = "crates/crates_io_index", features = ["testing"] }
145145
crates_io_tarball = { path = "crates/crates_io_tarball", features = ["builder"] }
146146
crates_io_team_repo = { path = "crates/crates_io_team_repo", features = ["mock"] }
147147
crates_io_test_db = { path = "crates/crates_io_test_db" }
148+
crates_io_trustpub = { path = "crates/crates_io_trustpub", features = ["test-helpers"] }
148149
claims = "=0.8.0"
149150
diesel = { version = "=2.2.10", features = ["r2d2"] }
150151
googletest = "=0.14.1"
151152
insta = { version = "=1.43.1", features = ["glob", "json", "redactions"] }
153+
jsonwebtoken = "=9.3.1"
152154
regex = "=1.11.1"
153155
sentry = { version = "=0.38.1", features = ["test"] }
154156
tokio = "=1.45.1"

crates/crates_io_database/src/models/category.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::schema::*;
33
use chrono::{DateTime, Utc};
44
use diesel::dsl;
55
use diesel::prelude::*;
6+
use diesel::sql_types::Text;
67
use diesel_async::scoped_futures::ScopedFutureExt;
78
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
89
use futures_util::FutureExt;
@@ -19,7 +20,7 @@ pub struct Category {
1920
pub created_at: DateTime<Utc>,
2021
}
2122

22-
type WithSlug<'a> = dsl::Eq<categories::slug, crates_io_diesel_helpers::lower<&'a str>>;
23+
type WithSlug<'a> = dsl::Eq<categories::slug, crates_io_diesel_helpers::lower<Text, &'a str>>;
2324

2425
#[derive(Associations, Insertable, Identifiable, Debug, Clone, Copy)]
2526
#[diesel(

crates/crates_io_diesel_helpers/src/fns.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use diesel::sql_types::{Date, Double, Integer, Interval, SingleValue, Text, Time
44
define_sql_function!(#[aggregate] fn array_agg<T: SingleValue>(x: T) -> Array<T>);
55
define_sql_function!(fn canon_crate_name(x: Text) -> Text);
66
define_sql_function!(fn to_char(a: Date, b: Text) -> Text);
7-
define_sql_function!(fn lower(x: Text) -> Text);
7+
define_sql_function!(fn lower<T: SingleValue>(x: T) -> T);
88
define_sql_function!(fn date_part(x: Text, y: Timestamptz) -> Double);
99
define_sql_function! {
1010
#[sql_name = "date_part"]

crates/crates_io_trustpub/Cargo.toml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,31 @@ edition = "2024"
77
[lints]
88
workspace = true
99

10+
[features]
11+
test-helpers = ["dep:bon", "dep:mockall", "dep:serde_json"]
12+
1013
[dependencies]
14+
anyhow = "=1.0.98"
15+
async-trait = "=0.1.88"
16+
bon = { version = "=3.6.3", optional = true }
17+
chrono = { version = "=0.4.41", features = ["serde"] }
18+
jsonwebtoken = "=9.3.1"
19+
mockall = { version = "=0.13.1", optional = true }
20+
rand = "=0.9.1"
21+
reqwest = { version = "=0.12.15", features = ["gzip", "json"] }
1122
regex = "=1.11.1"
23+
secrecy = "=0.10.3"
24+
serde = { version = "=1.0.219", features = ["derive"] }
25+
serde_json = { version = "=1.0.140", optional = true }
26+
sha2 = "=0.10.9"
1227
thiserror = "=2.0.12"
28+
tokio = { version = "=1.45.1", features = ["sync"] }
29+
tracing = "=0.1.41"
1330

1431
[dev-dependencies]
32+
bon = "=3.6.3"
1533
claims = "=0.8.0"
16-
insta = "=1.43.1"
34+
insta = { version = "=1.43.1", features = ["json", "redactions"] }
35+
mockito = "=1.7.0"
36+
serde_json = "=1.0.140"
37+
tokio = { version = "=1.45.1", features = ["macros", "rt-multi-thread"] }
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+
}

0 commit comments

Comments
 (0)