From d6fa9d9cc7ee87e495066bec063ecefbb594deb3 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 09:01:49 +0200 Subject: [PATCH 01/21] diesel_helpers: Adjust `lower()` fn to also accept `NULL` --- crates/crates_io_database/src/models/category.rs | 3 ++- crates/crates_io_diesel_helpers/src/fns.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/crates_io_database/src/models/category.rs b/crates/crates_io_database/src/models/category.rs index 2692471654e..62c870b0b90 100644 --- a/crates/crates_io_database/src/models/category.rs +++ b/crates/crates_io_database/src/models/category.rs @@ -3,6 +3,7 @@ use crate::schema::*; use chrono::{DateTime, Utc}; use diesel::dsl; use diesel::prelude::*; +use diesel::sql_types::Text; use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; use futures_util::future::BoxFuture; @@ -20,7 +21,7 @@ pub struct Category { pub created_at: DateTime, } -type WithSlug<'a> = dsl::Eq>; +type WithSlug<'a> = dsl::Eq>; #[derive(Associations, Insertable, Identifiable, Debug, Clone, Copy)] #[diesel( diff --git a/crates/crates_io_diesel_helpers/src/fns.rs b/crates/crates_io_diesel_helpers/src/fns.rs index ace831c8ffd..1a241d66f50 100644 --- a/crates/crates_io_diesel_helpers/src/fns.rs +++ b/crates/crates_io_diesel_helpers/src/fns.rs @@ -4,7 +4,7 @@ use diesel::sql_types::{Date, Double, Integer, Interval, SingleValue, Text, Time define_sql_function!(#[aggregate] fn array_agg(x: T) -> Array); define_sql_function!(fn canon_crate_name(x: Text) -> Text); define_sql_function!(fn to_char(a: Date, b: Text) -> Text); -define_sql_function!(fn lower(x: Text) -> Text); +define_sql_function!(fn lower(x: T) -> T); define_sql_function!(fn date_part(x: Text, y: Timestamptz) -> Double); define_sql_function! { #[sql_name = "date_part"] From 6eda81f02a86d5f3da32ac077ac5cee551a9b284 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 13:58:10 +0200 Subject: [PATCH 02/21] trustpub: Implement `load_jwks()` fn --- Cargo.lock | 77 +++++++++ crates/crates_io_trustpub/Cargo.toml | 5 + .../src/keystore/load_jwks.rs | 157 ++++++++++++++++++ crates/crates_io_trustpub/src/keystore/mod.rs | 1 + ...keystore__load_jwks__tests__load_jwks.snap | 126 ++++++++++++++ crates/crates_io_trustpub/src/lib.rs | 1 + 6 files changed, 367 insertions(+) create mode 100644 crates/crates_io_trustpub/src/keystore/load_jwks.rs create mode 100644 crates/crates_io_trustpub/src/keystore/mod.rs create mode 100644 crates/crates_io_trustpub/src/keystore/snapshots/crates_io_trustpub__keystore__load_jwks__tests__load_jwks.snap diff --git a/Cargo.lock b/Cargo.lock index eb578b56bef..48e879a010b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,16 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tokio-tar" version = "0.5.2" @@ -1500,8 +1510,13 @@ version = "0.0.0" dependencies = [ "claims", "insta", + "jsonwebtoken", + "mockito", "regex", + "reqwest", + "serde", "thiserror 2.0.12", + "tokio", ] [[package]] @@ -3151,6 +3166,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3489,6 +3519,30 @@ dependencies = [ "syn", ] +[[package]] +name = "mockito" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7760e0e418d9b7e5777c0374009ca4c93861b9066f18cb334a20ce50ab63aa48" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "log", + "rand 0.9.1", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "moka" version = "0.12.10" @@ -3814,6 +3868,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5055,6 +5119,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -5437,6 +5513,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index dbf9c1a5e36..9a1f4bc966f 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -8,9 +8,14 @@ edition = "2024" workspace = true [dependencies] +jsonwebtoken = "=9.3.1" +reqwest = { version = "=0.12.15", features = ["gzip", "json"] } regex = "=1.11.1" +serde = { version = "=1.0.219", features = ["derive"] } thiserror = "=2.0.12" [dev-dependencies] claims = "=0.8.0" insta = "=1.43.1" +mockito = "=1.7.0" +tokio = { version = "=1.45.0", features = ["macros", "rt-multi-thread"] } diff --git a/crates/crates_io_trustpub/src/keystore/load_jwks.rs b/crates/crates_io_trustpub/src/keystore/load_jwks.rs new file mode 100644 index 00000000000..9a1815224db --- /dev/null +++ b/crates/crates_io_trustpub/src/keystore/load_jwks.rs @@ -0,0 +1,157 @@ +use jsonwebtoken::jwk::JwkSet; +use reqwest::Client; + +/// Loads JSON Web Key Sets (JWKS) from an OpenID Connect provider. +/// +/// This function implements the OpenID Connect Discovery process to fetch the JWKS: +/// +/// 1. It first retrieves the OpenID configuration from the standard +/// `.well-known/openid-configuration` endpoint at the provided issuer URI. +/// 2. It extracts the `jwks_uri` from the configuration. +/// 3. It fetches the JWKS from the extracted URI. +/// +/// The JWKS contains the public keys used to verify JWT signatures issued by the provider. +#[allow(dead_code)] +pub async fn load_jwks(client: &Client, issuer_uri: &str) -> reqwest::Result { + #[derive(Debug, serde::Deserialize)] + struct OpenIdConfig { + jwks_uri: String, + } + + let url = format!("{issuer_uri}/.well-known/openid-configuration"); + let response = client.get(url).send().await?.error_for_status()?; + let openid_config: OpenIdConfig = response.json().await?; + + let url = openid_config.jwks_uri; + let response = client.get(url).send().await?.error_for_status()?; + let jwks: JwkSet = response.json().await?; + + Ok(jwks) +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::assert_ok; + use insta::assert_debug_snapshot; + + const GITHUB_JWKS: &str = r#"{ + "keys": [{ + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "cc413527-173f-5a05-976e-9c52b1d7b431", + "n": "w4M936N3ZxNaEblcUoBm-xu0-V9JxNx5S7TmF0M3SBK-2bmDyAeDdeIOTcIVZHG-ZX9N9W0u1yWafgWewHrsz66BkxXq3bscvQUTAw7W3s6TEeYY7o9shPkFfOiU3x_KYgOo06SpiFdymwJflRs9cnbaU88i5fZJmUepUHVllP2tpPWTi-7UA3AdP3cdcCs5bnFfTRKzH2W0xqKsY_jIG95aQJRBDpbiesefjuyxcQnOv88j9tCKWzHpJzRKYjAUM6OPgN4HYnaSWrPJj1v41eEkFM1kORuj-GSH2qMVD02VklcqaerhQHIqM-RjeHsN7G05YtwYzomE5G-fZuwgvQ", + "e": "AQAB" + }, { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "38826b17-6a30-5f9b-b169-8beb8202f723", + "n": "5Manmy-zwsk3wEftXNdKFZec4rSWENW4jTGevlvAcU9z3bgLBogQVvqYLtu9baVm2B3rfe5onadobq8po5UakJ0YsTiiEfXWdST7YI2Sdkvv-hOYMcZKYZ4dFvuSO1vQ2DgEkw_OZNiYI1S518MWEcNxnPU5u67zkawAGsLlmXNbOylgVfBRJrG8gj6scr-sBs4LaCa3kg5IuaCHe1pB-nSYHovGV_z0egE83C098FfwO1dNZBWeo4Obhb5Z-ZYFLJcZfngMY0zJnCVNmpHQWOgxfGikh3cwi4MYrFrbB4NTlxbrQ3bL-rGKR5X318veyDlo8Dyz2KWMobT4wB9U1Q", + "e": "AQAB", + "x5c": ["MIIDKzCCAhOgAwIBAgIUDnwm6eRIqGFA3o/P1oBrChvx/nowDQYJKoZIhvcNAQELBQAwJTEjMCEGA1UEAwwaYWN0aW9ucy5zZWxmLXNpZ25lZC5naXRodWIwHhcNMjQwMTIzMTUyNTM2WhcNMzQwMTIwMTUyNTM2WjAlMSMwIQYDVQQDDBphY3Rpb25zLnNlbGYtc2lnbmVkLmdpdGh1YjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOTGp5svs8LJN8BH7VzXShWXnOK0lhDVuI0xnr5bwHFPc924CwaIEFb6mC7bvW2lZtgd633uaJ2naG6vKaOVGpCdGLE4ohH11nUk+2CNknZL7/oTmDHGSmGeHRb7kjtb0Ng4BJMPzmTYmCNUudfDFhHDcZz1Obuu85GsABrC5ZlzWzspYFXwUSaxvII+rHK/rAbOC2gmt5IOSLmgh3taQfp0mB6Lxlf89HoBPNwtPfBX8DtXTWQVnqODm4W+WfmWBSyXGX54DGNMyZwlTZqR0FjoMXxopId3MIuDGKxa2weDU5cW60N2y/qxikeV99fL3sg5aPA8s9iljKG0+MAfVNUCAwEAAaNTMFEwHQYDVR0OBBYEFIPALo5VanJ6E1B9eLQgGO+uGV65MB8GA1UdIwQYMBaAFIPALo5VanJ6E1B9eLQgGO+uGV65MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGS0hZE+DqKIRi49Z2KDOMOaSZnAYgqq6ws9HJHT09MXWlMHB8E/apvy2ZuFrcSu14ZLweJid+PrrooXEXEO6azEakzCjeUb9G1QwlzP4CkTcMGCw1Snh3jWZIuKaw21f7mp2rQ+YNltgHVDKY2s8AD273E8musEsWxJl80/MNvMie8Hfh4n4/Xl2r6t1YPmUJMoXAXdTBb0hkPy1fUu3r2T+1oi7Rw6kuVDfAZjaHupNHzJeDOg2KxUoK/GF2/M2qpVrd19Pv/JXNkQXRE4DFbErMmA7tXpp1tkXJRPhFui/Pv5H9cPgObEf9x6W4KnCXzT3ReeeRDKF8SqGTPELsc="], + "x5t": "ykNaY4qM_ta4k2TgZOCEYLkcYlA" + }, { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "1F2AB83404C08EC9EA0BB99DAED02186B091DBF4", + "n": "u8zSYn5JR_O5yywSeOhmWWd7OMoLblh4iGTeIhTOVon-5e54RK30YQDeUCjpb9u3vdHTO7XS7i6EzkwLbsUOir27uhqoFGGWXSAZrPocOobSFoLC5l0NvSKRqVtpoADOHcAh59vLbr8dz3xtEEGx_qlLTzfFfWiCIYWiy15C2oo1eNPxzQfOvdu7Yet6Of4musV0Es5_mNETpeHOVEri8PWfxzw485UHIj3socl4Lk_I3iDyHfgpT49tIJYhHE5NImLNdwMha1cBCIbJMy1dJCfdoK827Hi9qKyBmftNQPhezGVRsOjsf2BfUGzGP5pCGrFBjEOcLhj_3j-TJebgvQ", + "e": "AQAB", + "x5c": ["MIIDrDCCApSgAwIBAgIQAP4blP36Q3WmMOhWf0RBMzANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAyNDE0NTI1NVoXDTI1MTAyNDE1MDI1NVowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALvM0mJ+SUfzucssEnjoZllnezjKC25YeIhk3iIUzlaJ/uXueESt9GEA3lAo6W/bt73R0zu10u4uhM5MC27FDoq9u7oaqBRhll0gGaz6HDqG0haCwuZdDb0ikalbaaAAzh3AIefby26/Hc98bRBBsf6pS083xX1ogiGFosteQtqKNXjT8c0Hzr3bu2Hrejn+JrrFdBLOf5jRE6XhzlRK4vD1n8c8OPOVByI97KHJeC5PyN4g8h34KU+PbSCWIRxOTSJizXcDIWtXAQiGyTMtXSQn3aCvNux4vaisgZn7TUD4XsxlUbDo7H9gX1Bsxj+aQhqxQYxDnC4Y/94/kyXm4L0CAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBSmWMP5CXuaSzoLKwcLXYZnoeCJmDAdBgNVHQ4EFgQUpljD+Ql7mks6CysHC12GZ6HgiZgwDQYJKoZIhvcNAQELBQADggEBAINwybFwYpXJkvauL5QbtrykIDYeP8oFdVIeVY8YI9MGfx7OwWDsNBVXv2B62zAZ49hK5G87++NmFI/FHnGOCISDYoJkRSCy2Nbeyr7Nx2VykWzUQqHLZfvr5KqW4Gj1OFHUqTl8lP3FWDd/P+lil3JobaSiICQshgF0GnX2a8ji8mfXpJSP20gzrLw84brmtmheAvJ9X/sLbM/RBkkT6g4NV2QbTMqo6k601qBNQBsH+lTDDWPCkRoAlW6a0z9bWIhGHWJ2lcR70zagcxIVl5/Fq35770/aMGroSrIx3JayOEqsvgIthYBKHzpT2VFwUz1VpBpNVJg9/u6jCwLY7QA="], + "x5t": "Hyq4NATAjsnqC7mdrtAhhrCR2_Q" + }, { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD", + "n": "sI_r4iOwvRxksSovyZN8da5u-dh07fdcqh7FjyKKZCOVr7da898xk0TG9eZ7lfA1CmBTH4sX5evg4Yg2xdFDxYK4xmLZcwMyQZIDiZcdIujnttaqplrMv_v-YyAapHFmudbBO8NVuOH3gmGaJ02G8u1Vdf8C3PdNK13ch4wpNvyoxwqaIWGPSzudA6mGPGovRLhu5dEOOJSJtsLzExNvNmHnhPJZk06r7FePkBWSQ1CCHXAzpB-aUWEZC1FKMSiq2dvfOCyiJttEdyj8O_5yqb0wLAPb-8NdzkppbRal2WGowoU-AejqoWImhfDzlOBQStnhuAluKpA6sH0ifKlQsQ", + "e": "AQAB", + "x5c": ["MIIDrDCCApSgAwIBAgIQKiyRrA01T5qtxdzvZ/ErzjANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAxODE1MDExOFoXDTI1MTAxODE1MTExOFowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCP6+IjsL0cZLEqL8mTfHWubvnYdO33XKoexY8iimQjla+3WvPfMZNExvXme5XwNQpgUx+LF+Xr4OGINsXRQ8WCuMZi2XMDMkGSA4mXHSLo57bWqqZazL/7/mMgGqRxZrnWwTvDVbjh94JhmidNhvLtVXX/Atz3TStd3IeMKTb8qMcKmiFhj0s7nQOphjxqL0S4buXRDjiUibbC8xMTbzZh54TyWZNOq+xXj5AVkkNQgh1wM6QfmlFhGQtRSjEoqtnb3zgsoibbRHco/Dv+cqm9MCwD2/vDXc5KaW0WpdlhqMKFPgHo6qFiJoXw85TgUErZ4bgJbiqQOrB9InypULECAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBQ45rBfvl4JJ7vg3WgLjQTfhDihvzAdBgNVHQ4EFgQUOOawX75eCSe74N1oC40E34Q4ob8wDQYJKoZIhvcNAQELBQADggEBABdN6HPheRdzwvJgi4xGHnf9pvlUC8981kAtgHnPT0VEYXh/dCMnKJSvCDJADpdmkuKxLxAfACeZR2CUHkQ0eO1ek/ihLvPqywDhLENq6Lvzu3qlhvUPBkGYjydpLtXQ1bBXUQ1FzT5/L1U19P2rJso9mC4ltu2OHJ9NLCKG0zffBItAJqhAiXtKbCUg4c9RbQxi9T2/xr9R72di4Qygfnmr3QleAqmjRG918cm5/uJ0s5EaK3QI7GQy7+tc44o3H3AI5eFtrHwIV0zoY4A9YIsaRmMHq9soHFBEO1HDKKRUOl/4tjpx8zHpp5Clz0wiZMgvSIdBa3/fTeUJ3flUYMo="], + "x5t": "AB3c0BSoSOiCRXez5POu2zvPX_0" + }] + }"#; + + fn make_github_openid_config(issuer_uri: &str) -> String { + format!( + r#"{{ + "issuer": "{issuer_uri}", + "jwks_uri": "{issuer_uri}/.well-known/jwks", + "subject_types_supported": [ + "public", + "pairwise" + ], + "response_types_supported": [ + "id_token" + ], + "claims_supported": [ + "sub", + "aud", + "exp", + "iat", + "iss", + "jti", + "nbf", + "ref", + "sha", + "repository", + "repository_id", + "repository_owner", + "repository_owner_id", + "enterprise", + "enterprise_id", + "run_id", + "run_number", + "run_attempt", + "actor", + "actor_id", + "workflow", + "workflow_ref", + "workflow_sha", + "head_ref", + "base_ref", + "event_name", + "ref_type", + "ref_protected", + "environment", + "environment_node_id", + "job_workflow_ref", + "job_workflow_sha", + "repository_visibility", + "runner_environment", + "issuer_scope" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "scopes_supported": [ + "openid" + ] + }}"#, + ) + } + + #[tokio::test] + async fn test_load_jwks() { + let mut server = mockito::Server::new_async().await; + + let issuer_url = server.url(); + + let _config_mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_header("content-type", "application/json") + .with_body(make_github_openid_config(&issuer_url)) + .create(); + + let _jwks_mock = server + .mock("GET", "/.well-known/jwks") + .with_header("content-type", "application/json") + .with_body(GITHUB_JWKS) + .create(); + + let client = Client::new(); + let jwks = assert_ok!(load_jwks(&client, &issuer_url).await); + assert_debug_snapshot!(jwks); + } +} diff --git a/crates/crates_io_trustpub/src/keystore/mod.rs b/crates/crates_io_trustpub/src/keystore/mod.rs new file mode 100644 index 00000000000..20b4c8f2547 --- /dev/null +++ b/crates/crates_io_trustpub/src/keystore/mod.rs @@ -0,0 +1 @@ +mod load_jwks; diff --git a/crates/crates_io_trustpub/src/keystore/snapshots/crates_io_trustpub__keystore__load_jwks__tests__load_jwks.snap b/crates/crates_io_trustpub/src/keystore/snapshots/crates_io_trustpub__keystore__load_jwks__tests__load_jwks.snap new file mode 100644 index 00000000000..33d599cd793 --- /dev/null +++ b/crates/crates_io_trustpub/src/keystore/snapshots/crates_io_trustpub__keystore__load_jwks__tests__load_jwks.snap @@ -0,0 +1,126 @@ +--- +source: crates/crates_io_trustpub/src/keystore/load_jwks.rs +expression: jwks +--- +JwkSet { + keys: [ + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "cc413527-173f-5a05-976e-9c52b1d7b431", + ), + x509_url: None, + x509_chain: None, + x509_sha1_fingerprint: None, + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "w4M936N3ZxNaEblcUoBm-xu0-V9JxNx5S7TmF0M3SBK-2bmDyAeDdeIOTcIVZHG-ZX9N9W0u1yWafgWewHrsz66BkxXq3bscvQUTAw7W3s6TEeYY7o9shPkFfOiU3x_KYgOo06SpiFdymwJflRs9cnbaU88i5fZJmUepUHVllP2tpPWTi-7UA3AdP3cdcCs5bnFfTRKzH2W0xqKsY_jIG95aQJRBDpbiesefjuyxcQnOv88j9tCKWzHpJzRKYjAUM6OPgN4HYnaSWrPJj1v41eEkFM1kORuj-GSH2qMVD02VklcqaerhQHIqM-RjeHsN7G05YtwYzomE5G-fZuwgvQ", + e: "AQAB", + }, + ), + }, + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "38826b17-6a30-5f9b-b169-8beb8202f723", + ), + x509_url: None, + x509_chain: Some( + [ + "MIIDKzCCAhOgAwIBAgIUDnwm6eRIqGFA3o/P1oBrChvx/nowDQYJKoZIhvcNAQELBQAwJTEjMCEGA1UEAwwaYWN0aW9ucy5zZWxmLXNpZ25lZC5naXRodWIwHhcNMjQwMTIzMTUyNTM2WhcNMzQwMTIwMTUyNTM2WjAlMSMwIQYDVQQDDBphY3Rpb25zLnNlbGYtc2lnbmVkLmdpdGh1YjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOTGp5svs8LJN8BH7VzXShWXnOK0lhDVuI0xnr5bwHFPc924CwaIEFb6mC7bvW2lZtgd633uaJ2naG6vKaOVGpCdGLE4ohH11nUk+2CNknZL7/oTmDHGSmGeHRb7kjtb0Ng4BJMPzmTYmCNUudfDFhHDcZz1Obuu85GsABrC5ZlzWzspYFXwUSaxvII+rHK/rAbOC2gmt5IOSLmgh3taQfp0mB6Lxlf89HoBPNwtPfBX8DtXTWQVnqODm4W+WfmWBSyXGX54DGNMyZwlTZqR0FjoMXxopId3MIuDGKxa2weDU5cW60N2y/qxikeV99fL3sg5aPA8s9iljKG0+MAfVNUCAwEAAaNTMFEwHQYDVR0OBBYEFIPALo5VanJ6E1B9eLQgGO+uGV65MB8GA1UdIwQYMBaAFIPALo5VanJ6E1B9eLQgGO+uGV65MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGS0hZE+DqKIRi49Z2KDOMOaSZnAYgqq6ws9HJHT09MXWlMHB8E/apvy2ZuFrcSu14ZLweJid+PrrooXEXEO6azEakzCjeUb9G1QwlzP4CkTcMGCw1Snh3jWZIuKaw21f7mp2rQ+YNltgHVDKY2s8AD273E8musEsWxJl80/MNvMie8Hfh4n4/Xl2r6t1YPmUJMoXAXdTBb0hkPy1fUu3r2T+1oi7Rw6kuVDfAZjaHupNHzJeDOg2KxUoK/GF2/M2qpVrd19Pv/JXNkQXRE4DFbErMmA7tXpp1tkXJRPhFui/Pv5H9cPgObEf9x6W4KnCXzT3ReeeRDKF8SqGTPELsc=", + ], + ), + x509_sha1_fingerprint: Some( + "ykNaY4qM_ta4k2TgZOCEYLkcYlA", + ), + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "5Manmy-zwsk3wEftXNdKFZec4rSWENW4jTGevlvAcU9z3bgLBogQVvqYLtu9baVm2B3rfe5onadobq8po5UakJ0YsTiiEfXWdST7YI2Sdkvv-hOYMcZKYZ4dFvuSO1vQ2DgEkw_OZNiYI1S518MWEcNxnPU5u67zkawAGsLlmXNbOylgVfBRJrG8gj6scr-sBs4LaCa3kg5IuaCHe1pB-nSYHovGV_z0egE83C098FfwO1dNZBWeo4Obhb5Z-ZYFLJcZfngMY0zJnCVNmpHQWOgxfGikh3cwi4MYrFrbB4NTlxbrQ3bL-rGKR5X318veyDlo8Dyz2KWMobT4wB9U1Q", + e: "AQAB", + }, + ), + }, + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "1F2AB83404C08EC9EA0BB99DAED02186B091DBF4", + ), + x509_url: None, + x509_chain: Some( + [ + "MIIDrDCCApSgAwIBAgIQAP4blP36Q3WmMOhWf0RBMzANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAyNDE0NTI1NVoXDTI1MTAyNDE1MDI1NVowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALvM0mJ+SUfzucssEnjoZllnezjKC25YeIhk3iIUzlaJ/uXueESt9GEA3lAo6W/bt73R0zu10u4uhM5MC27FDoq9u7oaqBRhll0gGaz6HDqG0haCwuZdDb0ikalbaaAAzh3AIefby26/Hc98bRBBsf6pS083xX1ogiGFosteQtqKNXjT8c0Hzr3bu2Hrejn+JrrFdBLOf5jRE6XhzlRK4vD1n8c8OPOVByI97KHJeC5PyN4g8h34KU+PbSCWIRxOTSJizXcDIWtXAQiGyTMtXSQn3aCvNux4vaisgZn7TUD4XsxlUbDo7H9gX1Bsxj+aQhqxQYxDnC4Y/94/kyXm4L0CAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBSmWMP5CXuaSzoLKwcLXYZnoeCJmDAdBgNVHQ4EFgQUpljD+Ql7mks6CysHC12GZ6HgiZgwDQYJKoZIhvcNAQELBQADggEBAINwybFwYpXJkvauL5QbtrykIDYeP8oFdVIeVY8YI9MGfx7OwWDsNBVXv2B62zAZ49hK5G87++NmFI/FHnGOCISDYoJkRSCy2Nbeyr7Nx2VykWzUQqHLZfvr5KqW4Gj1OFHUqTl8lP3FWDd/P+lil3JobaSiICQshgF0GnX2a8ji8mfXpJSP20gzrLw84brmtmheAvJ9X/sLbM/RBkkT6g4NV2QbTMqo6k601qBNQBsH+lTDDWPCkRoAlW6a0z9bWIhGHWJ2lcR70zagcxIVl5/Fq35770/aMGroSrIx3JayOEqsvgIthYBKHzpT2VFwUz1VpBpNVJg9/u6jCwLY7QA=", + ], + ), + x509_sha1_fingerprint: Some( + "Hyq4NATAjsnqC7mdrtAhhrCR2_Q", + ), + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "u8zSYn5JR_O5yywSeOhmWWd7OMoLblh4iGTeIhTOVon-5e54RK30YQDeUCjpb9u3vdHTO7XS7i6EzkwLbsUOir27uhqoFGGWXSAZrPocOobSFoLC5l0NvSKRqVtpoADOHcAh59vLbr8dz3xtEEGx_qlLTzfFfWiCIYWiy15C2oo1eNPxzQfOvdu7Yet6Of4musV0Es5_mNETpeHOVEri8PWfxzw485UHIj3socl4Lk_I3iDyHfgpT49tIJYhHE5NImLNdwMha1cBCIbJMy1dJCfdoK827Hi9qKyBmftNQPhezGVRsOjsf2BfUGzGP5pCGrFBjEOcLhj_3j-TJebgvQ", + e: "AQAB", + }, + ), + }, + Jwk { + common: CommonParameters { + public_key_use: Some( + Signature, + ), + key_operations: None, + key_algorithm: Some( + RS256, + ), + key_id: Some( + "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD", + ), + x509_url: None, + x509_chain: Some( + [ + "MIIDrDCCApSgAwIBAgIQKiyRrA01T5qtxdzvZ/ErzjANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAxODE1MDExOFoXDTI1MTAxODE1MTExOFowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCP6+IjsL0cZLEqL8mTfHWubvnYdO33XKoexY8iimQjla+3WvPfMZNExvXme5XwNQpgUx+LF+Xr4OGINsXRQ8WCuMZi2XMDMkGSA4mXHSLo57bWqqZazL/7/mMgGqRxZrnWwTvDVbjh94JhmidNhvLtVXX/Atz3TStd3IeMKTb8qMcKmiFhj0s7nQOphjxqL0S4buXRDjiUibbC8xMTbzZh54TyWZNOq+xXj5AVkkNQgh1wM6QfmlFhGQtRSjEoqtnb3zgsoibbRHco/Dv+cqm9MCwD2/vDXc5KaW0WpdlhqMKFPgHo6qFiJoXw85TgUErZ4bgJbiqQOrB9InypULECAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBQ45rBfvl4JJ7vg3WgLjQTfhDihvzAdBgNVHQ4EFgQUOOawX75eCSe74N1oC40E34Q4ob8wDQYJKoZIhvcNAQELBQADggEBABdN6HPheRdzwvJgi4xGHnf9pvlUC8981kAtgHnPT0VEYXh/dCMnKJSvCDJADpdmkuKxLxAfACeZR2CUHkQ0eO1ek/ihLvPqywDhLENq6Lvzu3qlhvUPBkGYjydpLtXQ1bBXUQ1FzT5/L1U19P2rJso9mC4ltu2OHJ9NLCKG0zffBItAJqhAiXtKbCUg4c9RbQxi9T2/xr9R72di4Qygfnmr3QleAqmjRG918cm5/uJ0s5EaK3QI7GQy7+tc44o3H3AI5eFtrHwIV0zoY4A9YIsaRmMHq9soHFBEO1HDKKRUOl/4tjpx8zHpp5Clz0wiZMgvSIdBa3/fTeUJ3flUYMo=", + ], + ), + x509_sha1_fingerprint: Some( + "AB3c0BSoSOiCRXez5POu2zvPX_0", + ), + x509_sha256_fingerprint: None, + }, + algorithm: RSA( + RSAKeyParameters { + key_type: RSA, + n: "sI_r4iOwvRxksSovyZN8da5u-dh07fdcqh7FjyKKZCOVr7da898xk0TG9eZ7lfA1CmBTH4sX5evg4Yg2xdFDxYK4xmLZcwMyQZIDiZcdIujnttaqplrMv_v-YyAapHFmudbBO8NVuOH3gmGaJ02G8u1Vdf8C3PdNK13ch4wpNvyoxwqaIWGPSzudA6mGPGovRLhu5dEOOJSJtsLzExNvNmHnhPJZk06r7FePkBWSQ1CCHXAzpB-aUWEZC1FKMSiq2dvfOCyiJttEdyj8O_5yqb0wLAPb-8NdzkppbRal2WGowoU-AejqoWImhfDzlOBQStnhuAluKpA6sH0ifKlQsQ", + e: "AQAB", + }, + ), + }, + ], +} diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs index da38d1f6d42..65f6417f5aa 100644 --- a/crates/crates_io_trustpub/src/lib.rs +++ b/crates/crates_io_trustpub/src/lib.rs @@ -1,3 +1,4 @@ #![doc = include_str!("../README.md")] pub mod github; +pub mod keystore; From 512c7622b4955a6dbe39a64a2534f9123e957148 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 15:35:56 +0200 Subject: [PATCH 03/21] trustpub: Add `GITHUB_ISSUER_URL` constant --- crates/crates_io_trustpub/src/github/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/crates_io_trustpub/src/github/mod.rs b/crates/crates_io_trustpub/src/github/mod.rs index 8695201df0e..476c846d3b5 100644 --- a/crates/crates_io_trustpub/src/github/mod.rs +++ b/crates/crates_io_trustpub/src/github/mod.rs @@ -1 +1,3 @@ pub mod validation; + +pub const GITHUB_ISSUER_URL: &str = "https://token.actions.githubusercontent.com"; From 9ed85fa39b93c7e7ddc6bb75f8c2a3a199d86bcd Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 15:30:55 +0200 Subject: [PATCH 04/21] trustpub: Add `OidcKeyStore` trait --- Cargo.lock | 2 ++ crates/crates_io_trustpub/Cargo.toml | 2 ++ crates/crates_io_trustpub/src/keystore/mod.rs | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 48e879a010b..31af86ff25d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1508,6 +1508,8 @@ dependencies = [ name = "crates_io_trustpub" version = "0.0.0" dependencies = [ + "anyhow", + "async-trait", "claims", "insta", "jsonwebtoken", diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index 9a1f4bc966f..a4a17c93060 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -8,6 +8,8 @@ edition = "2024" workspace = true [dependencies] +anyhow = "=1.0.98" +async-trait = "=0.1.88" jsonwebtoken = "=9.3.1" reqwest = { version = "=0.12.15", features = ["gzip", "json"] } regex = "=1.11.1" diff --git a/crates/crates_io_trustpub/src/keystore/mod.rs b/crates/crates_io_trustpub/src/keystore/mod.rs index 20b4c8f2547..04b25f0b15e 100644 --- a/crates/crates_io_trustpub/src/keystore/mod.rs +++ b/crates/crates_io_trustpub/src/keystore/mod.rs @@ -1 +1,14 @@ mod load_jwks; + +use async_trait::async_trait; +use jsonwebtoken::DecodingKey; + +/// A trait for fetching OIDC keys from a key store. +#[async_trait] +pub trait OidcKeyStore: Send + Sync { + /// Fetches a [`DecodingKey`] from the key store using the provided `key_id`. + /// + /// If the key is not found on the server, it will return `None`. If there + /// is an error while fetching the key, it will return an error. + async fn get_oidc_key(&self, key_id: &str) -> anyhow::Result>; +} From a0592db93e886a554b5e56e7e7db310a55836fac Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 15:36:19 +0200 Subject: [PATCH 05/21] trustpub: Implement `OidcKeyStore` trait --- crates/crates_io_trustpub/Cargo.toml | 1 + .../crates_io_trustpub/src/keystore/impl.rs | 82 +++++++++++++++++++ .../src/keystore/load_jwks.rs | 1 - crates/crates_io_trustpub/src/keystore/mod.rs | 4 + 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 crates/crates_io_trustpub/src/keystore/impl.rs diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index a4a17c93060..048d3474ac2 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -15,6 +15,7 @@ reqwest = { version = "=0.12.15", features = ["gzip", "json"] } regex = "=1.11.1" serde = { version = "=1.0.219", features = ["derive"] } thiserror = "=2.0.12" +tokio = { version = "=1.45.0", features = ["sync"] } [dev-dependencies] claims = "=0.8.0" diff --git a/crates/crates_io_trustpub/src/keystore/impl.rs b/crates/crates_io_trustpub/src/keystore/impl.rs new file mode 100644 index 00000000000..699ff550ad5 --- /dev/null +++ b/crates/crates_io_trustpub/src/keystore/impl.rs @@ -0,0 +1,82 @@ +use super::OidcKeyStore; +use super::load_jwks::load_jwks; +use async_trait::async_trait; +use jsonwebtoken::DecodingKey; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// The main implementation of the [`OidcKeyStore`] trait. +/// +/// This struct fetches OIDC keys from a remote provider and caches them. If +/// a key is not found in the cache, it will attempt to refresh the cached +/// key set, unless the cache has just recently been refreshed. +pub struct RealOidcKeyStore { + issuer_uri: String, + client: reqwest::Client, + cache: RwLock, +} + +#[derive(Default)] +struct Cache { + keys: HashMap, + last_update: Option, +} + +impl RealOidcKeyStore { + /// Creates a new instance of [`RealOidcKeyStore`]. + pub fn new(issuer_uri: String) -> Self { + let client = reqwest::Client::builder() + .user_agent("crates.io") + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + + Self { + issuer_uri, + client, + cache: RwLock::new(Cache::default()), + } + } +} + +#[async_trait] +impl OidcKeyStore for RealOidcKeyStore { + async fn get_oidc_key(&self, key_id: &str) -> anyhow::Result> { + const MIN_AGE_BEFORE_REFRESH: Duration = Duration::from_secs(60); + + // First, try to get the key with just a read lock. + let cache = self.cache.read().await; + if let Some(key) = cache.keys.get(key_id) { + return Ok(Some(key.clone())); + } + + // If that fails, drop the read lock before acquiring the write lock. + drop(cache); + + let mut cache = self.cache.write().await; + if cache + .last_update + .is_some_and(|last_update| last_update.elapsed() < MIN_AGE_BEFORE_REFRESH) + { + // If we're in a cooldown from a previous refresh, return + // whatever is in the cache. + return Ok(cache.keys.get(key_id).cloned()); + } + + // Load the keys from the OIDC provider. + let jwks = load_jwks(&self.client, &self.issuer_uri).await?; + + cache.keys.clear(); + for key in jwks.keys { + if let Some(key_id) = &key.common.key_id { + let decoding_key = DecodingKey::from_jwk(&key)?; + cache.keys.insert(key_id.clone(), decoding_key); + } + } + + cache.last_update = Some(Instant::now()); + + Ok(cache.keys.get(key_id).cloned()) + } +} diff --git a/crates/crates_io_trustpub/src/keystore/load_jwks.rs b/crates/crates_io_trustpub/src/keystore/load_jwks.rs index 9a1815224db..75c12d0914f 100644 --- a/crates/crates_io_trustpub/src/keystore/load_jwks.rs +++ b/crates/crates_io_trustpub/src/keystore/load_jwks.rs @@ -11,7 +11,6 @@ use reqwest::Client; /// 3. It fetches the JWKS from the extracted URI. /// /// The JWKS contains the public keys used to verify JWT signatures issued by the provider. -#[allow(dead_code)] pub async fn load_jwks(client: &Client, issuer_uri: &str) -> reqwest::Result { #[derive(Debug, serde::Deserialize)] struct OpenIdConfig { diff --git a/crates/crates_io_trustpub/src/keystore/mod.rs b/crates/crates_io_trustpub/src/keystore/mod.rs index 04b25f0b15e..a1a86767761 100644 --- a/crates/crates_io_trustpub/src/keystore/mod.rs +++ b/crates/crates_io_trustpub/src/keystore/mod.rs @@ -1,9 +1,13 @@ +mod r#impl; mod load_jwks; use async_trait::async_trait; +pub use r#impl::RealOidcKeyStore; use jsonwebtoken::DecodingKey; /// A trait for fetching OIDC keys from a key store. +/// +/// The main implementation is [`RealOidcKeyStore`]. #[async_trait] pub trait OidcKeyStore: Send + Sync { /// Fetches a [`DecodingKey`] from the key store using the provided `key_id`. From 31e6708df4a9fe9144732316266d4544158e05b5 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 15:31:48 +0200 Subject: [PATCH 06/21] trustpub: Add mock `OidcKeyStore` implementation --- Cargo.lock | 1 + crates/crates_io_trustpub/Cargo.toml | 4 ++++ crates/crates_io_trustpub/src/keystore/mod.rs | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 31af86ff25d..ff7fc14bbb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1513,6 +1513,7 @@ dependencies = [ "claims", "insta", "jsonwebtoken", + "mockall", "mockito", "regex", "reqwest", diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index 048d3474ac2..7a7b5b4616f 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -7,10 +7,14 @@ edition = "2024" [lints] workspace = true +[features] +test-helpers = ["dep:mockall"] + [dependencies] anyhow = "=1.0.98" async-trait = "=0.1.88" jsonwebtoken = "=9.3.1" +mockall = { version = "=0.13.1", optional = true } reqwest = { version = "=0.12.15", features = ["gzip", "json"] } regex = "=1.11.1" serde = { version = "=1.0.219", features = ["derive"] } diff --git a/crates/crates_io_trustpub/src/keystore/mod.rs b/crates/crates_io_trustpub/src/keystore/mod.rs index a1a86767761..d7210b989a7 100644 --- a/crates/crates_io_trustpub/src/keystore/mod.rs +++ b/crates/crates_io_trustpub/src/keystore/mod.rs @@ -7,7 +7,9 @@ use jsonwebtoken::DecodingKey; /// A trait for fetching OIDC keys from a key store. /// -/// The main implementation is [`RealOidcKeyStore`]. +/// The main implementation is [`RealOidcKeyStore`], but for testing purposes +/// there is also a mock implementation available. +#[cfg_attr(feature = "test-helpers", mockall::automock)] #[async_trait] pub trait OidcKeyStore: Send + Sync { /// Fetches a [`DecodingKey`] from the key store using the provided `key_id`. From 6a2caee6a6d4b532783ac0522bef57f1c649fea7 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 16:21:48 +0200 Subject: [PATCH 07/21] trustpub: Add RSA keys for testing purposes --- Cargo.lock | 1 + crates/crates_io_trustpub/Cargo.toml | 1 + crates/crates_io_trustpub/src/lib.rs | 2 + crates/crates_io_trustpub/src/test_keys.rs | 71 ++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 crates/crates_io_trustpub/src/test_keys.rs diff --git a/Cargo.lock b/Cargo.lock index ff7fc14bbb8..cb4c58c4684 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1518,6 +1518,7 @@ dependencies = [ "regex", "reqwest", "serde", + "serde_json", "thiserror 2.0.12", "tokio", ] diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index 7a7b5b4616f..7b3a3958a11 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -25,4 +25,5 @@ tokio = { version = "=1.45.0", features = ["sync"] } claims = "=0.8.0" insta = "=1.43.1" mockito = "=1.7.0" +serde_json = "=1.0.140" tokio = { version = "=1.45.0", features = ["macros", "rt-multi-thread"] } diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs index 65f6417f5aa..f9fa72326bb 100644 --- a/crates/crates_io_trustpub/src/lib.rs +++ b/crates/crates_io_trustpub/src/lib.rs @@ -2,3 +2,5 @@ pub mod github; pub mod keystore; +#[cfg(test)] +pub mod test_keys; diff --git a/crates/crates_io_trustpub/src/test_keys.rs b/crates/crates_io_trustpub/src/test_keys.rs new file mode 100644 index 00000000000..8f28cd17a1d --- /dev/null +++ b/crates/crates_io_trustpub/src/test_keys.rs @@ -0,0 +1,71 @@ +//! This module contains a set of RSA keys that must be used only for +//! testing purposes. The keys are not secure and must not be used +//! in production! + +use jsonwebtoken::errors::Error; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey}; +use serde::Serialize; +use std::sync::LazyLock; + +const PRIVATE_KEY_PEM: &[u8] = br#" +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCG7Za7JLYjNrWK +2dDl1Hg2lkBxBJSR4KfMVQ8PyN/hz6GGwTondSLTUVh9BzKfClnxcThqmv6awKZV +gv2ZV9FBUtOyromyZomdLRmYYzA8FLgXmXxqJqit8jQIAbpDGHz2qRdh4PITyEPl +Oib+hdhbQIOPK27xmPHXcQJ3gQoHoiCDkXbhDgztYM6BBKnSGaQPnW81p4pWEsFk +fhsmDKDm07EI/l96IQXbGlETna41+dtVmz83nL4DJ7jJUxuAlH8iH6w+4aMhjDmK +j7y4m394ceq0IWbptKZ7T/ewpKsbSzR14UuvBtLNeArSm5WqUmemxiWjeTo155Dp +c8tnMG6BAgMBAAECggEAf0tFCje/Ugd6TI3kRAAoja9BCp78n4eoJuEUfZrQhRRC +2oQPnkwnV+AFsKcKvfqhEmTzibfCfjNEeaZEJNgxxgQjTw7VP6b3K37yB8+EIRqW +90TJmMfyGXFIX0lp9YTz2C18rs3u9HTagTdUtImHrcd2lqquV2You02VuzLVSI7q +q4NvY1dHPRNo5g42EDhRVWjKPVq21EMGSsawSz/Y5jHoDIG4VRCqN1tuOwmKQw03 +6ldStckDshRcb6pfFsrsfC0YHqXM2SSwS15C2NlEMIzVITKXaHCf5ole08+F5kmU +ADav+hHONHogKf2zsb2rd9khqbRgEEZl5ArbtudgwQKBgQDfUJYeLRZaDxhnlhP7 +nfSWw0uYuXUu0LxsknHQMttC5YOKZRHl3RYHWSfNHMCe66geK5VykxSNOw2bVACm +hwJr4JZYgk3opLhvyvdRJ8NuoI4JTo24CoEF8EKHhJGPHMXQSre3JmK8ptPep9+P +/gTXT/U3Vlf9puZkoppq+/IEyQKBgQCarTptO3caJoCFlYnrCZ3X28LPv37tFjmE +AHRL5wxeFWhSZzemVul3v4vZvXgyc+VOBQoFvkQba7DncA1WVbl+zLgu8QZFotFf +VI3bZAK+02wqLXIo1CnAMB921Vn3UrHItToiCOJTHSEalxlTDEkSEMxx09sYGFh5 +REIcQXIP+QKBgHW9zYiXiSNuthVXoa2WuLEMwz0A+3H1iINOK0f0qHp6/IHpjChA +Cy9QqJWSxVSFN5zAqglA1yMnsaLmBXnH0VUDkwGTonQ49S2sO/3EE1yutnTdwAb7 +Ms/ov4soMH7eUsXhvz+Hs6N36lmI9Wy8J91GQSouEjKg3vTMbtJdiFtRAoGBAJYA +60mlwsKsljV2qXM0N0xwxoP8/ZXl2M+INUCrCJZxgmNv0EtTvEUykOkQU3HybW31 +exvIwnopPT2lsHmK10L+PJzhiCieVxhxgsVCP1ta5Goe+rhX0UmeIdV34TD2lI3G +G2OIZB0ggcsswBWHM5H+kpbNU4wRiDPKm6aVXY3ZAoGAVJPyITFs2foRiRG1S8o9 +gfz6rWleaXO2OmFh5P3UehhLwMr+vjvZn+8VByUubA9wqnY2JWu9ZSvdbdP6L6Z4 +usn0CLeCS1Gdbk4piqiSmUAe7nt2Sh258SVG5deDX6ej06NQzy249TtufxXjZ/3y +68y3i6u6aIE4wCiMYXl9B0o= +-----END PRIVATE KEY----- +"#; + +const PUBLIC_KEY_JWK: &str = r#" +{ + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "c0ffee", + "alg": "RS256", + "n": "hu2WuyS2Iza1itnQ5dR4NpZAcQSUkeCnzFUPD8jf4c-hhsE6J3Ui01FYfQcynwpZ8XE4apr-msCmVYL9mVfRQVLTsq6JsmaJnS0ZmGMwPBS4F5l8aiaorfI0CAG6Qxh89qkXYeDyE8hD5Tom_oXYW0CDjytu8Zjx13ECd4EKB6Igg5F24Q4M7WDOgQSp0hmkD51vNaeKVhLBZH4bJgyg5tOxCP5feiEF2xpRE52uNfnbVZs_N5y-Aye4yVMbgJR_Ih-sPuGjIYw5io-8uJt_eHHqtCFm6bSme0_3sKSrG0s0deFLrwbSzXgK0puVqlJnpsYlo3k6NeeQ6XPLZzBugQ" +} +"#; + +pub(crate) const KEY_ID: &str = "c0ffee"; + +static ENCODING_KEY: LazyLock = + LazyLock::new(|| EncodingKey::from_rsa_pem(PRIVATE_KEY_PEM).unwrap()); + +#[allow(unused)] +pub(crate) static DECODING_KEY: LazyLock = LazyLock::new(|| { + let jwk = serde_json::from_str(PUBLIC_KEY_JWK).unwrap(); + DecodingKey::from_jwk(&jwk).unwrap() +}); + +pub fn encode_for_testing(claims: &impl Serialize) -> Result { + let header = jsonwebtoken::Header { + alg: Algorithm::RS256, + kid: Some(KEY_ID.into()), + ..Default::default() + }; + + jsonwebtoken::encode(&header, claims, &ENCODING_KEY) +} From b5eb62bc423ae5b54ae578febaf070c554e8bdae Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 05:40:23 +0200 Subject: [PATCH 08/21] trustpub: Add `MockOidcKeyStore::with_test_key()` fn --- crates/crates_io_trustpub/Cargo.toml | 3 ++- crates/crates_io_trustpub/src/keystore/mod.rs | 18 ++++++++++++++++++ crates/crates_io_trustpub/src/lib.rs | 2 +- crates/crates_io_trustpub/src/test_keys.rs | 1 - 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index 7b3a3958a11..f84079de2f4 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -8,7 +8,7 @@ edition = "2024" workspace = true [features] -test-helpers = ["dep:mockall"] +test-helpers = ["dep:mockall", "dep:serde_json"] [dependencies] anyhow = "=1.0.98" @@ -18,6 +18,7 @@ mockall = { version = "=0.13.1", optional = true } reqwest = { version = "=0.12.15", features = ["gzip", "json"] } regex = "=1.11.1" serde = { version = "=1.0.219", features = ["derive"] } +serde_json = { version = "=1.0.140", optional = true } thiserror = "=2.0.12" tokio = { version = "=1.45.0", features = ["sync"] } diff --git a/crates/crates_io_trustpub/src/keystore/mod.rs b/crates/crates_io_trustpub/src/keystore/mod.rs index d7210b989a7..73cb3a8b3ae 100644 --- a/crates/crates_io_trustpub/src/keystore/mod.rs +++ b/crates/crates_io_trustpub/src/keystore/mod.rs @@ -18,3 +18,21 @@ pub trait OidcKeyStore: Send + Sync { /// is an error while fetching the key, it will return an error. async fn get_oidc_key(&self, key_id: &str) -> anyhow::Result>; } + +#[cfg(feature = "test-helpers")] +impl MockOidcKeyStore { + /// Creates a new instance of [`MockOidcKeyStore`] based on the RSA keys + /// provided in the [`crate::test_keys`] module. + pub fn with_test_key() -> Self { + use crate::test_keys::{DECODING_KEY, KEY_ID}; + use mockall::predicate::*; + + let mut mock = Self::new(); + + mock.expect_get_oidc_key() + .with(eq(KEY_ID)) + .returning(|_| Ok(Some(DECODING_KEY.clone()))); + + mock + } +} diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs index f9fa72326bb..fafe5449639 100644 --- a/crates/crates_io_trustpub/src/lib.rs +++ b/crates/crates_io_trustpub/src/lib.rs @@ -2,5 +2,5 @@ pub mod github; pub mod keystore; -#[cfg(test)] +#[cfg(any(test, feature = "test-helpers"))] pub mod test_keys; diff --git a/crates/crates_io_trustpub/src/test_keys.rs b/crates/crates_io_trustpub/src/test_keys.rs index 8f28cd17a1d..e84e9146022 100644 --- a/crates/crates_io_trustpub/src/test_keys.rs +++ b/crates/crates_io_trustpub/src/test_keys.rs @@ -54,7 +54,6 @@ pub(crate) const KEY_ID: &str = "c0ffee"; static ENCODING_KEY: LazyLock = LazyLock::new(|| EncodingKey::from_rsa_pem(PRIVATE_KEY_PEM).unwrap()); -#[allow(unused)] pub(crate) static DECODING_KEY: LazyLock = LazyLock::new(|| { let jwk = serde_json::from_str(PUBLIC_KEY_JWK).unwrap(); DecodingKey::from_jwk(&jwk).unwrap() From 83ce7933bb1298a0d443295550768fd361d6648c Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 16:10:03 +0200 Subject: [PATCH 09/21] trustpub: Implement `extract_workflow_filename()` fn --- crates/crates_io_trustpub/src/github/mod.rs | 1 + .../src/github/workflows.rs | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 crates/crates_io_trustpub/src/github/workflows.rs diff --git a/crates/crates_io_trustpub/src/github/mod.rs b/crates/crates_io_trustpub/src/github/mod.rs index 476c846d3b5..db153dabcff 100644 --- a/crates/crates_io_trustpub/src/github/mod.rs +++ b/crates/crates_io_trustpub/src/github/mod.rs @@ -1,3 +1,4 @@ pub mod validation; +mod workflows; pub const GITHUB_ISSUER_URL: &str = "https://token.actions.githubusercontent.com"; diff --git a/crates/crates_io_trustpub/src/github/workflows.rs b/crates/crates_io_trustpub/src/github/workflows.rs new file mode 100644 index 00000000000..727c3be691a --- /dev/null +++ b/crates/crates_io_trustpub/src/github/workflows.rs @@ -0,0 +1,110 @@ +use std::sync::LazyLock; + +/// Extracts the workflow filename from a GitHub workflow reference. +/// +/// In other words, it turns e.g. `rust-lang/regex/.github/workflows/ci.yml@refs/heads/main` +/// into `ci.yml`, or `None` if the reference is in an unexpected format. +#[allow(unused)] +pub(crate) fn extract_workflow_filename(workflow_ref: &str) -> Option<&str> { + static WORKFLOW_REF_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"([^/]+\.(yml|yaml))(@.+)").unwrap()); + + WORKFLOW_REF_RE + .captures(workflow_ref) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str()) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_extract_workflow_filename() { + let test_cases = [ + // Well-formed workflow refs, including exceedingly obnoxious ones + // with `@` or extra suffixes or `git` refs that look like workflows. + ( + "foo/bar/.github/workflows/basic.yml@refs/heads/main", + Some("basic.yml"), + ), + ( + "foo/bar/.github/workflows/basic.yaml@refs/heads/main", + Some("basic.yaml"), + ), + ( + "foo/bar/.github/workflows/has-dash.yml@refs/heads/main", + Some("has-dash.yml"), + ), + ( + "foo/bar/.github/workflows/has--dashes.yml@refs/heads/main", + Some("has--dashes.yml"), + ), + ( + "foo/bar/.github/workflows/has--dashes-.yml@refs/heads/main", + Some("has--dashes-.yml"), + ), + ( + "foo/bar/.github/workflows/has.period.yml@refs/heads/main", + Some("has.period.yml"), + ), + ( + "foo/bar/.github/workflows/has..periods.yml@refs/heads/main", + Some("has..periods.yml"), + ), + ( + "foo/bar/.github/workflows/has..periods..yml@refs/heads/main", + Some("has..periods..yml"), + ), + ( + "foo/bar/.github/workflows/has_underscore.yml@refs/heads/main", + Some("has_underscore.yml"), + ), + ( + "foo/bar/.github/workflows/nested@evil.yml@refs/heads/main", + Some("nested@evil.yml"), + ), + ( + "foo/bar/.github/workflows/nested.yml@evil.yml@refs/heads/main", + Some("nested.yml@evil.yml"), + ), + ( + "foo/bar/.github/workflows/extra@nested.yml@evil.yml@refs/heads/main", + Some("extra@nested.yml@evil.yml"), + ), + ( + "foo/bar/.github/workflows/extra.yml@nested.yml@evil.yml@refs/heads/main", + Some("extra.yml@nested.yml@evil.yml"), + ), + ( + "foo/bar/.github/workflows/basic.yml@refs/heads/misleading@branch.yml", + Some("basic.yml"), + ), + ( + "foo/bar/.github/workflows/basic.yml@refs/heads/bad@branch@twomatches.yml", + Some("basic.yml"), + ), + ( + "foo/bar/.github/workflows/foo.yml.yml@refs/heads/main", + Some("foo.yml.yml"), + ), + ( + "foo/bar/.github/workflows/foo.yml.foo.yml@refs/heads/main", + Some("foo.yml.foo.yml"), + ), + // Malformed workflow refs. + ( + "foo/bar/.github/workflows/basic.wrongsuffix@refs/heads/main", + None, + ), + ("foo/bar/.github/workflows/@refs/heads/main", None), + ("foo/bar/.github/workflows/nosuffix@refs/heads/main", None), + ("foo/bar/.github/workflows/.yml@refs/heads/main", None), + ("foo/bar/.github/workflows/.yaml@refs/heads/main", None), + ("foo/bar/.github/workflows/main.yml", None), + ]; + + for (input, expected) in test_cases { + let result = super::extract_workflow_filename(input); + assert_eq!(result, expected, "Input: {input}"); + } + } +} From 222b2b88042bbfd85425bb6967ebbf088918207e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 14:03:18 +0200 Subject: [PATCH 10/21] trustpub: Implement `UnverifiedClaims::decode()` fn This fn can be used to decode a JSON web token without verifying it's signature or claims. Only the `iss` claim will actually be decoded, since we use that to find the correct decoding key for the JWT issuer. --- crates/crates_io_trustpub/src/lib.rs | 1 + crates/crates_io_trustpub/src/unverified.rs | 93 +++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 crates/crates_io_trustpub/src/unverified.rs diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs index fafe5449639..67987eda7b9 100644 --- a/crates/crates_io_trustpub/src/lib.rs +++ b/crates/crates_io_trustpub/src/lib.rs @@ -4,3 +4,4 @@ pub mod github; pub mod keystore; #[cfg(any(test, feature = "test-helpers"))] pub mod test_keys; +pub mod unverified; diff --git a/crates/crates_io_trustpub/src/unverified.rs b/crates/crates_io_trustpub/src/unverified.rs new file mode 100644 index 00000000000..7bc761dd069 --- /dev/null +++ b/crates/crates_io_trustpub/src/unverified.rs @@ -0,0 +1,93 @@ +use jsonwebtoken::errors::Error; +use jsonwebtoken::{DecodingKey, TokenData, Validation}; +use serde::Deserialize; +use std::collections::HashSet; +use std::sync::LazyLock; + +/// [`Validation`] configuration for decoding JWTs without any +/// signature validation. +/// +/// **This must only be used to extract the `iss` claim from the JWT, which +/// is then used to look up the corresponding OIDC key set.** +static NO_VALIDATION: LazyLock = LazyLock::new(|| { + let mut no_validation = Validation::default(); + no_validation.validate_aud = false; + no_validation.validate_exp = false; + no_validation.required_spec_claims = HashSet::new(); + no_validation.insecure_disable_signature_validation(); + no_validation +}); + +/// Empty [`DecodingKey`] used for decoding JWTs without any signature +/// validation. +/// +/// **This must only be used to extract the `iss` claim from the JWT, which +/// is then used to look up the corresponding OIDC key set.** +static EMPTY_KEY: LazyLock = LazyLock::new(|| DecodingKey::from_secret(b"")); + +/// Claims that are extracted from the JWT without any signature +/// validation. Specifically, this only extracts the `iss` claim, which is +/// used to look up the corresponding OIDC key set to then verify the +/// JWT signature. +#[derive(Debug, Deserialize)] +pub struct UnverifiedClaims { + pub iss: String, +} + +impl UnverifiedClaims { + /// Decode the JWT and extract the `iss` claim without any + /// signature validation. + /// + /// **This must only be used to extract the `iss` claim from the JWT, which + /// is then used to look up the corresponding OIDC key set.** + pub fn decode(token: &str) -> Result, Error> { + jsonwebtoken::decode(token, &EMPTY_KEY, &NO_VALIDATION) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::{assert_err, assert_ok, assert_some_eq}; + use insta::assert_compact_debug_snapshot; + use jsonwebtoken::{EncodingKey, Header, encode}; + use serde::Serialize; + + #[derive(Debug, Serialize)] + struct TestClaims { + iss: String, + } + + #[test] + fn test_decode_valid_token() { + const KEY_ID: &str = "test-key-id"; + const ISSUER: &str = "https://example.com"; + + let header = Header { + kid: Some(KEY_ID.to_string()), + ..Default::default() + }; + + let iss = ISSUER.to_string(); + let claims = TestClaims { iss }; + + let key = EncodingKey::from_secret(b"test-secret"); + let token = assert_ok!(encode(&header, &claims, &key)); + + let decoded = assert_ok!(UnverifiedClaims::decode(&token)); + assert_some_eq!(decoded.header.kid, KEY_ID); + assert_eq!(decoded.claims.iss, ISSUER); + } + + #[test] + fn test_decode_invalid_token() { + let error = assert_err!(UnverifiedClaims::decode("")); + assert_compact_debug_snapshot!(error, @"Error(InvalidToken)"); + + let error = assert_err!(UnverifiedClaims::decode("invalid.token")); + assert_compact_debug_snapshot!(error, @"Error(InvalidToken)"); + + let error = assert_err!(UnverifiedClaims::decode("invalid.token.format")); + assert_compact_debug_snapshot!(error, @"Error(Base64(InvalidLastSymbol(6, 100)))"); + } +} From 8a2abad91869afd135e97283c318fecf542ec781 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 16:17:25 +0200 Subject: [PATCH 11/21] trustpub: Implement `GitHubClaims` struct --- Cargo.lock | 1 + crates/crates_io_trustpub/Cargo.toml | 3 +- .../crates_io_trustpub/src/github/claims.rs | 397 ++++++++++++++++++ crates/crates_io_trustpub/src/github/mod.rs | 3 + .../src/github/workflows.rs | 1 - 5 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 crates/crates_io_trustpub/src/github/claims.rs diff --git a/Cargo.lock b/Cargo.lock index cb4c58c4684..02cd98a7a7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1510,6 +1510,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "chrono", "claims", "insta", "jsonwebtoken", diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index f84079de2f4..908f114f1d0 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -13,6 +13,7 @@ test-helpers = ["dep:mockall", "dep:serde_json"] [dependencies] anyhow = "=1.0.98" async-trait = "=0.1.88" +chrono = { version = "=0.4.41", features = ["serde"] } jsonwebtoken = "=9.3.1" mockall = { version = "=0.13.1", optional = true } reqwest = { version = "=0.12.15", features = ["gzip", "json"] } @@ -24,7 +25,7 @@ tokio = { version = "=1.45.0", features = ["sync"] } [dev-dependencies] claims = "=0.8.0" -insta = "=1.43.1" +insta = { version = "=1.43.1", features = ["json", "redactions"] } mockito = "=1.7.0" serde_json = "=1.0.140" tokio = { version = "=1.45.0", features = ["macros", "rt-multi-thread"] } diff --git a/crates/crates_io_trustpub/src/github/claims.rs b/crates/crates_io_trustpub/src/github/claims.rs new file mode 100644 index 00000000000..2ae3e73b85d --- /dev/null +++ b/crates/crates_io_trustpub/src/github/claims.rs @@ -0,0 +1,397 @@ +use crate::github::GITHUB_ISSUER_URL; +use crate::github::workflows::extract_workflow_filename; +use chrono::serde::ts_seconds; +use chrono::{DateTime, Utc}; +use jsonwebtoken::errors::{Error, ErrorKind}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; + +/// Claims extracted from a GitHub Actions OIDC token. +/// +/// This struct is used to decode and validate the JWT token generated by +/// GitHub Actions. It contains the claims that are relevant for our "Trusted +/// Publishing" implementation. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct GitHubClaims { + pub aud: String, + #[serde(with = "ts_seconds")] + pub iat: DateTime, + #[serde(with = "ts_seconds")] + pub exp: DateTime, + pub jti: String, + + pub repository_owner_id: String, + pub repository: String, + pub job_workflow_ref: String, + pub environment: Option, +} + +impl GitHubClaims { + /// Decode and validate a JWT token, returning the relevant claims if valid. + pub fn decode(token: &str, audience: &str, key: &DecodingKey) -> Result { + let validation = validation(audience); + + let claims: Self = jsonwebtoken::decode(token, key, &validation)?.claims; + + let leeway = chrono::TimeDelta::seconds(validation.leeway as i64); + if claims.iat > Utc::now() + leeway { + return Err(ErrorKind::ImmatureSignature.into()); + } + + Ok(claims) + } + + /// Extract the workflow filename from the [`job_workflow_ref`](Self::job_workflow_ref) + /// field or return `None` if the filename cannot be extracted. + pub fn workflow_filename(&self) -> Option<&str> { + extract_workflow_filename(&self.job_workflow_ref) + } +} + +fn validation(audience: &str) -> Validation { + let mut validation = Validation::new(Algorithm::RS256); + validation.required_spec_claims.insert("iss".into()); + validation.required_spec_claims.insert("exp".into()); + validation.required_spec_claims.insert("aud".into()); + validation.validate_exp = true; + validation.validate_aud = true; + validation.validate_nbf = true; + validation.set_issuer(&[GITHUB_ISSUER_URL]); + validation.set_audience(&[audience]); + validation +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_keys::{DECODING_KEY, encode_for_testing}; + use insta::{assert_compact_debug_snapshot, assert_json_snapshot}; + use serde_json::json; + use std::time::SystemTime; + + const AUDIENCE: &str = "crates.io"; + + #[test] + fn test_decode() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "sub": "repo:octo-org/octo-repo:environment:prod", + "environment": "prod", + "aud": AUDIENCE, + "ref": "refs/heads/main", + "sha": "example-sha", + "repository": "octo-org/octo-repo", + "repository_owner": "octo-org", + "actor_id": "12", + "repository_visibility": "private", + "repository_id": "74", + "repository_owner_id": "65", + "run_id": "example-run-id", + "run_number": "10", + "run_attempt": "2", + "runner_environment": "github-hosted", + "actor": "octocat", + "workflow": "example-workflow", + "head_ref": "", + "base_ref": "", + "event_name": "workflow_dispatch", + "ref_type": "branch", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "nbf": now, + "exp": now + 30, + "iat": now, + }))?; + + let claims = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?; + assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#" + { + "aud": "crates.io", + "iat": "[datetime]", + "exp": "[datetime]", + "jti": "example-id", + "repository_owner_id": "65", + "repository": "octo-org/octo-repo", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "environment": "prod" + } + "#); + + Ok(()) + } + + #[test] + fn test_decode_minimal() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let claims = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY)?; + assert_json_snapshot!(claims, { ".iat" => "[datetime]", ".exp" => "[datetime]" }, @r#" + { + "aud": "crates.io", + "iat": "[datetime]", + "exp": "[datetime]", + "jti": "example-id", + "repository_owner_id": "65", + "repository": "octo-org/octo-repo", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "environment": null + } + "#); + + Ok(()) + } + + #[test] + fn test_decode_missing_jti() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `jti`", line: 1, column: 255)))"#); + + Ok(()) + } + + #[test] + fn test_decode_wrong_audience() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": "somebody-else", + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r"Error(InvalidAudience)"); + + Ok(()) + } + + #[test] + fn test_decode_multi_audience() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": [AUDIENCE, "somebody-else"], + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("invalid type: sequence, expected a string", line: 1, column: 7)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_repo() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `repository`", line: 1, column: 240)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_owner_id() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `repository_owner_id`", line: 1, column: 247)))"#); + + Ok(()) + } + #[test] + fn test_decode_missing_workflow() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `job_workflow_ref`", line: 1, column: 185)))"#); + + Ok(()) + } + + #[test] + fn test_decode_missing_issuer() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(MissingRequiredClaim("iss"))"#); + + Ok(()) + } + + #[test] + fn test_decode_wrong_issuer() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://gitlab.com", + "exp": now + 30, + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(InvalidIssuer)"); + + Ok(()) + } + + #[test] + fn test_decode_missing_exp() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "iat": now, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `exp`", line: 1, column: 257)))"#); + + Ok(()) + } + + #[test] + fn test_decode_expired() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now - 3000, + "iat": now - 6000, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(ExpiredSignature)"); + + Ok(()) + } + + #[test] + fn test_decode_missing_iat() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 30, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @r#"Error(Json(Error("missing field `iat`", line: 1, column: 257)))"#); + + Ok(()) + } + + #[test] + fn test_decode_future_iat() -> anyhow::Result<()> { + let now = SystemTime::UNIX_EPOCH.elapsed()?.as_secs(); + + let jwt = encode_for_testing(&json!({ + "jti": "example-id", + "aud": AUDIENCE, + "repository": "octo-org/octo-repo", + "repository_owner_id": "65", + "job_workflow_ref": "octo-org/octo-automation/.github/workflows/oidc.yml@refs/heads/main", + "iss": "https://token.actions.githubusercontent.com", + "exp": now + 300, + "iat": now + 100, + }))?; + + let error = GitHubClaims::decode(&jwt, AUDIENCE, &DECODING_KEY).unwrap_err(); + assert_compact_debug_snapshot!(error, @"Error(ImmatureSignature)"); + + Ok(()) + } +} diff --git a/crates/crates_io_trustpub/src/github/mod.rs b/crates/crates_io_trustpub/src/github/mod.rs index db153dabcff..db438b001d8 100644 --- a/crates/crates_io_trustpub/src/github/mod.rs +++ b/crates/crates_io_trustpub/src/github/mod.rs @@ -1,4 +1,7 @@ +mod claims; pub mod validation; mod workflows; +pub use claims::GitHubClaims; + pub const GITHUB_ISSUER_URL: &str = "https://token.actions.githubusercontent.com"; diff --git a/crates/crates_io_trustpub/src/github/workflows.rs b/crates/crates_io_trustpub/src/github/workflows.rs index 727c3be691a..7b918a583f3 100644 --- a/crates/crates_io_trustpub/src/github/workflows.rs +++ b/crates/crates_io_trustpub/src/github/workflows.rs @@ -4,7 +4,6 @@ use std::sync::LazyLock; /// /// In other words, it turns e.g. `rust-lang/regex/.github/workflows/ci.yml@refs/heads/main` /// into `ci.yml`, or `None` if the reference is in an unexpected format. -#[allow(unused)] pub(crate) fn extract_workflow_filename(workflow_ref: &str) -> Option<&str> { static WORKFLOW_REF_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"([^/]+\.(yml|yaml))(@.+)").unwrap()); From 35d61c05213beba918559c9224e9250271cbc0f9 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 06:04:35 +0200 Subject: [PATCH 12/21] trustpub: Implement `FullGitHubClaims` struct for testing purposes --- Cargo.lock | 1 + crates/crates_io_trustpub/Cargo.toml | 4 +- crates/crates_io_trustpub/src/github/mod.rs | 2 + ...b__test_helpers__tests__github_claims.snap | 32 +++++ .../src/github/test_helpers.rs | 128 ++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 crates/crates_io_trustpub/src/github/snapshots/crates_io_trustpub__github__test_helpers__tests__github_claims.snap create mode 100644 crates/crates_io_trustpub/src/github/test_helpers.rs diff --git a/Cargo.lock b/Cargo.lock index 02cd98a7a7f..5330b8e5b2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1510,6 +1510,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "bon", "chrono", "claims", "insta", diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index 908f114f1d0..97070cd8713 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -8,11 +8,12 @@ edition = "2024" workspace = true [features] -test-helpers = ["dep:mockall", "dep:serde_json"] +test-helpers = ["dep:bon", "dep:mockall", "dep:serde_json"] [dependencies] anyhow = "=1.0.98" async-trait = "=0.1.88" +bon = { version = "=3.6.3", optional = true } chrono = { version = "=0.4.41", features = ["serde"] } jsonwebtoken = "=9.3.1" mockall = { version = "=0.13.1", optional = true } @@ -24,6 +25,7 @@ thiserror = "=2.0.12" tokio = { version = "=1.45.0", features = ["sync"] } [dev-dependencies] +bon = "=3.6.3" claims = "=0.8.0" insta = { version = "=1.43.1", features = ["json", "redactions"] } mockito = "=1.7.0" diff --git a/crates/crates_io_trustpub/src/github/mod.rs b/crates/crates_io_trustpub/src/github/mod.rs index db438b001d8..b39d021a8ed 100644 --- a/crates/crates_io_trustpub/src/github/mod.rs +++ b/crates/crates_io_trustpub/src/github/mod.rs @@ -1,4 +1,6 @@ mod claims; +#[cfg(any(test, feature = "test-helpers"))] +pub mod test_helpers; pub mod validation; mod workflows; diff --git a/crates/crates_io_trustpub/src/github/snapshots/crates_io_trustpub__github__test_helpers__tests__github_claims.snap b/crates/crates_io_trustpub/src/github/snapshots/crates_io_trustpub__github__test_helpers__tests__github_claims.snap new file mode 100644 index 00000000000..ac5a28d40a9 --- /dev/null +++ b/crates/crates_io_trustpub/src/github/snapshots/crates_io_trustpub__github__test_helpers__tests__github_claims.snap @@ -0,0 +1,32 @@ +--- +source: crates/crates_io_trustpub/src/github/test_helpers.rs +expression: claims +--- +{ + "iss": "https://token.actions.githubusercontent.com", + "nbf": "[timestamp]", + "exp": "[timestamp]", + "iat": "[timestamp]", + "jti": "example-id", + "sub": "repo:octocat/hello-world", + "aud": "crates.io", + "ref": "refs/heads/main", + "sha": "example-sha", + "repository": "octocat/hello-world", + "repository_owner": "octocat", + "actor_id": "12", + "repository_visibility": "private", + "repository_id": "74", + "repository_owner_id": "123", + "run_id": "example-run-id", + "run_number": "10", + "run_attempt": "2", + "runner_environment": "github-hosted", + "actor": "octocat", + "workflow": "example-workflow", + "head_ref": "", + "base_ref": "", + "event_name": "workflow_dispatch", + "ref_type": "branch", + "job_workflow_ref": "octocat/hello-world/.github/workflows/ci.yml@refs/heads/main" +} diff --git a/crates/crates_io_trustpub/src/github/test_helpers.rs b/crates/crates_io_trustpub/src/github/test_helpers.rs new file mode 100644 index 00000000000..d4a5c1ef112 --- /dev/null +++ b/crates/crates_io_trustpub/src/github/test_helpers.rs @@ -0,0 +1,128 @@ +use crate::github::GITHUB_ISSUER_URL; +use crate::test_keys::encode_for_testing; +use bon::bon; +use serde_json::json; + +pub const AUDIENCE: &str = "crates.io"; + +/// A struct representing all the claims in a GitHub Actions OIDC token. +/// +/// This struct is used to create a JWT for testing purposes. +#[derive(Debug, serde::Serialize)] +pub struct FullGitHubClaims { + pub iss: String, + pub nbf: i64, + pub exp: i64, + pub iat: i64, + pub jti: String, + pub sub: String, + pub aud: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(rename = "ref")] + pub r#ref: String, + pub sha: String, + pub repository: String, + pub repository_owner: String, + pub actor_id: String, + pub repository_visibility: String, + pub repository_id: String, + pub repository_owner_id: String, + pub run_id: String, + pub run_number: String, + pub run_attempt: String, + pub runner_environment: String, + pub actor: String, + pub workflow: String, + pub head_ref: String, + pub base_ref: String, + pub event_name: String, + pub ref_type: String, + pub job_workflow_ref: String, +} + +#[bon] +impl FullGitHubClaims { + #[builder] + pub fn new( + owner_id: i32, + owner_name: &str, + repository_name: &str, + workflow_filename: &str, + environment: Option<&str>, + ) -> Self { + let now = chrono::Utc::now().timestamp(); + + Self { + iss: GITHUB_ISSUER_URL.into(), + nbf: now, + iat: now, + exp: now + 30 * 60, + jti: "example-id".into(), + sub: format!("repo:{owner_name}/{repository_name}"), + aud: AUDIENCE.into(), + + environment: environment.map(|s| s.into()), + r#ref: "refs/heads/main".into(), + sha: "example-sha".into(), + repository: format!("{owner_name}/{repository_name}"), + repository_owner: owner_name.into(), + actor_id: "12".into(), + repository_visibility: "private".into(), + repository_id: "74".into(), + repository_owner_id: owner_id.to_string(), + run_id: "example-run-id".into(), + run_number: "10".into(), + run_attempt: "2".into(), + runner_environment: "github-hosted".into(), + actor: "octocat".into(), + workflow: "example-workflow".into(), + head_ref: "".into(), + base_ref: "".into(), + event_name: "workflow_dispatch".into(), + ref_type: "branch".into(), + job_workflow_ref: format!( + "{owner_name}/{repository_name}/.github/workflows/{workflow_filename}@refs/heads/main" + ), + } + } + + pub fn encoded(&self) -> anyhow::Result { + Ok(encode_for_testing(self)?) + } + + pub fn as_exchange_body(&self) -> anyhow::Result { + let jwt = self.encoded()?; + Ok(serde_json::to_string(&json!({ "jwt": jwt }))?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::assert_ok; + use insta::assert_json_snapshot; + + #[test] + fn test_github_claims() { + let claims = FullGitHubClaims::builder() + .owner_id(123) + .owner_name("octocat") + .repository_name("hello-world") + .workflow_filename("ci.yml") + .build(); + + assert_json_snapshot!(claims, { + ".nbf" => "[timestamp]", + ".iat" => "[timestamp]", + ".exp" => "[timestamp]", + }); + + let encoded = assert_ok!(claims.encoded()); + assert!(!encoded.is_empty()); + + let exchange_body = assert_ok!(claims.as_exchange_body()); + assert!(exchange_body.contains(&encoded)); + } +} From 8cf78d8420643fa27ff2a969c2db751a5acf4880 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 06:40:05 +0200 Subject: [PATCH 13/21] trustpub: Implement `AccessToken` struct --- Cargo.lock | 3 + crates/crates_io_trustpub/Cargo.toml | 3 + crates/crates_io_trustpub/src/access_token.rs | 110 ++++++++++++++++++ crates/crates_io_trustpub/src/lib.rs | 1 + 4 files changed, 117 insertions(+) create mode 100644 crates/crates_io_trustpub/src/access_token.rs diff --git a/Cargo.lock b/Cargo.lock index 5330b8e5b2e..22c3145157c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1517,10 +1517,13 @@ dependencies = [ "jsonwebtoken", "mockall", "mockito", + "rand 0.9.1", "regex", "reqwest", + "secrecy", "serde", "serde_json", + "sha2", "thiserror 2.0.12", "tokio", ] diff --git a/crates/crates_io_trustpub/Cargo.toml b/crates/crates_io_trustpub/Cargo.toml index 97070cd8713..0515d756a99 100644 --- a/crates/crates_io_trustpub/Cargo.toml +++ b/crates/crates_io_trustpub/Cargo.toml @@ -17,10 +17,13 @@ bon = { version = "=3.6.3", optional = true } chrono = { version = "=0.4.41", features = ["serde"] } jsonwebtoken = "=9.3.1" mockall = { version = "=0.13.1", optional = true } +rand = "=0.9.1" reqwest = { version = "=0.12.15", features = ["gzip", "json"] } regex = "=1.11.1" +secrecy = "=0.10.3" serde = { version = "=1.0.219", features = ["derive"] } serde_json = { version = "=1.0.140", optional = true } +sha2 = "=0.10.9" thiserror = "=2.0.12" tokio = { version = "=1.45.0", features = ["sync"] } diff --git a/crates/crates_io_trustpub/src/access_token.rs b/crates/crates_io_trustpub/src/access_token.rs new file mode 100644 index 00000000000..b31fe4f39f2 --- /dev/null +++ b/crates/crates_io_trustpub/src/access_token.rs @@ -0,0 +1,110 @@ +use secrecy::{ExposeSecret, SecretString}; +use sha2::digest::Output; +use sha2::{Digest, Sha256}; + +/// A temporary access token used to publish crates to crates.io using +/// the "Trusted Publishing" feature. +#[derive(Debug)] +pub struct AccessToken(SecretString); + +impl AccessToken { + const PREFIX: &str = "cio_tp_"; + + /// Generate a new access token. + pub fn generate() -> Self { + Self::from_u64s(rand::random(), rand::random()) + } + + /// Create an access token from two u64 values. + /// + /// This is used internally by the `generate()` fn and is extracted + /// to a separate function for testing purposes. + fn from_u64s(r1: u64, r2: u64) -> Self { + let plaintext = format!("{}{r1:016x}{r2:016x}", Self::PREFIX); + Self(SecretString::from(plaintext)) + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + let str = String::from_utf8(bytes.into()).ok()?; + + let suffix = str.strip_prefix(Self::PREFIX)?; + if suffix.len() != 32 { + return None; + } + + let is_hexdigit = |c| matches!(c, 'a'..='f') || c.is_ascii_digit(); + if !suffix.chars().all(is_hexdigit) { + return None; + } + + Some(Self(SecretString::from(str))) + } + + /// Generate a SHA256 hash of the access token. + pub fn sha256(&self) -> Output { + Sha256::digest(self.0.expose_secret()) + } +} + +impl ExposeSecret for AccessToken { + fn expose_secret(&self) -> &str { + self.0.expose_secret() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use claims::{assert_none, assert_some}; + use insta::assert_snapshot; + + #[test] + fn test_generate() { + let token = AccessToken::generate(); + let token_str = token.expose_secret(); + assert!(token_str.starts_with(AccessToken::PREFIX)); + assert_eq!(token_str.len(), AccessToken::PREFIX.len() + 32); + } + + #[test] + fn test_serialization() { + let token = AccessToken::from_u64s(0, 0); + assert_snapshot!(token.expose_secret(), @"cio_tp_00000000000000000000000000000000"); + + let token = AccessToken::from_u64s(u64::MAX, u64::MAX); + assert_snapshot!(token.expose_secret(), @"cio_tp_ffffffffffffffffffffffffffffffff"); + + let token = AccessToken::from_u64s(0xc0ffee, 0xfa8072); + assert_snapshot!(token.expose_secret(), @"cio_tp_0000000000c0ffee0000000000fa8072"); + } + + #[test] + fn test_sha256() { + let token = AccessToken::generate(); + let hash = token.sha256(); + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_from_bytes() { + let token = AccessToken::generate(); + let bytes = token.expose_secret().as_bytes(); + let token2 = assert_some!(AccessToken::from_bytes(bytes)); + assert_eq!(token.expose_secret(), token2.expose_secret()); + + let bytes = b"cio_tp_00000000000000000000000000000000"; + assert_some!(AccessToken::from_bytes(bytes)); + + let invalid_bytes = b"invalid_token"; + assert_none!(AccessToken::from_bytes(invalid_bytes)); + + let invalid_bytes = b"cio_tp_invalid_token"; + assert_none!(AccessToken::from_bytes(invalid_bytes)); + + let invalid_bytes = b"cio_tp_00000000000000000000000000"; + assert_none!(AccessToken::from_bytes(invalid_bytes)); + + let invalid_bytes = b"cio_tp_000000x0000000000000000000000000"; + assert_none!(AccessToken::from_bytes(invalid_bytes)); + } +} diff --git a/crates/crates_io_trustpub/src/lib.rs b/crates/crates_io_trustpub/src/lib.rs index 67987eda7b9..c882f06b23a 100644 --- a/crates/crates_io_trustpub/src/lib.rs +++ b/crates/crates_io_trustpub/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +pub mod access_token; pub mod github; pub mod keystore; #[cfg(any(test, feature = "test-helpers"))] From 692834943b02e4f1cf7f9e93a91843a950a6237d Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 28 Apr 2025 12:30:58 +0200 Subject: [PATCH 14/21] database: Add `NewUsedJti` data access object --- .../src/models/trustpub/mod.rs | 2 ++ .../src/models/trustpub/used_jti.rs | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 crates/crates_io_database/src/models/trustpub/used_jti.rs diff --git a/crates/crates_io_database/src/models/trustpub/mod.rs b/crates/crates_io_database/src/models/trustpub/mod.rs index 634f500f46d..fb8bc09b789 100644 --- a/crates/crates_io_database/src/models/trustpub/mod.rs +++ b/crates/crates_io_database/src/models/trustpub/mod.rs @@ -1,3 +1,5 @@ mod github_config; +mod used_jti; pub use self::github_config::{GitHubConfig, NewGitHubConfig}; +pub use self::used_jti::NewUsedJti; diff --git a/crates/crates_io_database/src/models/trustpub/used_jti.rs b/crates/crates_io_database/src/models/trustpub/used_jti.rs new file mode 100644 index 00000000000..eced690bf0b --- /dev/null +++ b/crates/crates_io_database/src/models/trustpub/used_jti.rs @@ -0,0 +1,24 @@ +use crate::schema::trustpub_used_jtis; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; + +#[derive(Debug, Insertable)] +#[diesel(table_name = trustpub_used_jtis, check_for_backend(diesel::pg::Pg))] +pub struct NewUsedJti<'a> { + pub jti: &'a str, + pub expires_at: DateTime, +} + +impl<'a> NewUsedJti<'a> { + pub fn new(jti: &'a str, expires_at: DateTime) -> Self { + Self { jti, expires_at } + } + + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult { + diesel::insert_into(trustpub_used_jtis::table) + .values(self) + .execute(conn) + .await + } +} From 83be81ea8b93b62ff10200a1ec2d9914333e648b Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Mon, 28 Apr 2025 12:30:24 +0200 Subject: [PATCH 15/21] database: Add `NewToken` data access object --- .../src/models/trustpub/mod.rs | 2 ++ .../src/models/trustpub/token.rs | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 crates/crates_io_database/src/models/trustpub/token.rs diff --git a/crates/crates_io_database/src/models/trustpub/mod.rs b/crates/crates_io_database/src/models/trustpub/mod.rs index fb8bc09b789..6a2ad6357b4 100644 --- a/crates/crates_io_database/src/models/trustpub/mod.rs +++ b/crates/crates_io_database/src/models/trustpub/mod.rs @@ -1,5 +1,7 @@ mod github_config; +mod token; mod used_jti; pub use self::github_config::{GitHubConfig, NewGitHubConfig}; +pub use self::token::NewToken; pub use self::used_jti::NewUsedJti; diff --git a/crates/crates_io_database/src/models/trustpub/token.rs b/crates/crates_io_database/src/models/trustpub/token.rs new file mode 100644 index 00000000000..80e6fcf5c84 --- /dev/null +++ b/crates/crates_io_database/src/models/trustpub/token.rs @@ -0,0 +1,22 @@ +use crate::schema::trustpub_tokens; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel_async::{AsyncPgConnection, RunQueryDsl}; + +#[derive(Debug, Insertable)] +#[diesel(table_name = trustpub_tokens, check_for_backend(diesel::pg::Pg))] +pub struct NewToken<'a> { + pub expires_at: DateTime, + pub hashed_token: &'a [u8], + pub crate_ids: &'a [i32], +} + +impl NewToken<'_> { + pub async fn insert(&self, conn: &mut AsyncPgConnection) -> QueryResult<()> { + self.insert_into(trustpub_tokens::table) + .execute(conn) + .await?; + + Ok(()) + } +} From e3a2835d875878406f358f8005768c9df3c4b12c Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 08:13:12 +0200 Subject: [PATCH 16/21] config: Add `TRUSTPUB_AUDIENCE` setting This defaults to the domain name (crates.io / staging.crates.io) and controls the expected `aud` claim of the OIDC JWT in the Trusted Publishing token exchange. --- src/config/server.rs | 10 +++++++++- src/tests/util/test_app.rs | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/config/server.rs b/src/config/server.rs index 9fc9a326203..c7a15a90d96 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -87,6 +87,10 @@ pub struct Server { pub html_render_cache_max_capacity: u64, pub content_security_policy: Option, + + /// The expected audience claim (`aud`) for the Trusted Publishing + /// token exchange. + pub trustpub_audience: String, } impl Server { @@ -186,6 +190,9 @@ impl Server { .unwrap_or_default() ); + let domain_name = dotenvy::var("DOMAIN_NAME").unwrap_or_else(|_| "crates.io".into()); + let trustpub_audience = var("TRUSTPUB_AUDIENCE")?.unwrap_or_else(|| domain_name.clone()); + Ok(Server { db: DatabasePools::full_from_environment(&base)?, storage, @@ -210,7 +217,7 @@ impl Server { page_offset_ua_blocklist, page_offset_cidr_blocklist, excluded_crate_names, - domain_name: dotenvy::var("DOMAIN_NAME").unwrap_or_else(|_| "crates.io".into()), + domain_name, allowed_origins, downloads_persist_interval: var_parsed("DOWNLOADS_PERSIST_INTERVAL_MS")? .map(Duration::from_millis) @@ -233,6 +240,7 @@ impl Server { og_image_base_url: var_parsed("OG_IMAGE_BASE_URL")?, html_render_cache_max_capacity: var_parsed("HTML_RENDER_CACHE_CAP")?.unwrap_or(1024), content_security_policy: Some(content_security_policy.parse()?), + trustpub_audience, }) } } diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index 4811bd82513..34addeb8d27 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -16,6 +16,7 @@ use crates_io_index::testing::UpstreamIndex; use crates_io_index::{Credentials, RepositoryConfig}; use crates_io_team_repo::MockTeamRepo; use crates_io_test_db::TestDatabase; +use crates_io_trustpub::github::test_helpers::AUDIENCE; use crates_io_worker::Runner; use diesel_async::AsyncPgConnection; use futures_util::TryStreamExt; @@ -477,6 +478,7 @@ fn simple_config() -> config::Server { og_image_base_url: None, html_render_cache_max_capacity: 1024, content_security_policy: None, + trustpub_audience: AUDIENCE.to_string(), } } From b2ace7b6ead5f02fded54419c039e704d5a41659 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 15:43:54 +0200 Subject: [PATCH 17/21] App: Add `oidc_key_stores` hashmap --- src/app.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app.rs b/src/app.rs index 079d433b8d3..8f9757f8863 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,7 @@ use crate::storage::{Storage, StorageConfig}; use axum::extract::{FromRef, FromRequestParts, State}; use bon::Builder; use crates_io_github::GitHubClient; +use crates_io_trustpub::keystore::OidcKeyStore; use deadpool_diesel::Runtime; use derive_more::Deref; use diesel_async::AsyncPgConnection; @@ -42,6 +43,13 @@ pub struct App { pub github_oauth: BasicClient, + /// OIDC key stores for "Trusted Publishing" + /// + /// This is a map of OIDC key stores, where the key is the issuer URL and + /// the value is the OIDC key store instance. + #[builder(default)] + pub oidc_key_stores: HashMap>, + /// The server configuration pub config: Arc, From d203be6daeb643cb219231d8e9f04c52ffd85ef0 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 07:35:54 +0200 Subject: [PATCH 18/21] AppBuilder: Add `trustpub_providers()` fn --- src/app.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 8f9757f8863..1b96e17fca4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,8 @@ use crate::storage::{Storage, StorageConfig}; use axum::extract::{FromRef, FromRequestParts, State}; use bon::Builder; use crates_io_github::GitHubClient; -use crates_io_trustpub::keystore::OidcKeyStore; +use crates_io_trustpub::github::GITHUB_ISSUER_URL; +use crates_io_trustpub::keystore::{OidcKeyStore, RealOidcKeyStore}; use deadpool_diesel::Runtime; use derive_more::Deref; use diesel_async::AsyncPgConnection; @@ -94,6 +95,36 @@ impl AppBuilder { self.github_oauth(github_oauth) } + /// Set the "Trusted Publishing" providers supported by the application. + /// + /// This method configures the OIDC key stores for the specified providers + /// and expects a list of provider names as input. + /// + /// Currently, only "github" is supported as a provider. + pub fn trustpub_providers( + self, + providers: &[String], + ) -> AppBuilder> + where + S::OidcKeyStores: app_builder::IsUnset, + { + let mut key_stores: HashMap> = HashMap::new(); + + for provider in providers { + match provider.as_str() { + "github" => { + let key_store = RealOidcKeyStore::new(GITHUB_ISSUER_URL.into()); + key_stores.insert(GITHUB_ISSUER_URL.into(), Box::new(key_store)); + } + provider => { + warn!("Unknown Trusted Publishing provider: {provider}"); + } + } + } + + self.oidc_key_stores(key_stores) + } + pub fn databases_from_config( self, config: &config::DatabasePools, From 3cd0e7b47bf800d44261aa4ee8cf66bffa1194a7 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 8 May 2025 07:36:20 +0200 Subject: [PATCH 19/21] bin/server: Use `TRUSTPUB_PROVIDERS` env var to configure Trusted Publishing providers --- src/bin/server.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bin/server.rs b/src/bin/server.rs index 3303158c7e1..34c0bf11023 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -6,6 +6,7 @@ use crates_io::{App, Emails, metrics::LogEncoder}; use std::{sync::Arc, time::Duration}; use axum::ServiceExt; +use crates_io_env_vars::list; use crates_io_github::RealGitHubClient; use prometheus::Encoder; use reqwest::Client; @@ -37,6 +38,7 @@ fn main() -> anyhow::Result<()> { .databases_from_config(&config.db) .github(github) .github_oauth_from_config(&config) + .trustpub_providers(&list("TRUSTPUB_PROVIDERS")?) .emails(emails) .storage_from_config(&config.storage) .rate_limiter_from_config(config.rate_limiter.clone()) From a60534f33bcaebf49b0eff57a38cb4df7fd28dd6 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Wed, 7 May 2025 15:45:20 +0200 Subject: [PATCH 20/21] tests/TestAppBuilder: Add `with_oidc_keystore()` fn --- src/tests/util/test_app.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index 34addeb8d27..ff3dc5a413d 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -17,12 +17,13 @@ use crates_io_index::{Credentials, RepositoryConfig}; use crates_io_team_repo::MockTeamRepo; use crates_io_test_db::TestDatabase; use crates_io_trustpub::github::test_helpers::AUDIENCE; +use crates_io_trustpub::keystore::{MockOidcKeyStore, OidcKeyStore}; use crates_io_worker::Runner; use diesel_async::AsyncPgConnection; use futures_util::TryStreamExt; use oauth2::{ClientId, ClientSecret}; use regex::Regex; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; use std::{rc::Rc, sync::Arc, time::Duration}; use tokio::runtime::Handle; @@ -102,6 +103,7 @@ impl TestApp { use_chaos_proxy: false, team_repo: MockTeamRepo::new(), github: None, + oidc_key_stores: Default::default(), } } @@ -243,6 +245,7 @@ pub struct TestAppBuilder { use_chaos_proxy: bool, team_repo: MockTeamRepo, github: Option, + oidc_key_stores: HashMap>, } impl TestAppBuilder { @@ -281,7 +284,7 @@ impl TestAppBuilder { (primary_proxy, replica_proxy) }; - let (app, router) = build_app(self.config, self.github); + let (app, router) = build_app(self.config, self.github, self.oidc_key_stores); let runner = if self.build_job_runner { let index = self @@ -389,6 +392,17 @@ impl TestAppBuilder { self } + /// Add a new OIDC keystore to the application + pub fn with_oidc_keystore( + mut self, + issuer_url: impl Into, + keystore: MockOidcKeyStore, + ) -> Self { + self.oidc_key_stores + .insert(issuer_url.into(), Box::new(keystore)); + self + } + pub fn with_team_repo(mut self, team_repo: MockTeamRepo) -> Self { self.team_repo = team_repo; self @@ -482,7 +496,11 @@ fn simple_config() -> config::Server { } } -fn build_app(config: config::Server, github: Option) -> (Arc, axum::Router) { +fn build_app( + config: config::Server, + github: Option, + oidc_key_stores: HashMap>, +) -> (Arc, axum::Router) { // Use the in-memory email backend for all tests, allowing tests to analyze the emails sent by // the application. This will also prevent cluttering the filesystem. let emails = Emails::new_in_memory(); @@ -494,6 +512,7 @@ fn build_app(config: config::Server, github: Option) -> (Arc Date: Thu, 8 May 2025 07:07:26 +0200 Subject: [PATCH 21/21] Implement `PUT /api/v1/trusted_publishing/tokens` API endpoint --- Cargo.lock | 1 + Cargo.toml | 2 + src/controllers/trustpub/mod.rs | 1 + .../trustpub/tokens/exchange/mod.rs | 146 +++++++ .../trustpub/tokens/exchange/tests.rs | 377 ++++++++++++++++++ src/controllers/trustpub/tokens/json.rs | 13 + src/controllers/trustpub/tokens/mod.rs | 2 + src/router.rs | 1 + ..._io__openapi__tests__openapi_snapshot.snap | 47 +++ 9 files changed, 590 insertions(+) create mode 100644 src/controllers/trustpub/tokens/exchange/mod.rs create mode 100644 src/controllers/trustpub/tokens/exchange/tests.rs create mode 100644 src/controllers/trustpub/tokens/json.rs create mode 100644 src/controllers/trustpub/tokens/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 22c3145157c..a3c8f967a26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,6 +1230,7 @@ dependencies = [ "insta", "ipnetwork", "json-subscriber", + "jsonwebtoken", "lettre", "minijinja", "mockall", diff --git a/Cargo.toml b/Cargo.toml index 92be673c6ea..14a2d3ab30a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,10 +143,12 @@ crates_io_index = { path = "crates/crates_io_index", features = ["testing"] } crates_io_tarball = { path = "crates/crates_io_tarball", features = ["builder"] } crates_io_team_repo = { path = "crates/crates_io_team_repo", features = ["mock"] } crates_io_test_db = { path = "crates/crates_io_test_db" } +crates_io_trustpub = { path = "crates/crates_io_trustpub", features = ["test-helpers"] } claims = "=0.8.0" diesel = { version = "=2.2.10", features = ["r2d2"] } googletest = "=0.14.0" insta = { version = "=1.43.1", features = ["glob", "json", "redactions"] } +jsonwebtoken = "=9.3.1" regex = "=1.11.1" sentry = { version = "=0.37.0", features = ["test"] } tokio = "=1.45.0" diff --git a/src/controllers/trustpub/mod.rs b/src/controllers/trustpub/mod.rs index 6903a27236a..fe0f81d16f2 100644 --- a/src/controllers/trustpub/mod.rs +++ b/src/controllers/trustpub/mod.rs @@ -1 +1,2 @@ pub mod github_configs; +pub mod tokens; diff --git a/src/controllers/trustpub/tokens/exchange/mod.rs b/src/controllers/trustpub/tokens/exchange/mod.rs new file mode 100644 index 00000000000..cea0ed61ba6 --- /dev/null +++ b/src/controllers/trustpub/tokens/exchange/mod.rs @@ -0,0 +1,146 @@ +use super::json; +use crate::app::AppState; +use crate::util::errors::{AppResult, bad_request, server_error}; +use axum::Json; +use crates_io_database::models::trustpub::{NewToken, NewUsedJti}; +use crates_io_database::schema::trustpub_configs_github; +use crates_io_diesel_helpers::lower; +use crates_io_trustpub::access_token::AccessToken; +use crates_io_trustpub::github::{GITHUB_ISSUER_URL, GitHubClaims}; +use crates_io_trustpub::unverified::UnverifiedClaims; +use diesel::prelude::*; +use diesel::result::DatabaseErrorKind::UniqueViolation; +use diesel::result::Error::DatabaseError; +use diesel_async::scoped_futures::ScopedFutureExt; +use diesel_async::{AsyncConnection, RunQueryDsl}; +use secrecy::ExposeSecret; + +#[cfg(test)] +mod tests; + +/// Exchange an OIDC token for a temporary access token. +#[utoipa::path( + put, + path = "/api/v1/trusted_publishing/tokens", + request_body = inline(json::ExchangeRequest), + tag = "trusted_publishing", + responses((status = 200, description = "Successful Response", body = inline(json::ExchangeResponse))), +)] +pub async fn exchange_trustpub_token( + state: AppState, + json: json::ExchangeRequest, +) -> AppResult> { + let unverified_jwt = json.jwt; + + let unverified_token_data = UnverifiedClaims::decode(&unverified_jwt) + .map_err(|_err| bad_request("Failed to decode JWT"))?; + + let unverified_issuer = unverified_token_data.claims.iss; + let Some(keystore) = state.oidc_key_stores.get(&unverified_issuer) else { + return Err(bad_request("Unsupported JWT issuer")); + }; + + let Some(unverified_key_id) = unverified_token_data.header.kid else { + let message = "Missing JWT key ID"; + return Err(bad_request(message)); + }; + + let key = match keystore.get_oidc_key(&unverified_key_id).await { + Ok(Some(key)) => key, + Ok(None) => { + return Err(bad_request("Invalid JWT key ID")); + } + Err(err) => { + warn!("Failed to load OIDC key set: {err}"); + return Err(server_error("Failed to load OIDC key set")); + } + }; + + // The following code is only supporting GitHub Actions for now, so let's + // drop out if the issuer is not GitHub. + if unverified_issuer != GITHUB_ISSUER_URL { + return Err(bad_request("Unsupported JWT issuer")); + } + + let audience = &state.config.trustpub_audience; + let signed_claims = GitHubClaims::decode(&unverified_jwt, audience, &key).map_err(|err| { + warn!("Failed to decode JWT: {err}"); + bad_request("Failed to decode JWT") + })?; + + let mut conn = state.db_write().await?; + + conn.transaction(|conn| { + async move { + let used_jti = NewUsedJti::new(&signed_claims.jti, signed_claims.exp); + match used_jti.insert(conn).await { + Ok(_) => {} // JTI was successfully inserted, continue + Err(DatabaseError(UniqueViolation, _)) => { + warn!("Attempted JWT reuse (jti: {})", signed_claims.jti); + let detail = "JWT has already been used"; + return Err(bad_request(detail)); + } + Err(err) => Err(err)?, + }; + + let repo = &signed_claims.repository; + let Some((repository_owner, repository_name)) = repo.split_once('/') else { + warn!("Unexpected repository format in JWT: {repo}"); + let message = "Unexpected `repository` value"; + return Err(bad_request(message)); + }; + + let Some(workflow_filename) = signed_claims.workflow_filename() else { + let job_workflow_ref = &signed_claims.job_workflow_ref; + warn!("Unexpected `job_workflow_ref` format in JWT: {job_workflow_ref}"); + let message = "Unexpected `job_workflow_ref` value"; + return Err(bad_request(message)); + }; + + let Ok(repository_owner_id) = signed_claims.repository_owner_id.parse::() else { + let repository_owner_id = &signed_claims.repository_owner_id; + warn!("Unexpected `repository_owner_id` format in JWT: {repository_owner_id}"); + let message = "Unexpected `repository_owner_id` value"; + return Err(bad_request(message)); + }; + + let crate_ids = trustpub_configs_github::table + .select(trustpub_configs_github::crate_id) + .filter(trustpub_configs_github::repository_owner_id.eq(&repository_owner_id)) + .filter( + lower(trustpub_configs_github::repository_owner).eq(lower(&repository_owner)), + ) + .filter(lower(trustpub_configs_github::repository_name).eq(lower(&repository_name))) + .filter(trustpub_configs_github::workflow_filename.eq(&workflow_filename)) + .filter( + trustpub_configs_github::environment + .is_null() + .or(lower(trustpub_configs_github::environment) + .eq(lower(&signed_claims.environment))), + ) + .load::(conn) + .await?; + + if crate_ids.is_empty() { + warn!("No matching Trusted Publishing config found"); + let message = "No matching Trusted Publishing config found"; + return Err(bad_request(message)); + } + + let new_token = AccessToken::generate(); + + let new_token_model = NewToken { + expires_at: chrono::Utc::now() + chrono::Duration::minutes(30), + hashed_token: &new_token.sha256(), + crate_ids: &crate_ids, + }; + + new_token_model.insert(conn).await?; + + let token = new_token.expose_secret().into(); + Ok(Json(json::ExchangeResponse { token })) + } + .scope_boxed() + }) + .await +} diff --git a/src/controllers/trustpub/tokens/exchange/tests.rs b/src/controllers/trustpub/tokens/exchange/tests.rs new file mode 100644 index 00000000000..ea544219c14 --- /dev/null +++ b/src/controllers/trustpub/tokens/exchange/tests.rs @@ -0,0 +1,377 @@ +use crate::tests::builders::CrateBuilder; +use crate::tests::util::{MockAnonymousUser, RequestHelper, TestApp}; +use crates_io_database::models::trustpub::NewGitHubConfig; +use crates_io_database::schema::trustpub_tokens; +use crates_io_trustpub::access_token::AccessToken; +use crates_io_trustpub::github::GITHUB_ISSUER_URL; +use crates_io_trustpub::github::test_helpers::FullGitHubClaims; +use crates_io_trustpub::keystore::MockOidcKeyStore; +use diesel::prelude::*; +use diesel_async::RunQueryDsl; +use http::StatusCode; +use insta::{assert_compact_debug_snapshot, assert_json_snapshot, assert_snapshot}; +use jsonwebtoken::{EncodingKey, Header}; +use mockall::predicate::*; +use serde_json::json; + +const URL: &str = "/api/v1/trusted_publishing/tokens"; + +const CRATE_NAME: &str = "foo"; +const OWNER_NAME: &str = "rust-lang"; +const OWNER_ID: i32 = 42; +const REPOSITORY_NAME: &str = "foo-rs"; +const WORKFLOW_FILENAME: &str = "publish.yml"; + +async fn prepare() -> anyhow::Result { + prepare_with_config(|_config| {}).await +} + +async fn prepare_with_config( + adjust_config: fn(&mut NewGitHubConfig<'static>), +) -> anyhow::Result { + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_ISSUER_URL, MockOidcKeyStore::with_test_key()) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new(CRATE_NAME, owner_id) + .build(&mut conn) + .await?; + + let mut new_oidc_config = new_oidc_config(krate.id); + adjust_config(&mut new_oidc_config); + new_oidc_config.insert(&mut conn).await?; + + Ok(client) +} + +fn new_oidc_config(crate_id: i32) -> NewGitHubConfig<'static> { + NewGitHubConfig { + crate_id, + repository_owner: OWNER_NAME, + repository_owner_id: OWNER_ID, + repository_name: REPOSITORY_NAME, + workflow_filename: WORKFLOW_FILENAME, + environment: None, + } +} + +fn default_claims() -> FullGitHubClaims { + FullGitHubClaims::builder() + .owner_id(OWNER_ID) + .owner_name(OWNER_NAME) + .repository_name(REPOSITORY_NAME) + .workflow_filename(WORKFLOW_FILENAME) + .build() +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path() -> anyhow::Result<()> { + let client = prepare().await?; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::OK); + + let json = response.json(); + assert_json_snapshot!(json, { ".token" => "[token]" }, @r#" + { + "token": "[token]" + } + "#); + + let token = json["token"].as_str().unwrap(); + let token = assert_some!(AccessToken::from_bytes(token.as_bytes())); + let hashed_token = token.sha256(); + + let mut conn = client.app().db_conn().await; + + let tokens = trustpub_tokens::table + .filter(trustpub_tokens::hashed_token.eq(hashed_token.as_slice())) + .select((trustpub_tokens::id, trustpub_tokens::crate_ids)) + .get_results::<(i64, Vec>)>(&mut conn) + .await?; + + assert_eq!(tokens.len(), 1); + assert_compact_debug_snapshot!(tokens, @"[(1, [Some(1)])]"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_environment() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("prod")).await?; + + let mut claims = default_claims(); + claims.environment = Some("prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::OK); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_happy_path_with_ignored_environment() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.environment = Some("prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::OK); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_broken_jwt() -> anyhow::Result<()> { + let client = prepare().await?; + + let body = serde_json::to_vec(&json!({ "jwt": "broken" }))?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Failed to decode JWT"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unsupported_issuer() -> anyhow::Result<()> { + let (app, client, cookie) = TestApp::full().with_user().await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new(CRATE_NAME, owner_id) + .build(&mut conn) + .await?; + + new_oidc_config(krate.id).insert(&mut conn).await?; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unsupported JWT issuer"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_key_id() -> anyhow::Result<()> { + let client = prepare().await?; + + let claims = default_claims(); + let secret_key = EncodingKey::from_secret(b"secret"); + let jwt = jsonwebtoken::encode(&Header::default(), &claims, &secret_key)?; + let body = serde_json::to_vec(&json!({ "jwt": jwt }))?; + + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Missing JWT key ID"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_unknown_key() -> anyhow::Result<()> { + let mut mock_key_store = MockOidcKeyStore::default(); + + mock_key_store + .expect_get_oidc_key() + .with(always()) + .returning(|_| Ok(None)); + + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_ISSUER_URL, mock_key_store) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Invalid JWT key ID"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_key_store_error() -> anyhow::Result<()> { + let mut mock_key_store = MockOidcKeyStore::default(); + + mock_key_store + .expect_get_oidc_key() + .with(always()) + .returning(|_| Err(anyhow::anyhow!("Failed to load OIDC key set"))); + + let (app, client, cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_ISSUER_URL, mock_key_store) + .with_user() + .await; + + let mut conn = app.db_conn().await; + + let owner_id = cookie.as_model().id; + let krate = CrateBuilder::new("foo", owner_id).build(&mut conn).await?; + new_oidc_config(krate.id).insert(&mut conn).await?; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Failed to load OIDC key set"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_audience() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.aud = "invalid-audience".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Failed to decode JWT"}]}"#); + + Ok(()) +} + +/// Test that OIDC tokens can only be exchanged once +#[tokio::test(flavor = "multi_thread")] +async fn test_token_reuse() -> anyhow::Result<()> { + let client = prepare().await?; + + let body = default_claims().as_exchange_body()?; + + // The first exchange should succeed + let response = client.put::<()>(URL, body.clone()).await; + assert_eq!(response.status(), StatusCode::OK); + + // The second exchange should fail + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"JWT has already been used"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_repository() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.repository = "what?".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unexpected `repository` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_workflow() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.job_workflow_ref = "what?".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unexpected `job_workflow_ref` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_owner_id() -> anyhow::Result<()> { + let client = prepare().await?; + + let mut claims = default_claims(); + claims.repository_owner_id = "what?".into(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"Unexpected `repository_owner_id` value"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_config() -> anyhow::Result<()> { + let (_app, client, _cookie) = TestApp::full() + .with_oidc_keystore(GITHUB_ISSUER_URL, MockOidcKeyStore::with_test_key()) + .with_user() + .await; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_missing_environment() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("prod")).await?; + + let body = default_claims().as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_wrong_environment() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("prod")).await?; + + let mut claims = default_claims(); + claims.environment = Some("not-prod".into()); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_snapshot!(response.json(), @r#"{"errors":[{"detail":"No matching Trusted Publishing config found"}]}"#); + + Ok(()) +} + +/// Check that the owner name, repository name, and environment are accepted in +/// a case-insensitive manner. +#[tokio::test(flavor = "multi_thread")] +async fn test_case_insensitive() -> anyhow::Result<()> { + let client = prepare_with_config(|c| c.environment = Some("Prod")).await?; + + let claims = FullGitHubClaims::builder() + .owner_id(OWNER_ID) + .owner_name("RUST-lanG") + .repository_name("foo-RS") + .workflow_filename(WORKFLOW_FILENAME) + .environment("PROD") + .build(); + + let body = claims.as_exchange_body()?; + let response = client.put::<()>(URL, body).await; + assert_eq!(response.status(), StatusCode::OK); + + Ok(()) +} diff --git a/src/controllers/trustpub/tokens/json.rs b/src/controllers/trustpub/tokens/json.rs new file mode 100644 index 00000000000..c9fefbef0c3 --- /dev/null +++ b/src/controllers/trustpub/tokens/json.rs @@ -0,0 +1,13 @@ +use axum::Json; +use axum::extract::FromRequest; + +#[derive(Debug, Deserialize, FromRequest, utoipa::ToSchema)] +#[from_request(via(Json))] +pub struct ExchangeRequest { + pub jwt: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ExchangeResponse { + pub token: String, +} diff --git a/src/controllers/trustpub/tokens/mod.rs b/src/controllers/trustpub/tokens/mod.rs new file mode 100644 index 00000000000..4f561da4247 --- /dev/null +++ b/src/controllers/trustpub/tokens/mod.rs @@ -0,0 +1,2 @@ +pub mod exchange; +pub mod json; diff --git a/src/router.rs b/src/router.rs index b589892cf96..dbf78e16533 100644 --- a/src/router.rs +++ b/src/router.rs @@ -88,6 +88,7 @@ pub fn build_axum_router(state: AppState) -> Router<()> { .routes(routes!(session::authorize_session)) .routes(routes!(session::end_session)) // OIDC / Trusted Publishing + .routes(routes!(trustpub::tokens::exchange::exchange_trustpub_token)) .routes(routes!( trustpub::github_configs::create::create_trustpub_github_config, )) diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index 2f1b4601c27..4eb2ce94487 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -4184,6 +4184,53 @@ expression: response.json() ] } }, + "/api/v1/trusted_publishing/tokens": { + "put": { + "operationId": "exchange_trustpub_token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "jwt": { + "type": "string" + } + }, + "required": [ + "jwt" + ], + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Exchange an OIDC token for a temporary access token.", + "tags": [ + "trusted_publishing" + ] + } + }, "/api/v1/users/{id}/resend": { "put": { "operationId": "resend_email_verification",