Skip to content

Commit d2d8d21

Browse files
authored
Merge pull request #11585 from Turbo87/encrypted-gh-tokens
Encrypt GitHub access tokens
2 parents 647a589 + 5f0f6ee commit d2d8d21

File tree

16 files changed

+344
-14
lines changed

16 files changed

+344
-14
lines changed

.env.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export GIT_REPO_URL=file://$PWD/tmp/index-bare
7272
export GH_CLIENT_ID=
7373
export GH_CLIENT_SECRET=
7474

75+
# Key for encrypting/decrypting GitHub tokens. Must be exactly 64 hex characters.
76+
# Used for secure storage of GitHub tokens in the database.
77+
export GITHUB_TOKEN_ENCRYPTION_KEY=0af877502cf11413eaa64af985fe1f8ed250ac9168a3b2db7da52cd5cc6116a9
78+
7579
# Credentials for configuring Mailgun. You can leave these commented out
7680
# if you are not interested in actually sending emails. If left empty,
7781
# a mock email will be sent to a file in your local '/tmp/' directory.

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ name = "crates_io"
4747
doctest = true
4848

4949
[dependencies]
50+
aes-gcm = { version = "=0.10.3", features = ["std"] }
5051
anyhow = "=1.0.98"
5152
astral-tokio-tar = "=0.5.2"
5253
async-compression = { version = "=0.4.27", default-features = false, features = ["gzip", "tokio"] }

crates/crates_io_database/src/models/user.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pub struct User {
2323
#[diesel(deserialize_as = String)]
2424
#[serde(skip)]
2525
pub gh_access_token: SecretString,
26+
#[serde(skip)]
27+
pub gh_encrypted_token: Option<Vec<u8>>,
2628
pub account_lock_reason: Option<String>,
2729
pub account_lock_until: Option<DateTime<Utc>>,
2830
pub is_admin: bool,
@@ -94,6 +96,7 @@ pub struct NewUser<'a> {
9496
pub name: Option<&'a str>,
9597
pub gh_avatar: Option<&'a str>,
9698
pub gh_access_token: &'a str,
99+
pub gh_encrypted_token: Option<&'a [u8]>,
97100
}
98101

99102
impl NewUser<'_> {
@@ -125,6 +128,7 @@ impl NewUser<'_> {
125128
users::name.eq(excluded(users::name)),
126129
users::gh_avatar.eq(excluded(users::gh_avatar)),
127130
users::gh_access_token.eq(excluded(users::gh_access_token)),
131+
users::gh_encrypted_token.eq(excluded(users::gh_encrypted_token)),
128132
))
129133
.returning(User::as_returning())
130134
.get_result(conn)

crates/crates_io_database/src/schema.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,8 @@ diesel::table! {
880880
is_admin -> Bool,
881881
/// Whether or not the user wants to receive notifications when a package they own is published
882882
publish_notifications -> Bool,
883+
/// Encrypted GitHub access token
884+
gh_encrypted_token -> Nullable<Bytea>,
883885
}
884886
}
885887

crates/crates_io_database_dump/src/dump-db.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ account_lock_reason = "private"
232232
account_lock_until = "private"
233233
is_admin = "private"
234234
publish_notifications = "private"
235+
gh_encrypted_token = "private"
235236
[users.column_defaults]
236237
gh_access_token = "''"
237238

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
alter table users drop column gh_encrypted_token;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
alter table users
2+
add column gh_encrypted_token bytea;
3+
4+
comment on column users.gh_encrypted_token is 'Encrypted GitHub access token';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use anyhow::{Context, Result};
2+
use crates_io::util::gh_token_encryption::GitHubTokenEncryption;
3+
use crates_io::{db, models::User};
4+
use crates_io_database::schema::users;
5+
use diesel::prelude::*;
6+
use diesel_async::RunQueryDsl;
7+
use indicatif::{ProgressBar, ProgressIterator, ProgressStyle};
8+
use secrecy::ExposeSecret;
9+
10+
#[derive(clap::Parser, Debug)]
11+
#[command(
12+
name = "encrypt-github-tokens",
13+
about = "Encrypt existing plaintext GitHub tokens in the database.",
14+
long_about = "Backfill operation to encrypt existing plaintext GitHub tokens using AES-256-GCM. \
15+
This reads users with plaintext tokens but no encrypted tokens, encrypts them, and \
16+
updates the database with the encrypted versions."
17+
)]
18+
pub struct Opts {}
19+
20+
pub async fn run(_opts: Opts) -> Result<()> {
21+
println!("Starting GitHub token encryption backfill…");
22+
23+
// Load encryption configuration
24+
let encryption = GitHubTokenEncryption::from_environment()
25+
.context("Failed to load encryption configuration")?;
26+
27+
// Get database connection
28+
let mut conn = db::oneoff_connection()
29+
.await
30+
.context("Failed to establish database connection")?;
31+
32+
// Query users with no encrypted tokens
33+
let users_to_encrypt = users::table
34+
.filter(users::gh_encrypted_token.is_null())
35+
.select(User::as_select())
36+
.load(&mut conn)
37+
.await
38+
.context("Failed to query users with plaintext tokens")?;
39+
40+
let total_users = users_to_encrypt.len();
41+
if total_users == 0 {
42+
println!("Found no users that need token encryption. Exiting.");
43+
return Ok(());
44+
}
45+
46+
println!("Found {total_users} users with plaintext tokens to encrypt");
47+
48+
let pb = ProgressBar::new(total_users as u64);
49+
pb.set_style(ProgressStyle::with_template(
50+
"{bar:60} ({pos}/{len}, ETA {eta}) {msg}",
51+
)?);
52+
53+
let mut encrypted_count = 0;
54+
let mut failed_count = 0;
55+
56+
for user in users_to_encrypt.into_iter().progress_with(pb.clone()) {
57+
let user_id = user.id;
58+
let plaintext_token = user.gh_access_token.expose_secret();
59+
60+
let encrypted_token = match encryption.encrypt(plaintext_token) {
61+
Ok(encrypted_token) => encrypted_token,
62+
Err(e) => {
63+
pb.suspend(|| eprintln!("Failed to encrypt token for user {user_id}: {e}"));
64+
failed_count += 1;
65+
continue;
66+
}
67+
};
68+
69+
// Update the user with the encrypted token
70+
if let Err(e) = diesel::update(users::table.find(user_id))
71+
.set(users::gh_encrypted_token.eq(Some(encrypted_token)))
72+
.execute(&mut conn)
73+
.await
74+
{
75+
pb.suspend(|| eprintln!("Failed to update user {user_id}: {e}"));
76+
failed_count += 1;
77+
continue;
78+
}
79+
80+
encrypted_count += 1;
81+
}
82+
83+
pb.finish_with_message("Backfill completed!");
84+
println!("Successfully encrypted: {encrypted_count} tokens");
85+
86+
if failed_count > 0 {
87+
eprintln!(
88+
"WARNING: {failed_count} tokens failed to encrypt. Please review the errors above."
89+
);
90+
std::process::exit(1);
91+
}
92+
93+
// Verify the backfill by checking for any remaining unencrypted tokens
94+
let remaining_unencrypted = users::table
95+
.filter(users::gh_encrypted_token.is_null())
96+
.count()
97+
.get_result::<i64>(&mut conn)
98+
.await
99+
.context("Failed to count remaining unencrypted tokens")?;
100+
101+
if remaining_unencrypted > 0 {
102+
eprintln!("WARNING: {remaining_unencrypted} users still have unencrypted tokens");
103+
std::process::exit(1);
104+
}
105+
106+
println!("Verification successful: All non-empty tokens have been encrypted!");
107+
Ok(())
108+
}

src/bin/crates-admin/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod default_versions;
66
mod delete_crate;
77
mod delete_version;
88
mod dialoguer;
9+
mod encrypt_github_tokens;
910
mod enqueue_job;
1011
mod migrate;
1112
mod populate;
@@ -21,6 +22,7 @@ enum Command {
2122
BackfillOgImages(backfill_og_images::Opts),
2223
DeleteCrate(delete_crate::Opts),
2324
DeleteVersion(delete_version::Opts),
25+
EncryptGithubTokens(encrypt_github_tokens::Opts),
2426
Populate(populate::Opts),
2527
RenderReadmes(render_readmes::Opts),
2628
TransferCrates(transfer_crates::Opts),
@@ -51,6 +53,7 @@ async fn main() -> anyhow::Result<()> {
5153
Command::BackfillOgImages(opts) => backfill_og_images::run(opts).await,
5254
Command::DeleteCrate(opts) => delete_crate::run(opts).await,
5355
Command::DeleteVersion(opts) => delete_version::run(opts).await,
56+
Command::EncryptGithubTokens(opts) => encrypt_github_tokens::run(opts).await,
5457
Command::Populate(opts) => populate::run(opts).await,
5558
Command::RenderReadmes(opts) => render_readmes::run(opts).await,
5659
Command::TransferCrates(opts) => transfer_crates::run(opts).await,

0 commit comments

Comments
 (0)