Skip to content

Commit b3c57a8

Browse files
committed
WIP
1 parent a2fca3a commit b3c57a8

File tree

19 files changed

+948
-57
lines changed

19 files changed

+948
-57
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ paste = "=1.0.15"
110110
postgres-native-tls = "=0.5.1"
111111
prometheus = { version = "=0.14.0", default-features = false }
112112
rand = "=0.9.1"
113+
regex = "=1.11.1"
113114
reqwest = { version = "=0.12.15", features = ["gzip", "json"] }
114115
rss = { version = "=2.0.12", default-features = false, features = ["atom"] }
115116
secrecy = "=0.10.3"
@@ -143,11 +144,12 @@ crates_io_index = { path = "crates/crates_io_index", features = ["testing"] }
143144
crates_io_tarball = { path = "crates/crates_io_tarball", features = ["builder"] }
144145
crates_io_team_repo = { path = "crates/crates_io_team_repo", features = ["mock"] }
145146
crates_io_test_db = { path = "crates/crates_io_test_db" }
147+
crates_io_trustpub = { path = "crates/crates_io_trustpub", features = ["mock"] }
146148
claims = "=0.8.0"
147149
diesel = { version = "=2.2.10", features = ["r2d2"] }
148150
googletest = "=0.14.0"
149151
insta = { version = "=1.43.1", features = ["glob", "json", "redactions"] }
150-
regex = "=1.11.1"
152+
jsonwebtoken = "=9.3.1"
151153
sentry = { version = "=0.37.0", features = ["test"] }
152154
tokio = "=1.45.0"
153155
zip = { version = "=2.6.1", default-features = false, features = ["deflate"] }

crates/crates_io_trustpub/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ edition = "2024"
88
workspace = true
99

1010
[features]
11-
mock = ["dep:mockall"]
11+
mock = ["dep:mockall", "dep:serde_json"]
1212

1313
[dependencies]
1414
anyhow = "=1.0.98"
@@ -19,6 +19,7 @@ mockall = { version = "=0.13.1", optional = true }
1919
reqwest = { version = "=0.12.15", features = ["gzip", "json"] }
2020
regex = "=1.11.1"
2121
serde = { version = "=1.0.219", features = ["derive"] }
22+
serde_json = { version = "=1.0.140", optional = true }
2223
thiserror = "=2.0.12"
2324
tokio = { version = "=1.45.0", features = ["sync"] }
2425

crates/crates_io_trustpub/src/keystore/mod.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,21 @@ pub trait OidcKeyStore: Send + Sync {
1818
/// is an error while fetching the key, it will return an error.
1919
async fn get_oidc_key(&self, key_id: &str) -> anyhow::Result<Option<DecodingKey>>;
2020
}
21+
22+
#[cfg(feature = "mock")]
23+
impl MockOidcKeyStore {
24+
/// Creates a new instance of [`MockOidcKeyStore`] based on the RSA keys
25+
/// provided in the [`crate::test_keys`] module.
26+
pub fn with_test_key() -> Self {
27+
use crate::test_keys::{DECODING_KEY, KEY_ID};
28+
use mockall::predicate::*;
29+
30+
let mut mock = Self::new();
31+
32+
mock.expect_get_oidc_key()
33+
.with(eq(KEY_ID))
34+
.returning(|_| Ok(Some(DECODING_KEY.clone())));
35+
36+
mock
37+
}
38+
}

crates/crates_io_trustpub/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
pub mod github;
44
pub mod keystore;
5-
#[cfg(test)]
5+
#[cfg(any(test, feature = "mock"))]
66
pub mod test_keys;
77
pub mod unverified;
88

src/bin/server.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
#[macro_use]
22
extern crate tracing;
33

4+
use axum::ServiceExt;
45
use crates_io::middleware::normalize_path::normalize_path;
56
use crates_io::{App, Emails, metrics::LogEncoder};
6-
use std::{sync::Arc, time::Duration};
7-
8-
use axum::ServiceExt;
97
use crates_io_github::RealGitHubClient;
8+
use crates_io_trustpub::github::GITHUB_ISSUER_URL;
9+
use crates_io_trustpub::keystore::{OidcKeyStore, RealOidcKeyStore};
1010
use prometheus::Encoder;
1111
use reqwest::Client;
12+
use std::collections::HashMap;
1213
use std::io::Write;
1314
use std::net::SocketAddr;
15+
use std::{sync::Arc, time::Duration};
1416
use tokio::net::TcpListener;
1517
use tokio::signal::unix::{SignalKind, signal};
1618
use tower::Layer;
@@ -33,10 +35,15 @@ fn main() -> anyhow::Result<()> {
3335
let github = RealGitHubClient::new(client);
3436
let github = Box::new(github);
3537

38+
let github_key_store: Box<dyn OidcKeyStore> = Box::new(RealOidcKeyStore::github());
39+
let oidc_key_stores: HashMap<String, Box<dyn OidcKeyStore>> =
40+
HashMap::from([(GITHUB_ISSUER_URL.into(), github_key_store)]);
41+
3642
let app = App::builder()
3743
.databases_from_config(&config.db)
3844
.github(github)
3945
.github_oauth_from_config(&config)
46+
.oidc_key_stores(oidc_key_stores)
4047
.emails(emails)
4148
.storage_from_config(&config.storage)
4249
.rate_limiter_from_config(config.rate_limiter.clone())

src/controllers/krate/publish.rs

Lines changed: 103 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
//! Functionality related to publishing a new crate or version of a crate.
22
33
use crate::app::AppState;
4-
use crate::auth::AuthCheck;
4+
use crate::auth::{AuthCheck, Authentication};
5+
use crate::models::{
6+
Category, Crate, DependencyKind, Keyword, NewCrate, NewVersion, NewVersionOwnerAction,
7+
VersionAction, default_versions::Version as DefaultVersion,
8+
};
59
use crate::worker::jobs::{
610
self, CheckTyposquat, SendPublishNotificationsJob, UpdateDefaultVersion,
711
};
@@ -11,27 +15,22 @@ use cargo_manifest::{Dependency, DepsSet, TargetDepsSet};
1115
use chrono::{DateTime, SecondsFormat, Utc};
1216
use crates_io_tarball::{TarballError, process_tarball};
1317
use crates_io_worker::{BackgroundJob, EnqueueError};
14-
use diesel::dsl::{exists, select};
18+
use diesel::dsl::{exists, now, select};
1519
use diesel::prelude::*;
1620
use diesel::sql_types::Timestamptz;
1721
use diesel_async::scoped_futures::ScopedFutureExt;
1822
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};
1923
use futures_util::TryFutureExt;
2024
use futures_util::TryStreamExt;
2125
use hex::ToHex;
22-
use http::StatusCode;
2326
use http::request::Parts;
27+
use http::{StatusCode, header};
2428
use sha2::{Digest, Sha256};
2529
use std::collections::HashMap;
2630
use tokio::io::{AsyncRead, AsyncReadExt};
2731
use tokio_util::io::StreamReader;
2832
use url::Url;
2933

30-
use crate::models::{
31-
Category, Crate, DependencyKind, Keyword, NewCrate, NewVersion, NewVersionOwnerAction,
32-
VersionAction, default_versions::Version as DefaultVersion,
33-
};
34-
3534
use crate::controllers::helpers::authorization::Rights;
3635
use crate::licenses::parse_license_expr;
3736
use crate::middleware::log_request::RequestLogExt;
@@ -51,6 +50,11 @@ const MISSING_RIGHTS_ERROR_MESSAGE: &str = "this crate exists but you don't seem
5150

5251
const MAX_DESCRIPTION_LENGTH: usize = 1000;
5352

53+
enum AuthType {
54+
Regular(Box<Authentication>),
55+
Oidc(),
56+
}
57+
5458
/// Publish a new crate/version.
5559
///
5660
/// Used by `cargo publish` to publish a new crate or to publish a new version of an
@@ -130,30 +134,62 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
130134
None => EndpointScope::PublishNew,
131135
};
132136

133-
let auth = AuthCheck::default()
134-
.with_endpoint_scope(endpoint_scope)
135-
.for_crate(&metadata.name)
136-
.check(&req, &mut conn)
137-
.await?;
137+
let bearer = req
138+
.headers
139+
.get(header::AUTHORIZATION)
140+
.and_then(|h| h.as_bytes().strip_prefix(b"Bearer "));
138141

139-
let verified_email_address = auth.user().verified_email(&mut conn).await?;
140-
let verified_email_address = verified_email_address.ok_or_else(|| {
141-
bad_request(format!(
142-
"A verified email address is required to publish crates to crates.io. \
143-
Visit https://{}/settings/profile to set and verify your email address.",
144-
app.config.domain_name,
145-
))
146-
})?;
142+
let auth = match (bearer, &existing_crate) {
143+
(Some(bearer), Some(existing_crate)) if bearer.starts_with(b"crates.io/oidc/") => {
144+
let hashed_token = Sha256::digest(bearer);
145+
146+
trustpub_tokens::table
147+
.filter(trustpub_tokens::crate_ids.contains(vec![existing_crate.id]))
148+
.filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice()))
149+
.filter(trustpub_tokens::expires_at.gt(now))
150+
.select(trustpub_tokens::id)
151+
.get_result::<i64>(&mut conn)
152+
.await?;
147153

148-
// Use a different rate limit whether this is a new or an existing crate.
149-
let rate_limit_action = match existing_crate {
150-
Some(_) => LimitedAction::PublishUpdate,
151-
None => LimitedAction::PublishNew,
154+
AuthType::Oidc()
155+
}
156+
_ => {
157+
let auth = AuthCheck::default()
158+
.with_endpoint_scope(endpoint_scope)
159+
.for_crate(&metadata.name)
160+
.check(&req, &mut conn)
161+
.await?;
162+
163+
AuthType::Regular(Box::new(auth))
164+
}
152165
};
153166

154-
app.rate_limiter
155-
.check_rate_limit(auth.user().id, rate_limit_action, &mut conn)
156-
.await?;
167+
let verified_email_address = match &auth {
168+
AuthType::Regular(auth) => {
169+
let verified_email_address = auth.user().verified_email(&mut conn).await?;
170+
let verified_email_address = verified_email_address.ok_or_else(|| {
171+
bad_request(format!(
172+
"A verified email address is required to publish crates to crates.io. \
173+
Visit https://{}/settings/profile to set and verify your email address.",
174+
app.config.domain_name,
175+
))
176+
})?;
177+
Some(verified_email_address)
178+
}
179+
AuthType::Oidc() => None,
180+
};
181+
182+
if let AuthType::Regular(auth) = &auth {
183+
// Use a different rate limit whether this is a new or an existing crate.
184+
let rate_limit_action = match existing_crate {
185+
Some(_) => LimitedAction::PublishUpdate,
186+
None => LimitedAction::PublishNew,
187+
};
188+
189+
app.rate_limiter
190+
.check_rate_limit(auth.user().id, rate_limit_action, &mut conn)
191+
.await?;
192+
}
157193

158194
let max_upload_size = existing_crate
159195
.as_ref()
@@ -342,9 +378,6 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
342378
validate_dependency(dep)?;
343379
}
344380

345-
let api_token_id = auth.api_token_id();
346-
let user = auth.user();
347-
348381
// Create a transaction on the database, if there are no errors,
349382
// commit the transactions to record a new or updated crate.
350383
conn.transaction(|conn| async move {
@@ -368,17 +401,29 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
368401
return Err(bad_request("cannot upload a crate with a reserved name"));
369402
}
370403

371-
// To avoid race conditions, we try to insert
372-
// first so we know whether to add an owner
373-
let krate = match persist.create(conn, user.id).await.optional()? {
374-
Some(krate) => krate,
375-
None => persist.update(conn).await?,
376-
};
404+
let krate = match &auth {
405+
AuthType::Regular(auth) => {
406+
let user = auth.user();
377407

378-
let owners = krate.owners(conn).await?;
379-
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
380-
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
381-
}
408+
// To avoid race conditions, we try to insert
409+
// first so we know whether to add an owner
410+
let krate = match persist.create(conn, user.id).await.optional()? {
411+
Some(krate) => krate,
412+
None => persist.update(conn).await?,
413+
};
414+
415+
let owners = krate.owners(conn).await?;
416+
if Rights::get(user, &*app.github, &owners).await? < Rights::Publish {
417+
return Err(custom(StatusCode::FORBIDDEN, MISSING_RIGHTS_ERROR_MESSAGE));
418+
}
419+
420+
krate
421+
}
422+
AuthType::Oidc() => {
423+
// OIDC does not support creating new crates
424+
persist.update(conn).await?
425+
}
426+
};
382427

383428
if krate.name != *name {
384429
return Err(bad_request(format_args!(
@@ -407,6 +452,11 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
407452

408453
let edition = edition.map(|edition| edition.as_str());
409454

455+
let published_by = match &auth {
456+
AuthType::Regular(auth) => Some(auth.user().id),
457+
AuthType::Oidc() => None,
458+
};
459+
410460
// Read tarball from request
411461
let hex_cksum: String = Sha256::digest(&tarball_bytes).encode_hex();
412462

@@ -417,7 +467,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
417467
// Downcast is okay because the file length must be less than the max upload size
418468
// to get here, and max upload sizes are way less than i32 max
419469
.size(content_length as i32)
420-
.published_by(user.id)
470+
.maybe_published_by(published_by)
421471
.checksum(&hex_cksum)
422472
.maybe_links(package.links.as_deref())
423473
.maybe_rust_version(rust_version.as_deref())
@@ -432,7 +482,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
432482
.keywords(&keywords)
433483
.build();
434484

435-
let version = new_version.save(conn, &verified_email_address).await.map_err(|error| {
485+
let version = new_version.save(conn, verified_email_address.as_deref()).await.map_err(|error| {
436486
use diesel::result::{Error, DatabaseErrorKind};
437487
match error {
438488
Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _) =>
@@ -441,14 +491,16 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult<Json<Go
441491
}
442492
})?;
443493

444-
NewVersionOwnerAction::builder()
445-
.version_id(version.id)
446-
.user_id(user.id)
447-
.maybe_api_token_id(api_token_id)
448-
.action(VersionAction::Publish)
449-
.build()
450-
.insert(conn)
451-
.await?;
494+
if let AuthType::Regular(auth) = &auth {
495+
NewVersionOwnerAction::builder()
496+
.version_id(version.id)
497+
.user_id(auth.user().id)
498+
.maybe_api_token_id(auth.api_token_id())
499+
.action(VersionAction::Publish)
500+
.build()
501+
.insert(conn)
502+
.await?;
503+
}
452504

453505
// Link this new version to all dependencies
454506
add_dependencies(conn, &deps, version.id).await?;

src/controllers/trustpub/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pub mod github_configs;
2+
pub mod tokens;

0 commit comments

Comments
 (0)