|
1 | 1 | use crate::tests::util::MockRequestExt;
|
| 2 | +use crate::tests::util::insta::api_token_redaction; |
2 | 3 | use crate::tests::{RequestHelper, TestApp};
|
3 | 4 | use crate::util::token::HashedToken;
|
4 | 5 | use crate::{models::ApiToken, schema::api_tokens};
|
| 6 | +use base64::{Engine as _, engine::general_purpose}; |
| 7 | +use chrono::{TimeDelta, Utc}; |
| 8 | +use crates_io_database::models::trustpub::NewToken; |
| 9 | +use crates_io_database::schema::trustpub_tokens; |
5 | 10 | use crates_io_github::{GitHubPublicKey, MockGitHubClient};
|
| 11 | +use crates_io_trustpub::access_token::AccessToken; |
6 | 12 | use diesel::prelude::*;
|
7 | 13 | use diesel_async::RunQueryDsl;
|
8 | 14 | use googletest::prelude::*;
|
9 | 15 | use insta::{assert_json_snapshot, assert_snapshot};
|
| 16 | +use p256::ecdsa::{Signature, SigningKey, signature::Signer}; |
| 17 | +use p256::pkcs8::DecodePrivateKey; |
| 18 | +use secrecy::ExposeSecret; |
| 19 | +use std::sync::LazyLock; |
10 | 20 |
|
11 | 21 | static URL: &str = "/api/github/secret-scanning/verify";
|
12 | 22 |
|
13 |
| -// Test request and signature from https://docs.github.com/en/developers/overview/secret-scanning-partner-program#create-a-secret-alert-service |
| 23 | +// Test request payload for GitHub secret scanning |
14 | 24 | static GITHUB_ALERT: &[u8] =
|
15 | 25 | br#"[{"token":"some_token","type":"some_type","url":"some_url","source":"some_source"}]"#;
|
16 | 26 |
|
17 |
| -static GITHUB_PUBLIC_KEY_IDENTIFIER: &str = |
18 |
| - "f9525bf080f75b3506ca1ead061add62b8633a346606dc5fe544e29231c6ee0d"; |
| 27 | +/// Generate a GitHub alert with a given token |
| 28 | +fn github_alert_with_token(token: &str) -> Vec<u8> { |
| 29 | + format!( |
| 30 | + r#"[{{"token":"{token}","type":"some_type","url":"some_url","source":"some_source"}}]"#, |
| 31 | + ) |
| 32 | + .into_bytes() |
| 33 | +} |
| 34 | + |
| 35 | +/// Private key for signing payloads (ECDSA P-256) |
| 36 | +/// |
| 37 | +/// Generated specifically for testing - do not use in production. |
| 38 | +/// |
| 39 | +/// This corresponds to the public key below and is used to generate valid signatures |
| 40 | +static PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- |
| 41 | +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgV64BdEFXg9aT/m4p |
| 42 | +wOQ/o9WUHxZ6qfBaP3D7Km1TOWuhRANCAARYKkbkTbIr//8klg1CMYGQIwtlfNd4 |
| 43 | +JQYV5+q0s3+JnBSLb1/sx/lEDzmMVZQIZQrACUHFW4UVdmox2NvmNWyy |
| 44 | +-----END PRIVATE KEY-----"#; |
| 45 | + |
| 46 | +/// Public key (corresponds to the private key above) |
| 47 | +static PUBLIC_KEY: &str = r#"-----BEGIN PUBLIC KEY----- |
| 48 | +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWCpG5E2yK///JJYNQjGBkCMLZXzX |
| 49 | +eCUGFefqtLN/iZwUi29f7Mf5RA85jFWUCGUKwAlBxVuFFXZqMdjb5jVssg== |
| 50 | +-----END PUBLIC KEY-----"#; |
| 51 | + |
| 52 | +/// Public key identifier (SHA256 hash of the DER-encoded public key) |
| 53 | +static KEY_IDENTIFIER: &str = "2aafbbe2d329af78d875cd2dd0291048799176466844315b6a846d6e12aa26ca"; |
| 54 | + |
| 55 | +/// Signing key derived from the private key |
| 56 | +static SIGNING_KEY: LazyLock<SigningKey> = |
| 57 | + LazyLock::new(|| SigningKey::from_pkcs8_pem(PRIVATE_KEY).unwrap()); |
| 58 | + |
| 59 | +/// Generate a signature for the payload using our private key |
| 60 | +fn sign_payload(payload: &[u8]) -> String { |
| 61 | + let signature: Signature = SIGNING_KEY.sign(payload); |
| 62 | + general_purpose::STANDARD.encode(signature.to_der()) |
| 63 | +} |
| 64 | + |
| 65 | +/// Generate a new Trusted Publishing token and its SHA256 hash |
| 66 | +fn generate_trustpub_token() -> (String, Vec<u8>) { |
| 67 | + let token = AccessToken::generate(); |
| 68 | + let finalized_token = token.finalize().expose_secret().to_string(); |
| 69 | + let hashed_token = token.sha256().to_vec(); |
| 70 | + (finalized_token, hashed_token) |
| 71 | +} |
19 | 72 |
|
20 |
| -/// Test key from https://docs.github.com/en/developers/overview/secret-scanning-partner-program#create-a-secret-alert-service |
21 |
| -static GITHUB_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEsz9ugWDj5jK5ELBK42ynytbo38gP\nHzZFI03Exwz8Lh/tCfL3YxwMdLjB+bMznsanlhK0RwcGP3IDb34kQDIo3Q==\n-----END PUBLIC KEY-----"; |
| 73 | +/// Create a new Trusted Publishing token in the database |
| 74 | +async fn insert_trustpub_token(conn: &mut diesel_async::AsyncPgConnection) -> QueryResult<String> { |
| 75 | + let (token, hashed_token) = generate_trustpub_token(); |
22 | 76 |
|
23 |
| -static GITHUB_PUBLIC_KEY_SIGNATURE: &str = "MEUCIFLZzeK++IhS+y276SRk2Pe5LfDrfvTXu6iwKKcFGCrvAiEAhHN2kDOhy2I6eGkOFmxNkOJ+L2y8oQ9A2T9GGJo6WJY="; |
| 77 | + let new_token = NewToken { |
| 78 | + expires_at: Utc::now() + TimeDelta::minutes(30), |
| 79 | + hashed_token: &hashed_token, |
| 80 | + crate_ids: &[1], // Arbitrary crate ID for testing |
| 81 | + }; |
| 82 | + |
| 83 | + new_token.insert(conn).await?; |
| 84 | + |
| 85 | + Ok(token) |
| 86 | +} |
24 | 87 |
|
25 | 88 | fn github_mock() -> MockGitHubClient {
|
26 | 89 | let mut mock = MockGitHubClient::new();
|
27 | 90 |
|
28 | 91 | mock.expect_public_keys().returning(|_, _| {
|
29 | 92 | let key = GitHubPublicKey {
|
30 |
| - key_identifier: GITHUB_PUBLIC_KEY_IDENTIFIER.to_string(), |
31 |
| - key: GITHUB_PUBLIC_KEY.to_string(), |
| 93 | + key_identifier: KEY_IDENTIFIER.to_string(), |
| 94 | + key: PUBLIC_KEY.to_string(), |
32 | 95 | is_current: true,
|
33 | 96 | };
|
34 | 97 |
|
@@ -70,8 +133,8 @@ async fn github_secret_alert_revokes_token() {
|
70 | 133 |
|
71 | 134 | let mut request = anon.post_request(URL);
|
72 | 135 | *request.body_mut() = GITHUB_ALERT.into();
|
73 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
74 |
| - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); |
| 136 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
| 137 | + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); |
75 | 138 | let response = anon.run::<()>(request).await;
|
76 | 139 | assert_snapshot!(response.status(), @"200 OK");
|
77 | 140 | assert_json_snapshot!(response.json());
|
@@ -134,8 +197,8 @@ async fn github_secret_alert_for_revoked_token() {
|
134 | 197 |
|
135 | 198 | let mut request = anon.post_request(URL);
|
136 | 199 | *request.body_mut() = GITHUB_ALERT.into();
|
137 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
138 |
| - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); |
| 200 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
| 201 | + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); |
139 | 202 | let response = anon.run::<()>(request).await;
|
140 | 203 | assert_snapshot!(response.status(), @"200 OK");
|
141 | 204 | assert_json_snapshot!(response.json());
|
@@ -187,8 +250,8 @@ async fn github_secret_alert_for_unknown_token() {
|
187 | 250 |
|
188 | 251 | let mut request = anon.post_request(URL);
|
189 | 252 | *request.body_mut() = GITHUB_ALERT.into();
|
190 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
191 |
| - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); |
| 253 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
| 254 | + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); |
192 | 255 | let response = anon.run::<()>(request).await;
|
193 | 256 | assert_snapshot!(response.status(), @"200 OK");
|
194 | 257 | assert_json_snapshot!(response.json());
|
@@ -225,31 +288,105 @@ async fn github_secret_alert_invalid_signature_fails() {
|
225 | 288 |
|
226 | 289 | // Headers but no request body
|
227 | 290 | let mut request = anon.post_request(URL);
|
228 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
229 |
| - request.header("GITHUB-PUBLIC-KEY-SIGNATURE", GITHUB_PUBLIC_KEY_SIGNATURE); |
| 291 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
| 292 | + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(GITHUB_ALERT)); |
230 | 293 | let response = anon.run::<()>(request).await;
|
231 | 294 | assert_snapshot!(response.status(), @"400 Bad Request");
|
232 | 295 |
|
233 | 296 | // Request body but only key identifier header
|
234 | 297 | let mut request = anon.post_request(URL);
|
235 | 298 | *request.body_mut() = GITHUB_ALERT.into();
|
236 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
| 299 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
237 | 300 | let response = anon.run::<()>(request).await;
|
238 | 301 | assert_snapshot!(response.status(), @"400 Bad Request");
|
239 | 302 |
|
240 | 303 | // Invalid signature
|
241 | 304 | let mut request = anon.post_request(URL);
|
242 | 305 | *request.body_mut() = GITHUB_ALERT.into();
|
243 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
| 306 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
244 | 307 | request.header("GITHUB-PUBLIC-KEY-SIGNATURE", "bad signature");
|
245 | 308 | let response = anon.run::<()>(request).await;
|
246 | 309 | assert_snapshot!(response.status(), @"400 Bad Request");
|
247 | 310 |
|
248 | 311 | // Invalid signature that is valid base64
|
249 | 312 | let mut request = anon.post_request(URL);
|
250 | 313 | *request.body_mut() = GITHUB_ALERT.into();
|
251 |
| - request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", GITHUB_PUBLIC_KEY_IDENTIFIER); |
| 314 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
252 | 315 | request.header("GITHUB-PUBLIC-KEY-SIGNATURE", "YmFkIHNpZ25hdHVyZQ==");
|
253 | 316 | let response = anon.run::<()>(request).await;
|
254 | 317 | assert_snapshot!(response.status(), @"400 Bad Request");
|
255 | 318 | }
|
| 319 | + |
| 320 | +#[tokio::test(flavor = "multi_thread")] |
| 321 | +async fn github_secret_alert_revokes_trustpub_token() { |
| 322 | + let (app, anon) = TestApp::init().with_github(github_mock()).empty().await; |
| 323 | + let mut conn = app.db_conn().await; |
| 324 | + |
| 325 | + // Generate a valid Trusted Publishing token |
| 326 | + let token = insert_trustpub_token(&mut conn).await.unwrap(); |
| 327 | + |
| 328 | + // Verify the token exists in the database |
| 329 | + let count = trustpub_tokens::table |
| 330 | + .count() |
| 331 | + .get_result::<i64>(&mut conn) |
| 332 | + .await |
| 333 | + .unwrap(); |
| 334 | + assert_eq!(count, 1); |
| 335 | + |
| 336 | + // Send the GitHub alert to the API endpoint |
| 337 | + let mut request = anon.post_request(URL); |
| 338 | + let vec = github_alert_with_token(&token); |
| 339 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
| 340 | + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(&vec)); |
| 341 | + *request.body_mut() = vec.into(); |
| 342 | + let response = anon.run::<()>(request).await; |
| 343 | + assert_snapshot!(response.status(), @"200 OK"); |
| 344 | + assert_json_snapshot!(response.json(), { |
| 345 | + "[].token_raw" => api_token_redaction() |
| 346 | + }); |
| 347 | + |
| 348 | + // Verify the token was deleted from the database |
| 349 | + let count = trustpub_tokens::table |
| 350 | + .count() |
| 351 | + .get_result::<i64>(&mut conn) |
| 352 | + .await |
| 353 | + .unwrap(); |
| 354 | + assert_eq!(count, 0); |
| 355 | +} |
| 356 | + |
| 357 | +#[tokio::test(flavor = "multi_thread")] |
| 358 | +async fn github_secret_alert_for_unknown_trustpub_token() { |
| 359 | + let (app, anon) = TestApp::init().with_github(github_mock()).empty().await; |
| 360 | + let mut conn = app.db_conn().await; |
| 361 | + |
| 362 | + // Generate a valid Trusted Publishing token but don't insert it into the database |
| 363 | + let (token, _) = generate_trustpub_token(); |
| 364 | + |
| 365 | + // Verify no tokens exist in the database |
| 366 | + let count = trustpub_tokens::table |
| 367 | + .count() |
| 368 | + .get_result::<i64>(&mut conn) |
| 369 | + .await |
| 370 | + .unwrap(); |
| 371 | + assert_eq!(count, 0); |
| 372 | + |
| 373 | + // Send the GitHub alert to the API endpoint |
| 374 | + let mut request = anon.post_request(URL); |
| 375 | + let vec = github_alert_with_token(&token); |
| 376 | + request.header("GITHUB-PUBLIC-KEY-IDENTIFIER", KEY_IDENTIFIER); |
| 377 | + request.header("GITHUB-PUBLIC-KEY-SIGNATURE", &sign_payload(&vec)); |
| 378 | + *request.body_mut() = vec.into(); |
| 379 | + let response = anon.run::<()>(request).await; |
| 380 | + assert_snapshot!(response.status(), @"200 OK"); |
| 381 | + assert_json_snapshot!(response.json(), { |
| 382 | + "[].token_raw" => api_token_redaction() |
| 383 | + }); |
| 384 | + |
| 385 | + // Verify still no tokens exist in the database |
| 386 | + let count = trustpub_tokens::table |
| 387 | + .count() |
| 388 | + .get_result::<i64>(&mut conn) |
| 389 | + .await |
| 390 | + .unwrap(); |
| 391 | + assert_eq!(count, 0); |
| 392 | +} |
0 commit comments