Skip to content

Encrypt GitHub access tokens #11585

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export GIT_REPO_URL=file://$PWD/tmp/index-bare
export GH_CLIENT_ID=
export GH_CLIENT_SECRET=

# Key for encrypting/decrypting GitHub tokens. Must be exactly 64 hex characters.
# Used for secure storage of GitHub tokens in the database.
export GITHUB_TOKEN_ENCRYPTION_KEY=0af877502cf11413eaa64af985fe1f8ed250ac9168a3b2db7da52cd5cc6116a9

# Credentials for configuring Mailgun. You can leave these commented out
# if you are not interested in actually sending emails. If left empty,
# a mock email will be sent to a file in your local '/tmp/' directory.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ name = "crates_io"
doctest = true

[dependencies]
aes-gcm = { version = "=0.10.3", features = ["std"] }
anyhow = "=1.0.98"
astral-tokio-tar = "=0.5.2"
async-compression = { version = "=0.4.27", default-features = false, features = ["gzip", "tokio"] }
Expand Down
3 changes: 3 additions & 0 deletions crates/crates_io_database/src/models/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct User {
pub account_lock_until: Option<DateTime<Utc>>,
pub is_admin: bool,
pub publish_notifications: bool,
pub gh_encrypted_token: Option<Vec<u8>>,
}

impl User {
Expand Down Expand Up @@ -89,6 +90,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]>,
}

impl NewUser<'_> {
Expand Down Expand Up @@ -119,6 +121,7 @@ impl NewUser<'_> {
users::name.eq(excluded(users::name)),
users::gh_avatar.eq(excluded(users::gh_avatar)),
users::gh_access_token.eq(excluded(users::gh_access_token)),
users::gh_encrypted_token.eq(excluded(users::gh_encrypted_token)),
))
.get_result(conn)
.await
Expand Down
2 changes: 2 additions & 0 deletions crates/crates_io_database/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,8 @@ diesel::table! {
is_admin -> Bool,
/// 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<Bytea>,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/crates_io_database_dump/src/dump-db.toml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ account_lock_reason = "private"
account_lock_until = "private"
is_admin = "private"
publish_notifications = "private"
gh_encrypted_token = "private"
[users.column_defaults]
gh_access_token = "''"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table users drop column gh_encrypted_token;
4 changes: 4 additions & 0 deletions migrations/2025-07-16-123330_add_gh_encrypted_token/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
alter table users
add column gh_encrypted_token bytea;

comment on column users.gh_encrypted_token is 'Encrypted GitHub access token';
108 changes: 108 additions & 0 deletions src/bin/crates-admin/encrypt_github_tokens.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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::<i64>(&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(())
}
3 changes: 3 additions & 0 deletions src/bin/crates-admin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod default_versions;
mod delete_crate;
mod delete_version;
mod dialoguer;
mod encrypt_github_tokens;
mod enqueue_job;
mod migrate;
mod populate;
Expand All @@ -21,6 +22,7 @@ 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),
Expand Down Expand Up @@ -51,6 +53,7 @@ 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,
Expand Down
4 changes: 4 additions & 0 deletions src/config/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use url::Url;

use crate::Env;
use crate::rate_limiter::{LimitedAction, RateLimiterConfig};
use crate::util::gh_token_encryption::GitHubTokenEncryption;

use super::base::Base;
use super::database_pools::DatabasePools;
Expand Down Expand Up @@ -42,6 +43,7 @@ pub struct Server {
pub session_key: cookie::Key,
pub gh_client_id: ClientId,
pub gh_client_secret: ClientSecret,
pub gh_token_encryption: GitHubTokenEncryption,
pub max_upload_size: u32,
pub max_unpack_size: u64,
pub max_dependencies: usize,
Expand Down Expand Up @@ -106,6 +108,7 @@ impl Server {
/// - `SESSION_KEY`: The key used to sign and encrypt session cookies.
/// - `GH_CLIENT_ID`: The client ID of the associated GitHub application.
/// - `GH_CLIENT_SECRET`: The client secret of the associated GitHub application.
/// - `GITHUB_TOKEN_ENCRYPTION_KEY`: Key for encrypting GitHub access tokens (64 hex characters).
/// - `BLOCKED_TRAFFIC`: A list of headers and environment variables to use for blocking
/// traffic. See the `block_traffic` module for more documentation.
/// - `DOWNLOADS_PERSIST_INTERVAL_MS`: how frequent to persist download counts (in ms).
Expand Down Expand Up @@ -205,6 +208,7 @@ impl Server {
session_key: cookie::Key::derive_from(required_var("SESSION_KEY")?.as_bytes()),
gh_client_id: ClientId::new(required_var("GH_CLIENT_ID")?),
gh_client_secret: ClientSecret::new(required_var("GH_CLIENT_SECRET")?),
gh_token_encryption: GitHubTokenEncryption::from_environment()?,
max_upload_size: 10 * 1024 * 1024, // 10 MB default file upload size limit
max_unpack_size: 512 * 1024 * 1024, // 512 MB max when decompressed
max_dependencies: DEFAULT_MAX_DEPENDENCIES,
Expand Down
24 changes: 21 additions & 3 deletions src/controllers/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use minijinja::context;
use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use tracing::warn;
use tracing::{error, warn};

#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BeginResponse {
Expand Down Expand Up @@ -114,11 +114,25 @@ pub async fn authorize_session(

let token = token.access_token();

// Encrypt the GitHub access token
let encryption = &app.config.gh_token_encryption;
let encrypted_token = encryption.encrypt(token.secret()).map_err(|error| {
error!("Failed to encrypt GitHub token: {error}");
server_error("Internal server error")
})?;

// Fetch the user info from GitHub using the access token we just got and create a user record
let ghuser = app.github.current_user(token).await?;

let mut conn = app.db_write().await?;
let user = save_user_to_database(&ghuser, token.secret(), &app.emails, &mut conn).await?;
let user = save_user_to_database(
&ghuser,
token.secret(),
&encrypted_token,
&app.emails,
&mut conn,
)
.await?;

// Log in by setting a cookie and the middleware authentication
session.insert("user_id".to_string(), user.id.to_string());
Expand All @@ -129,6 +143,7 @@ pub async fn authorize_session(
pub async fn save_user_to_database(
user: &GitHubUser,
access_token: &str,
encrypted_token: &[u8],
emails: &Emails,
conn: &mut AsyncPgConnection,
) -> QueryResult<User> {
Expand All @@ -138,6 +153,7 @@ pub async fn save_user_to_database(
.maybe_name(user.name.as_deref())
.maybe_gh_avatar(user.avatar_url.as_deref())
.gh_access_token(access_token)
.gh_encrypted_token(encrypted_token)
.build();

match create_or_update_user(&new_user, user.email.as_deref(), emails, conn).await {
Expand Down Expand Up @@ -241,7 +257,9 @@ mod tests {
id: -1,
avatar_url: None,
};
let result = save_user_to_database(&gh_user, "arbitrary_token", &emails, &mut conn).await;

let result =
save_user_to_database(&gh_user, "arbitrary_token", &[], &emails, &mut conn).await;

assert!(
result.is_ok(),
Expand Down
22 changes: 11 additions & 11 deletions src/tests/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()>
email: None,
avatar_url: None,
};
assert_ok!(session::save_user_to_database(&gh_user, "bar_token", emails, &mut conn).await);
assert_ok!(session::save_user_to_database(&gh_user, "bar_token", &[], emails, &mut conn).await);

// Use the original API token to find the now updated user
let hashed_token = assert_ok!(HashedToken::parse(token));
Expand Down Expand Up @@ -79,8 +79,8 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> {
avatar_url: None,
};

let u =
session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
let u = session::save_user_to_database(&gh_user, "some random token", &[], emails, &mut conn)
.await?;

let user_without_github_email = MockCookieUser::new(&app, u);

Expand All @@ -103,8 +103,8 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> {
avatar_url: None,
};

let u =
session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
let u = session::save_user_to_database(&gh_user, "some random token", &[], emails, &mut conn)
.await?;

let again_user_without_github_email = MockCookieUser::new(&app, u);

Expand Down Expand Up @@ -145,8 +145,8 @@ async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> {
avatar_url: None,
};

let u =
session::save_user_to_database(&gh_user, "some random token", &emails, &mut conn).await?;
let u = session::save_user_to_database(&gh_user, "some random token", &[], &emails, &mut conn)
.await?;

let user_with_different_email_in_github = MockCookieUser::new(&app, u);

Expand Down Expand Up @@ -202,8 +202,8 @@ async fn test_confirm_user_email() -> anyhow::Result<()> {
avatar_url: None,
};

let u =
session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
let u = session::save_user_to_database(&gh_user, "some random token", &[], emails, &mut conn)
.await?;

let user = MockCookieUser::new(&app, u);
let user_model = user.as_model();
Expand Down Expand Up @@ -248,8 +248,8 @@ async fn test_existing_user_email() -> anyhow::Result<()> {
avatar_url: None,
};

let u =
session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
let u = session::save_user_to_database(&gh_user, "some random token", &[], emails, &mut conn)
.await?;

update(Email::belonging_to(&u))
// Users created before we added verification will have
Expand Down
2 changes: 2 additions & 0 deletions src/tests/util/test_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::rate_limiter::{LimitedAction, RateLimiterConfig};
use crate::storage::StorageConfig;
use crate::tests::util::chaosproxy::ChaosProxy;
use crate::tests::util::github::MOCK_GITHUB_DATA;
use crate::util::gh_token_encryption::GitHubTokenEncryption;
use crate::worker::{Environment, RunnerExt};
use crate::{App, Emails, Env};
use claims::assert_some;
Expand Down Expand Up @@ -489,6 +490,7 @@ fn simple_config() -> config::Server {
session_key: cookie::Key::derive_from("test this has to be over 32 bytes long".as_bytes()),
gh_client_id: ClientId::new(dotenvy::var("GH_CLIENT_ID").unwrap_or_default()),
gh_client_secret: ClientSecret::new(dotenvy::var("GH_CLIENT_SECRET").unwrap_or_default()),
gh_token_encryption: GitHubTokenEncryption::for_testing(),
max_upload_size: 128 * 1024, // 128 kB should be enough for most testing purposes
max_unpack_size: 128 * 1024, // 128 kB should be enough for most testing purposes
max_features: 10,
Expand Down
1 change: 1 addition & 0 deletions src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub use crates_io_database::utils::token;

pub mod diesel;
pub mod errors;
pub mod gh_token_encryption;
mod io_util;
mod request_helpers;
pub mod string_excl_null;
Expand Down
Loading
Loading