diff --git a/crates/crates_io_database/src/models/user.rs b/crates/crates_io_database/src/models/user.rs index 06ea0b2d310..4eae5b246ce 100644 --- a/crates/crates_io_database/src/models/user.rs +++ b/crates/crates_io_database/src/models/user.rs @@ -24,7 +24,7 @@ pub struct User { #[serde(skip)] pub gh_access_token: SecretString, #[serde(skip)] - pub gh_encrypted_token: Option>, + pub gh_encrypted_token: Vec, pub account_lock_reason: Option, pub account_lock_until: Option>, pub is_admin: bool, @@ -96,7 +96,7 @@ pub struct NewUser<'a> { pub name: Option<&'a str>, pub gh_avatar: Option<&'a str>, pub gh_access_token: &'a str, - pub gh_encrypted_token: Option<&'a [u8]>, + pub gh_encrypted_token: &'a [u8], } impl NewUser<'_> { diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index 31dd2772e86..ce88578ae13 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -881,7 +881,7 @@ diesel::table! { /// Whether or not the user wants to receive notifications when a package they own is published publish_notifications -> Bool, /// Encrypted GitHub access token - gh_encrypted_token -> Nullable, + gh_encrypted_token -> Bytea, } } diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index f4e2c7c42f4..e426c6e0a05 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -235,6 +235,7 @@ publish_notifications = "private" gh_encrypted_token = "private" [users.column_defaults] gh_access_token = "''" +gh_encrypted_token = "''" [version_downloads] dependencies = ["versions"] diff --git a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap index f5315ad6929..8c70a31925b 100644 --- a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap +++ b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap @@ -24,6 +24,7 @@ BEGIN; -- Set defaults for non-nullable columns not included in the dump. ALTER TABLE "users" ALTER COLUMN "gh_access_token" SET DEFAULT ''; + ALTER TABLE "users" ALTER COLUMN "gh_encrypted_token" SET DEFAULT ''; -- Truncate all tables. @@ -67,6 +68,7 @@ BEGIN; -- Drop the defaults again. ALTER TABLE "users" ALTER COLUMN "gh_access_token" DROP DEFAULT; + ALTER TABLE "users" ALTER COLUMN "gh_encrypted_token" DROP DEFAULT; -- Reenable triggers on each table. diff --git a/migrations/2025-07-16-163013_make_gh_encrypted_token_not_null/down.sql b/migrations/2025-07-16-163013_make_gh_encrypted_token_not_null/down.sql new file mode 100644 index 00000000000..d637ae789af --- /dev/null +++ b/migrations/2025-07-16-163013_make_gh_encrypted_token_not_null/down.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN gh_encrypted_token DROP NOT NULL; diff --git a/migrations/2025-07-16-163013_make_gh_encrypted_token_not_null/up.sql b/migrations/2025-07-16-163013_make_gh_encrypted_token_not_null/up.sql new file mode 100644 index 00000000000..356b050826f --- /dev/null +++ b/migrations/2025-07-16-163013_make_gh_encrypted_token_not_null/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN gh_encrypted_token SET NOT NULL; diff --git a/src/bin/crates-admin/encrypt_github_tokens.rs b/src/bin/crates-admin/encrypt_github_tokens.rs deleted file mode 100644 index 9898c63e3fd..00000000000 --- a/src/bin/crates-admin/encrypt_github_tokens.rs +++ /dev/null @@ -1,108 +0,0 @@ -use anyhow::{Context, Result}; -use crates_io::util::gh_token_encryption::GitHubTokenEncryption; -use crates_io::{db, models::User}; -use crates_io_database::schema::users; -use diesel::prelude::*; -use diesel_async::RunQueryDsl; -use indicatif::{ProgressBar, ProgressIterator, ProgressStyle}; -use secrecy::ExposeSecret; - -#[derive(clap::Parser, Debug)] -#[command( - name = "encrypt-github-tokens", - about = "Encrypt existing plaintext GitHub tokens in the database.", - long_about = "Backfill operation to encrypt existing plaintext GitHub tokens using AES-256-GCM. \ - This reads users with plaintext tokens but no encrypted tokens, encrypts them, and \ - updates the database with the encrypted versions." -)] -pub struct Opts {} - -pub async fn run(_opts: Opts) -> Result<()> { - println!("Starting GitHub token encryption backfill…"); - - // Load encryption configuration - let encryption = GitHubTokenEncryption::from_environment() - .context("Failed to load encryption configuration")?; - - // Get database connection - let mut conn = db::oneoff_connection() - .await - .context("Failed to establish database connection")?; - - // Query users with no encrypted tokens - let users_to_encrypt = users::table - .filter(users::gh_encrypted_token.is_null()) - .select(User::as_select()) - .load(&mut conn) - .await - .context("Failed to query users with plaintext tokens")?; - - let total_users = users_to_encrypt.len(); - if total_users == 0 { - println!("Found no users that need token encryption. Exiting."); - return Ok(()); - } - - println!("Found {total_users} users with plaintext tokens to encrypt"); - - let pb = ProgressBar::new(total_users as u64); - pb.set_style(ProgressStyle::with_template( - "{bar:60} ({pos}/{len}, ETA {eta}) {msg}", - )?); - - let mut encrypted_count = 0; - let mut failed_count = 0; - - for user in users_to_encrypt.into_iter().progress_with(pb.clone()) { - let user_id = user.id; - let plaintext_token = user.gh_access_token.expose_secret(); - - let encrypted_token = match encryption.encrypt(plaintext_token) { - Ok(encrypted_token) => encrypted_token, - Err(e) => { - pb.suspend(|| eprintln!("Failed to encrypt token for user {user_id}: {e}")); - failed_count += 1; - continue; - } - }; - - // Update the user with the encrypted token - if let Err(e) = diesel::update(users::table.find(user_id)) - .set(users::gh_encrypted_token.eq(Some(encrypted_token))) - .execute(&mut conn) - .await - { - pb.suspend(|| eprintln!("Failed to update user {user_id}: {e}")); - failed_count += 1; - continue; - } - - encrypted_count += 1; - } - - pb.finish_with_message("Backfill completed!"); - println!("Successfully encrypted: {encrypted_count} tokens"); - - if failed_count > 0 { - eprintln!( - "WARNING: {failed_count} tokens failed to encrypt. Please review the errors above." - ); - std::process::exit(1); - } - - // Verify the backfill by checking for any remaining unencrypted tokens - let remaining_unencrypted = users::table - .filter(users::gh_encrypted_token.is_null()) - .count() - .get_result::(&mut conn) - .await - .context("Failed to count remaining unencrypted tokens")?; - - if remaining_unencrypted > 0 { - eprintln!("WARNING: {remaining_unencrypted} users still have unencrypted tokens"); - std::process::exit(1); - } - - println!("Verification successful: All non-empty tokens have been encrypted!"); - Ok(()) -} diff --git a/src/bin/crates-admin/main.rs b/src/bin/crates-admin/main.rs index cabc4dda62c..0e3b8cc1e1c 100644 --- a/src/bin/crates-admin/main.rs +++ b/src/bin/crates-admin/main.rs @@ -6,7 +6,6 @@ mod default_versions; mod delete_crate; mod delete_version; mod dialoguer; -mod encrypt_github_tokens; mod enqueue_job; mod migrate; mod populate; @@ -22,7 +21,6 @@ enum Command { BackfillOgImages(backfill_og_images::Opts), DeleteCrate(delete_crate::Opts), DeleteVersion(delete_version::Opts), - EncryptGithubTokens(encrypt_github_tokens::Opts), Populate(populate::Opts), RenderReadmes(render_readmes::Opts), TransferCrates(transfer_crates::Opts), @@ -53,7 +51,6 @@ async fn main() -> anyhow::Result<()> { Command::BackfillOgImages(opts) => backfill_og_images::run(opts).await, Command::DeleteCrate(opts) => delete_crate::run(opts).await, Command::DeleteVersion(opts) => delete_version::run(opts).await, - Command::EncryptGithubTokens(opts) => encrypt_github_tokens::run(opts).await, Command::Populate(opts) => populate::run(opts).await, Command::RenderReadmes(opts) => render_readmes::run(opts).await, Command::TransferCrates(opts) => transfer_crates::run(opts).await, diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs index a113eec82fc..118bd80fe44 100644 --- a/src/controllers/crate_owner_invitation.rs +++ b/src/controllers/crate_owner_invitation.rs @@ -166,7 +166,8 @@ async fn prepare_list( // Only allow crate owners to query pending invitations for their crate. let krate: Crate = Crate::by_name(&crate_name).first(conn).await?; let owners = krate.owners(conn).await?; - if Rights::get(user, &*state.github, &owners).await? != Rights::Full { + let encryption = &state.config.gh_token_encryption; + if Rights::get(user, &*state.github, &owners, encryption).await? != Rights::Full { let detail = "only crate owners can query pending invitations for their crate"; return Err(forbidden(detail)); } diff --git a/src/controllers/helpers/authorization.rs b/src/controllers/helpers/authorization.rs index e33f4a94f4b..5596e47385b 100644 --- a/src/controllers/helpers/authorization.rs +++ b/src/controllers/helpers/authorization.rs @@ -1,7 +1,6 @@ use crate::models::{Owner, User}; +use crate::util::gh_token_encryption::GitHubTokenEncryption; use crates_io_github::{GitHubClient, GitHubError}; -use oauth2::AccessToken; -use secrecy::ExposeSecret; /// Access rights to the crate (publishing and ownership management) /// NOTE: The order of these variants matters! @@ -25,8 +24,11 @@ impl Rights { user: &User, gh_client: &dyn GitHubClient, owners: &[Owner], + encryption: &GitHubTokenEncryption, ) -> Result { - let token = AccessToken::new(user.gh_access_token.expose_secret().to_string()); + let token = encryption + .decrypt(&user.gh_encrypted_token) + .map_err(GitHubError::Other)?; let mut best = Self::None; for owner in owners { diff --git a/src/controllers/krate/delete.rs b/src/controllers/krate/delete.rs index 9beeb0811f1..63f20afc82b 100644 --- a/src/controllers/krate/delete.rs +++ b/src/controllers/krate/delete.rs @@ -72,7 +72,7 @@ pub async fn delete_crate( // Check that the user is an owner of the crate (team owners are not allowed to delete crates) let user = auth.user(); let owners = krate.owners(&mut conn).await?; - match Rights::get(user, &*app.github, &owners).await? { + match Rights::get(user, &*app.github, &owners, &app.config.gh_token_encryption).await? { Rights::Full => {} Rights::Publish => { let msg = "team members don't have permission to delete crates"; diff --git a/src/controllers/krate/owners.rs b/src/controllers/krate/owners.rs index ac2a4da23c7..2f58fd6dca0 100644 --- a/src/controllers/krate/owners.rs +++ b/src/controllers/krate/owners.rs @@ -9,6 +9,7 @@ use crate::models::{ krate::NewOwnerInvite, token::EndpointScope, }; use crate::util::errors::{AppResult, BoxedAppError, bad_request, crate_not_found, custom}; +use crate::util::gh_token_encryption::GitHubTokenEncryption; use crate::views::EncodableOwner; use crate::{App, app::AppState}; use crate::{auth::AuthCheck, email::EmailMessage}; @@ -199,7 +200,7 @@ async fn modify_owners( let owners = krate.owners(conn).await?; - match Rights::get(user, &*app.github, &owners).await? { + match Rights::get(user, &*app.github, &owners, &app.config.gh_token_encryption).await? { Rights::Full => {} // Yes! Rights::Publish => { @@ -320,7 +321,8 @@ async fn add_owner( login: &str, ) -> Result { if login.contains(':') { - add_team_owner(&*app.github, conn, req_user, krate, login).await + let encryption = &app.config.gh_token_encryption; + add_team_owner(&*app.github, conn, req_user, krate, login, encryption).await } else { invite_user_owner(app, conn, req_user, krate, login).await } @@ -363,6 +365,7 @@ async fn add_team_owner( req_user: &User, krate: &Crate, login: &str, + encryption: &GitHubTokenEncryption, ) -> Result { // github:rust-lang:owners let mut chunks = login.split(':'); @@ -381,9 +384,16 @@ async fn add_team_owner( })?; // Always recreate teams to get the most up-to-date GitHub ID - let team = - create_or_update_github_team(gh_client, conn, &login.to_lowercase(), org, team, req_user) - .await?; + let team = create_or_update_github_team( + gh_client, + conn, + &login.to_lowercase(), + org, + team, + req_user, + encryption, + ) + .await?; // Teams are added as owners immediately, since the above call ensures // the user is a team member. @@ -408,6 +418,7 @@ pub async fn create_or_update_github_team( org_name: &str, team_name: &str, req_user: &User, + encryption: &GitHubTokenEncryption, ) -> AppResult { // GET orgs/:org/teams // check that `team` is the `slug` in results, and grab its data @@ -424,7 +435,14 @@ pub async fn create_or_update_github_team( ))); } - let token = AccessToken::new(req_user.gh_access_token.expose_secret().to_string()); + let token = encryption + .decrypt(&req_user.gh_encrypted_token) + .map_err(|err| { + custom( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to decrypt GitHub token: {err}"), + ) + })?; let team = gh_client.team_by_name(org_name, team_name, &token).await .map_err(|_| { bad_request(format_args!( diff --git a/src/controllers/krate/publish.rs b/src/controllers/krate/publish.rs index c3de6d7cade..0d6343f418a 100644 --- a/src/controllers/krate/publish.rs +++ b/src/controllers/krate/publish.rs @@ -450,7 +450,7 @@ pub async fn publish(app: AppState, req: Parts, body: Body) -> AppResult user, Err(GitHubError::NotFound(_)) => Err(bad_request("Unknown GitHub user or organization"))?, diff --git a/src/controllers/version/docs.rs b/src/controllers/version/docs.rs index 664f4f5995e..a4ef570e684 100644 --- a/src/controllers/version/docs.rs +++ b/src/controllers/version/docs.rs @@ -36,7 +36,8 @@ pub async fn rebuild_version_docs( // Check that the user is an owner of the crate, or a team member (= publish rights) let user = auth.user(); let owners = krate.owners(&mut conn).await?; - if Rights::get(user, &*app.github, &owners).await? < Rights::Publish { + let encryption = &app.config.gh_token_encryption; + if Rights::get(user, &*app.github, &owners, encryption).await? < Rights::Publish { return Err(custom( StatusCode::FORBIDDEN, "user doesn't have permission to trigger a docs rebuild", @@ -107,6 +108,7 @@ mod tests { .gh_id(111) .gh_login("other_user") .gh_access_token("token") + .gh_encrypted_token(&[]) .build() .insert(&mut conn) .await?; diff --git a/src/controllers/version/update.rs b/src/controllers/version/update.rs index 96647db7afe..0bb844d35e2 100644 --- a/src/controllers/version/update.rs +++ b/src/controllers/version/update.rs @@ -125,7 +125,8 @@ pub async fn perform_version_yank_update( let yanked = yanked.unwrap_or(version.yanked); - if Rights::get(user, &*state.github, &owners).await? < Rights::Publish { + let encryption = &state.config.gh_token_encryption; + if Rights::get(user, &*state.github, &owners, encryption).await? < Rights::Publish { if user.is_admin { let action = if yanked { "yanking" } else { "unyanking" }; warn!( diff --git a/src/index.rs b/src/index.rs index b8c2645e6e3..1eacb94576a 100644 --- a/src/index.rs +++ b/src/index.rs @@ -157,6 +157,7 @@ mod tests { users::gh_login.eq("user1"), users::gh_id.eq(42), users::gh_access_token.eq("some random token"), + users::gh_encrypted_token.eq(&[]), )) .returning(users::id) .get_result::(&mut conn) diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 5416a9ffaa4..4cbe1fcf8bb 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -707,6 +707,7 @@ mod tests { .gh_id(0) .gh_login(gh_login) .gh_access_token("some random token") + .gh_encrypted_token(&[]) .build() .insert(conn) .await diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 6fe0e62148c..3ecba4c39b0 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -6,9 +6,11 @@ use crate::views::{ }; use crate::tests::util::github::next_gh_id; +use crate::util::gh_token_encryption::GitHubTokenEncryption; use diesel::prelude::*; use diesel_async::AsyncPgConnection; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; mod account_lock; mod authentication; @@ -92,10 +94,17 @@ pub struct OwnerResp { } fn new_user(login: &str) -> NewUser<'_> { + static ENCRYPTED_TOKEN: LazyLock> = LazyLock::new(|| { + GitHubTokenEncryption::for_testing() + .encrypt("some random token") + .unwrap() + }); + NewUser::builder() .gh_id(next_gh_id()) .gh_login(login) .gh_access_token("some random token") + .gh_encrypted_token(&ENCRYPTED_TOKEN) .build() } diff --git a/src/tests/owners.rs b/src/tests/owners.rs index 837a1d8e077..99962fa43e3 100644 --- a/src/tests/owners.rs +++ b/src/tests/owners.rs @@ -821,6 +821,7 @@ async fn inactive_users_dont_get_invitations() { .gh_id(-1) .gh_login(invited_gh_login) .gh_access_token("some random token") + .gh_encrypted_token(&[]) .build() .insert(&mut conn) .await diff --git a/src/tests/routes/users/read.rs b/src/tests/routes/users/read.rs index 7caa728024a..abf8bf228bd 100644 --- a/src/tests/routes/users/read.rs +++ b/src/tests/routes/users/read.rs @@ -42,6 +42,7 @@ async fn show_latest_user_case_insensitively() { .gh_login("foobar") .name("I was first then deleted my github account") .gh_access_token("bar") + .gh_encrypted_token(&[]) .build(); let user2 = NewUser::builder() @@ -49,6 +50,7 @@ async fn show_latest_user_case_insensitively() { .gh_login("FOOBAR") .name("I was second, I took the foobar username on github") .gh_access_token("bar") + .gh_encrypted_token(&[]) .build(); assert_ok!( diff --git a/src/tests/worker/sync_admins.rs b/src/tests/worker/sync_admins.rs index 3ec4245045d..4f7a36e4909 100644 --- a/src/tests/worker/sync_admins.rs +++ b/src/tests/worker/sync_admins.rs @@ -79,6 +79,7 @@ async fn create_user( users::gh_login.eq(name), users::gh_id.eq(gh_id), users::gh_access_token.eq("some random token"), + users::gh_encrypted_token.eq(&[]), users::is_admin.eq(is_admin), )) .returning(users::id) diff --git a/src/typosquat/test_util.rs b/src/typosquat/test_util.rs index 69875e1261b..6ba942d5a64 100644 --- a/src/typosquat/test_util.rs +++ b/src/typosquat/test_util.rs @@ -42,6 +42,7 @@ pub mod faker { .gh_id(next_gh_id()) .gh_login(login) .gh_access_token("token") + .gh_encrypted_token(&[]) .build() .insert(conn) .await diff --git a/src/worker/jobs/downloads/update_metadata.rs b/src/worker/jobs/downloads/update_metadata.rs index 56be964f4fc..e785eb653a0 100644 --- a/src/worker/jobs/downloads/update_metadata.rs +++ b/src/worker/jobs/downloads/update_metadata.rs @@ -118,6 +118,7 @@ mod tests { .gh_id(2) .gh_login("login") .gh_access_token("access_token") + .gh_encrypted_token(&[]) .build() .insert(conn) .await diff --git a/src/worker/jobs/expiry_notification.rs b/src/worker/jobs/expiry_notification.rs index fbcc5e284fc..2a433c04c33 100644 --- a/src/worker/jobs/expiry_notification.rs +++ b/src/worker/jobs/expiry_notification.rs @@ -159,6 +159,7 @@ mod tests { .gh_id(0) .gh_login("a") .gh_access_token("token") + .gh_encrypted_token(&[]) .build() .insert(&mut conn) .await?;