diff --git a/examples/bundle/main.rs b/examples/bundle/main.rs index 6506d51637..612db59ad9 100644 --- a/examples/bundle/main.rs +++ b/examples/bundle/main.rs @@ -85,8 +85,8 @@ fn sign(artifact_path: &PathBuf) { }); let token = authorize(); - let email = &token.unverified_claims().email.clone(); - debug!("Signing with {}", email); + let identity = token.identity_claim.to_string(); + debug!("Signing with {}", identity); let signing_artifact = SigningContext::production().and_then(|ctx| { ctx.blocking_signer(token) @@ -105,7 +105,7 @@ fn sign(artifact_path: &PathBuf) { println!( "Created signature bundle {} with identity {}", bundle_path.display(), - email + identity ); } diff --git a/src/bundle/sign.rs b/src/bundle/sign.rs index 0c6e4c82af..51d4ef2331 100644 --- a/src/bundle/sign.rs +++ b/src/bundle/sign.rs @@ -44,7 +44,7 @@ use crate::crypto::transparency::{verify_sct, CertificateEmbeddedSCT}; use crate::errors::{Result as SigstoreResult, SigstoreError}; use crate::fulcio::oauth::OauthTokenProvider; use crate::fulcio::{self, FulcioClient, FULCIO_ROOT}; -use crate::oauth::IdentityToken; +use crate::oauth::{IdentityClaim, IdentityToken}; use crate::rekor::apis::configuration::Configuration as RekorConfiguration; use crate::rekor::apis::entries_api::create_log_entry; use crate::rekor::models::{hashedrekord, proposed_entry::ProposedEntry as ProposedLogEntry}; @@ -85,6 +85,12 @@ impl<'ctx> SigningSession<'ctx> { fulcio: &FulcioClient, token: &IdentityToken, ) -> SigstoreResult<(ecdsa::SigningKey, fulcio::CertificateResponse)> { + // NOTE: Currently both email and machine identities get wrapped in a "email" OID. + // Fulcio does not care about the content. + let identity = match &token.identity_claim { + IdentityClaim::Sub(identity) | IdentityClaim::Email(identity) => identity.as_str(), + }; + let subject = // SEQUENCE OF RelativeDistinguishedName vec![ @@ -95,7 +101,7 @@ impl<'ctx> SigningSession<'ctx> { oid: const_oid::db::rfc3280::EMAIL_ADDRESS, value: AttributeValue::new( pkcs8::der::Tag::Utf8String, - token.unverified_claims().email.as_ref(), + identity.as_ref(), )?, } ].try_into()? diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index b78bd78d6c..a5998155bf 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -16,4 +16,5 @@ pub mod openidflow; pub mod token; +pub use token::IdentityClaim; pub use token::IdentityToken; diff --git a/src/oauth/token.rs b/src/oauth/token.rs index e916a30003..cc837ddadd 100644 --- a/src/oauth/token.rs +++ b/src/oauth/token.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::fmt; + use chrono::{DateTime, Utc}; use openidconnect::core::CoreIdToken; use serde::Deserialize; @@ -28,15 +30,41 @@ pub struct Claims { #[serde(with = "chrono::serde::ts_seconds_option")] #[serde(default)] pub nbf: Option>, - pub email: String, + pub email: Option, + pub iss: String, + pub sub: Option, } pub type UnverifiedClaims = Claims; -/// A Sigstore token. +// The identity that should be compatible with Fulcio: Depending on the issuer it is +// either a "sub" or "email" claim. +#[derive(Debug, PartialEq)] +pub enum IdentityClaim { + Sub(String), + Email(String), +} + +impl fmt::Display for IdentityClaim { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + IdentityClaim::Sub(sub) => sub.fmt(f), + IdentityClaim::Email(email) => email.fmt(f), + } + } +} + +/// A Sigstore (Fulcio) authentication token +/// +/// An IdentityToken is built from a OIDC JWT received from an OIDC provider and is used to +/// authenticate a signing identity to Fulcio to get a signing certificate for that identity. +/// +/// The content of the token, including identity and issuer claims, come unverified from +/// the JWT: IdentityToken only makes some validity checks. pub struct IdentityToken { original_token: String, claims: UnverifiedClaims, + pub identity_claim: IdentityClaim, } impl IdentityToken { @@ -82,9 +110,35 @@ impl TryFrom<&str> for IdentityToken { )); } + // Find the identity claim that we believe Fulcio used for this token. + // This means a few special cases and fall back on "sub" claim + let identity = match claims.iss.as_str() { + "https://accounts.google.com" + | "https://oauth2.sigstore.dev/auth" + | "https://oauth2.sigstage.dev/auth" => { + if let Some(email) = claims.email.as_ref() { + IdentityClaim::Email(email.clone()) + } else { + return Err(SigstoreError::IdentityTokenError( + "Email claim not found in JWT".into(), + )); + } + } + _ => { + if let Some(sub) = claims.sub.as_ref() { + IdentityClaim::Sub(sub.clone()) + } else { + return Err(SigstoreError::IdentityTokenError( + "Sub claim not found in JWT".into(), + )); + } + } + }; + Ok(IdentityToken { original_token: value.to_owned(), claims, + identity_claim: identity, }) } } @@ -104,3 +158,56 @@ impl std::fmt::Display for IdentityToken { write!(f, "{}", self.original_token.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn interactive_token() { + let content = fs::read_to_string("tests/data/tokens/interactive-token.txt").unwrap(); + let identity_token = IdentityToken::try_from(content.as_str()).unwrap(); + assert_eq!( + identity_token.claims.email, + Some(String::from("jku@goto.fi")) + ); + assert_eq!( + identity_token.identity_claim, + IdentityClaim::Email(String::from("jku@goto.fi")) + ); + assert_eq!(identity_token.claims.aud, "sigstore"); + assert_eq!( + identity_token.claims.iss, + "https://oauth2.sigstore.dev/auth" + ); + assert_eq!( + identity_token.claims.exp, + DateTime::parse_from_rfc3339("2024-10-21T12:15:30Z").unwrap() + ); + } + + #[test] + fn github_actions_token() { + let content = fs::read_to_string("tests/data/tokens/gha-token.txt").unwrap(); + let identity_token = IdentityToken::try_from(content.as_str()).unwrap(); + assert_eq!(identity_token.claims.email, None); + assert_eq!( + identity_token.claims.sub, + Some(String::from("repo:sigstore-conformance/extremely-dangerous-public-oidc-beacon:ref:refs/heads/main")) + ); + assert_eq!( + identity_token.identity_claim, + IdentityClaim::Sub(String::from("repo:sigstore-conformance/extremely-dangerous-public-oidc-beacon:ref:refs/heads/main")) + ); + assert_eq!(identity_token.claims.aud, "sigstore"); + assert_eq!( + identity_token.claims.iss, + "https://token.actions.githubusercontent.com" + ); + assert_eq!( + identity_token.claims.exp, + DateTime::parse_from_rfc3339("2024-10-21T07:29:49Z").unwrap() + ); + } +} diff --git a/tests/data/tokens/gha-token.txt b/tests/data/tokens/gha-token.txt new file mode 100644 index 0000000000..cc7dd10fea --- /dev/null +++ b/tests/data/tokens/gha-token.txt @@ -0,0 +1 @@ +eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ikh5cTROQVRBanNucUM3bWRydEFoaHJDUjJfUSIsImtpZCI6IjFGMkFCODM0MDRDMDhFQzlFQTBCQjk5REFFRDAyMTg2QjA5MURCRjQifQ.eyJqdGkiOiI4NTI3ZGQyMy0zYmNjLTQzYjgtYTMyNy1kYjFkM2Q4ZTY4NGUiLCJzdWIiOiJyZXBvOnNpZ3N0b3JlLWNvbmZvcm1hbmNlL2V4dHJlbWVseS1kYW5nZXJvdXMtcHVibGljLW9pZGMtYmVhY29uOnJlZjpyZWZzL2hlYWRzL21haW4iLCJhdWQiOiJzaWdzdG9yZSIsInJlZiI6InJlZnMvaGVhZHMvbWFpbiIsInNoYSI6IjZjZjNkMWQ3MTNjZDM0MTA4ODA3NTRmNWQxZDAyNTE4MTM2OTMzNTciLCJyZXBvc2l0b3J5Ijoic2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24iLCJyZXBvc2l0b3J5X293bmVyIjoic2lnc3RvcmUtY29uZm9ybWFuY2UiLCJyZXBvc2l0b3J5X293bmVyX2lkIjoiMTMxODA0NTYzIiwicnVuX2lkIjoiMTE0MzUyMjEzNDQiLCJydW5fbnVtYmVyIjoiMTY5NDA3IiwicnVuX2F0dGVtcHQiOiIxIiwicmVwb3NpdG9yeV92aXNpYmlsaXR5IjoicHVibGljIiwicmVwb3NpdG9yeV9pZCI6IjYzMjU5Njg5NyIsImFjdG9yX2lkIjoiNDE4OTgyODIiLCJhY3RvciI6ImdpdGh1Yi1hY3Rpb25zW2JvdF0iLCJ3b3JrZmxvdyI6IkV4dHJlbWVseSBkYW5nZXJvdXMgT0lEQyBiZWFjb24iLCJoZWFkX3JlZiI6IiIsImJhc2VfcmVmIjoiIiwiZXZlbnRfbmFtZSI6IndvcmtmbG93X2Rpc3BhdGNoIiwicmVmX3Byb3RlY3RlZCI6InRydWUiLCJyZWZfdHlwZSI6ImJyYW5jaCIsIndvcmtmbG93X3JlZiI6InNpZ3N0b3JlLWNvbmZvcm1hbmNlL2V4dHJlbWVseS1kYW5nZXJvdXMtcHVibGljLW9pZGMtYmVhY29uLy5naXRodWIvd29ya2Zsb3dzL2V4dHJlbWVseS1kYW5nZXJvdXMtb2lkYy1iZWFjb24ueW1sQHJlZnMvaGVhZHMvbWFpbiIsIndvcmtmbG93X3NoYSI6IjZjZjNkMWQ3MTNjZDM0MTA4ODA3NTRmNWQxZDAyNTE4MTM2OTMzNTciLCJqb2Jfd29ya2Zsb3dfcmVmIjoic2lnc3RvcmUtY29uZm9ybWFuY2UvZXh0cmVtZWx5LWRhbmdlcm91cy1wdWJsaWMtb2lkYy1iZWFjb24vLmdpdGh1Yi93b3JrZmxvd3MvZXh0cmVtZWx5LWRhbmdlcm91cy1vaWRjLWJlYWNvbi55bWxAcmVmcy9oZWFkcy9tYWluIiwiam9iX3dvcmtmbG93X3NoYSI6IjZjZjNkMWQ3MTNjZDM0MTA4ODA3NTRmNWQxZDAyNTE4MTM2OTMzNTciLCJydW5uZXJfZW52aXJvbm1lbnQiOiJnaXRodWItaG9zdGVkIiwiaXNzIjoiaHR0cHM6Ly90b2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbSIsIm5iZiI6MTcyOTQ5NDg4OSwiZXhwIjoxNzI5NDk1Nzg5LCJpYXQiOjE3Mjk0OTU0ODl9.qMQkx6qIODt4fFvEpOFQNT7Gw-GVeoUtPNLwl-RGpJaO2EdCys4o3iPrX1-h8yvEtKpv4DFrtgNfzwTJCb9ueEWqW1Ll5oijYyd2VR7ghAYEeGV-sdwdkNQ1HO09UcRZcht00dgYayeVhbY4967dV5fNWuGib0c9BwJ2K1stzber3HgvGjjhPXoeKYdXvEE0L0MMq30b_eu1XW6ojvfeBTzujgHNxK8_drKAK1R9ENpBFBgreBeJvA1zu3hrvkby2g_sktRoHH2daOsdp4UhZjnr8IWJVMTAVHyueNJSu-UFKd5--TLKUdt_CpVW_PI_uv_xrKgJ9gU7n63w1qdbIQ diff --git a/tests/data/tokens/interactive-token.txt b/tests/data/tokens/interactive-token.txt new file mode 100644 index 0000000000..5c8a9340f3 --- /dev/null +++ b/tests/data/tokens/interactive-token.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6IjMxNjA2OGMzM2ZhMjg2OTZhZmI5YzM5YWI2OTMxMjY1ZDk0Y2I3NTUifQ.eyJpc3MiOiJodHRwczovL29hdXRoMi5zaWdzdG9yZS5kZXYvYXV0aCIsInN1YiI6IkNnVXpNVGc0T1JJbWFIUjBjSE02SlRKR0pUSkdaMmwwYUhWaUxtTnZiU1V5Um14dloybHVKVEpHYjJGMWRHZyIsImF1ZCI6InNpZ3N0b3JlIiwiZXhwIjoxNzI5NTEyOTMwLCJpYXQiOjE3Mjk1MTI4NzAsIm5vbmNlIjoiNTI3NjM3Y2UtN2Q2MS00MDA5LThkM2EtNGNjZGM3OGJiZDg1IiwiYXRfaGFzaCI6IktmMUNPTXB5TVJDTkdzWWp1QXczclEiLCJlbWFpbCI6ImprdUBnb3RvLmZpIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZlZGVyYXRlZF9jbGFpbXMiOnsiY29ubmVjdG9yX2lkIjoiaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoIiwidXNlcl9pZCI6IjMxODg5In19.s27uZ3vpIzRS4eWdC3pM0FSsYkHNvScQoii_TcSRVZhtrcPAbA4D95Pw_R_UB-qRquMK1BHepKmeN1b1-CQ00jiFZgUOf9sDLC3Hy3oQejGJsYKb-7oeHs7amLz3SBzPwDwVd09e-7Yu1x9YV5k6aezqruLLt42C_kyOTsHeCIWWMEVmGp32105Jkj8YT5uEYXS-aOEvQFvAYsDfKgGuiJtGybUycVcJEfqyWI3cami7fkjU5PcCx8oFyP2E7YNRw4UeNWCTn7WFtL2onrgDm0oa2AqF3gtH4Q-9ByksVq3y6xQdoLj1ydzWcoCzsF43oZ6O6DkLmWk5fu3FxNyewg