Skip to content

Commit 0945639

Browse files
committed
Implement GitHubTokenEncryption struct
1 parent d96befd commit 0945639

File tree

5 files changed

+183
-0
lines changed

5 files changed

+183
-0
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"] }

src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub use crates_io_database::utils::token;
44

55
pub mod diesel;
66
pub mod errors;
7+
pub mod gh_token_encryption;
78
mod io_util;
89
mod request_helpers;
910
pub mod string_excl_null;

src/util/gh_token_encryption.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use aes_gcm::aead::{Aead, AeadCore, OsRng};
2+
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
3+
use anyhow::{Context, Result};
4+
use oauth2::AccessToken;
5+
6+
/// A struct that encapsulates GitHub token encryption and decryption
7+
/// using AES-256-GCM.
8+
pub struct GitHubTokenEncryption {
9+
cipher: Aes256Gcm,
10+
}
11+
12+
impl GitHubTokenEncryption {
13+
/// Creates a new [GitHubTokenEncryption] instance with the provided cipher
14+
pub fn new(cipher: Aes256Gcm) -> Self {
15+
Self { cipher }
16+
}
17+
18+
/// Creates a new [GitHubTokenEncryption] instance with a cipher for testing
19+
/// purposes.
20+
#[cfg(test)]
21+
pub fn for_testing() -> Self {
22+
let test_key = b"test_key_32_bytes_long_for_tests";
23+
Self::new(Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(test_key)))
24+
}
25+
26+
/// Creates a new [GitHubTokenEncryption] instance from the environment
27+
///
28+
/// Reads the `GITHUB_TOKEN_ENCRYPTION_KEY` environment variable, which
29+
/// should be a 64-character hex string (32 bytes when decoded).
30+
pub fn from_environment() -> Result<Self> {
31+
let gh_token_key = std::env::var("GITHUB_TOKEN_ENCRYPTION_KEY")
32+
.context("GITHUB_TOKEN_ENCRYPTION_KEY environment variable not set")?;
33+
34+
if gh_token_key.len() != 64 {
35+
anyhow::bail!("GITHUB_TOKEN_ENCRYPTION_KEY must be exactly 64 hex characters");
36+
}
37+
38+
let gh_token_key = hex::decode(gh_token_key.as_bytes())
39+
.context("GITHUB_TOKEN_ENCRYPTION_KEY must be exactly 64 hex characters")?;
40+
41+
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&gh_token_key));
42+
43+
Ok(Self::new(cipher))
44+
}
45+
46+
/// Encrypts a GitHub access token using AES-256-GCM
47+
///
48+
/// The encrypted data format is: `[12-byte nonce][encrypted data]`
49+
/// The nonce is randomly generated for each encryption to ensure uniqueness.
50+
pub fn encrypt(&self, plaintext: &str) -> Result<Vec<u8>> {
51+
// Generate a random nonce for this encryption
52+
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
53+
54+
// Encrypt the token
55+
let encrypted = self
56+
.cipher
57+
.encrypt(&nonce, plaintext.as_bytes())
58+
.map_err(|error| anyhow::anyhow!("Failed to encrypt token: {error}"))?;
59+
60+
// Combine nonce + ciphertext (which includes the auth tag)
61+
let mut result = Vec::with_capacity(nonce.len() + encrypted.len());
62+
result.extend_from_slice(&nonce);
63+
result.extend_from_slice(&encrypted);
64+
65+
Ok(result)
66+
}
67+
68+
/// Decrypts a GitHub access token using AES-256-GCM
69+
///
70+
/// Expects the data format: `[12-byte nonce][encrypted data]`
71+
pub fn decrypt(&self, encrypted: &[u8]) -> Result<AccessToken> {
72+
if encrypted.len() < 12 {
73+
anyhow::bail!("Invalid encrypted token: too short");
74+
}
75+
76+
// Extract nonce and ciphertext
77+
let (nonce_bytes, ciphertext) = encrypted.split_at(12);
78+
let nonce = Nonce::from_slice(nonce_bytes);
79+
80+
// Decrypt the token
81+
let plaintext = self
82+
.cipher
83+
.decrypt(nonce, ciphertext)
84+
.context("Failed to decrypt token")?;
85+
86+
let plaintext =
87+
String::from_utf8(plaintext).context("Decrypted token is not valid UTF-8")?;
88+
89+
Ok(AccessToken::new(plaintext))
90+
}
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
use aes_gcm::{Key, KeyInit};
97+
use claims::{assert_err, assert_ok};
98+
use insta::assert_snapshot;
99+
100+
fn create_test_encryption() -> GitHubTokenEncryption {
101+
let key = Key::<Aes256Gcm>::from_slice(b"test_master_key_32_bytes_long!!!");
102+
let cipher = Aes256Gcm::new(key);
103+
GitHubTokenEncryption { cipher }
104+
}
105+
106+
#[test]
107+
fn test_encrypt_decrypt_roundtrip() {
108+
let encryption = create_test_encryption();
109+
let original_token = "ghs_test_token_123456789";
110+
111+
// Encrypt the token
112+
let encrypted = assert_ok!(encryption.encrypt(original_token));
113+
114+
// Decrypt it back
115+
let decrypted = assert_ok!(encryption.decrypt(&encrypted));
116+
117+
assert_eq!(original_token, decrypted.secret());
118+
}
119+
120+
#[test]
121+
fn test_encrypt_produces_different_ciphertext() {
122+
let encryption = create_test_encryption();
123+
let token = "ghs_test_token_123456789";
124+
125+
// Encrypt the same token twice
126+
let encrypted1 = assert_ok!(encryption.encrypt(token));
127+
let encrypted2 = assert_ok!(encryption.encrypt(token));
128+
129+
// Should produce different ciphertext due to random nonce
130+
assert_ne!(encrypted1, encrypted2);
131+
132+
// But both should decrypt to the same plaintext
133+
let decrypted1 = assert_ok!(encryption.decrypt(&encrypted1));
134+
let decrypted2 = assert_ok!(encryption.decrypt(&encrypted2));
135+
136+
assert_eq!(decrypted1.secret(), decrypted2.secret());
137+
assert_eq!(decrypted1.secret(), token);
138+
}
139+
140+
#[test]
141+
fn test_invalid_encrypted_data() {
142+
let encryption = create_test_encryption();
143+
144+
// Too short
145+
let err = assert_err!(encryption.decrypt(&[1, 2, 3]));
146+
assert_snapshot!(err, @"Invalid encrypted token: too short");
147+
148+
// Invalid data
149+
let invalid_data = vec![0u8; 50];
150+
let err = assert_err!(encryption.decrypt(&invalid_data));
151+
assert_snapshot!(err, @"Failed to decrypt token");
152+
}
153+
154+
#[test]
155+
fn test_different_keys() {
156+
let encryption1 = create_test_encryption();
157+
158+
// Create a different encryption with a different key
159+
let key2 = Key::<Aes256Gcm>::from_slice(b"different_key_32_bytes_long!!!!!");
160+
let cipher2 = Aes256Gcm::new(key2);
161+
let encryption2 = GitHubTokenEncryption { cipher: cipher2 };
162+
163+
let token = "ghs_test_token_123456789";
164+
165+
// Encrypt with encryption1
166+
let encrypted = assert_ok!(encryption1.encrypt(token));
167+
168+
// Try to decrypt with encryption2 (should fail)
169+
let err = assert_err!(encryption2.decrypt(&encrypted));
170+
assert_snapshot!(err, @"Failed to decrypt token");
171+
172+
// But encryption1 should still work
173+
let decrypted = assert_ok!(encryption1.decrypt(&encrypted));
174+
assert_eq!(decrypted.secret(), token);
175+
}
176+
}

0 commit comments

Comments
 (0)