| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | | use rsa::pkcs1v15::{SigningKey, VerifyingKey}; |
| 10 | | use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey}; |
| 11 | | use rsa::signature::{SignatureEncoding, Signer, Verifier}; |
| 12 | | use rsa::traits::PublicKeyParts; |
| 13 | | use rsa::RsaPrivateKey; |
| 14 | | use sha2::Sha256; |
| 15 | | use std::path::Path; |
| 16 | |
|
| 17 | | use crate::api::ApiError; |
| 18 | | use crate::Shared; |
| 19 | | use axum::extract::State; |
| 20 | | use axum::http::StatusCode; |
| 21 | | use axum::response::{IntoResponse, Response}; |
| 22 | | use axum::Json; |
| 23 | |
|
| 24 | |
|
| 25 | | pub const MAX_TTL_S: u64 = 6 * 3600; |
| 26 | |
|
| 27 | | pub struct Issuer { |
| 28 | | signing: SigningKey<Sha256>, |
| 29 | | verifying: VerifyingKey<Sha256>, |
| 30 | | |
| 31 | | |
| 32 | | pub kid: String, |
| 33 | | n: String, |
| 34 | | e: String, |
| 35 | | } |
| 36 | |
|
| 37 | |
|
| 38 | | pub struct JobClaims<'a> { |
| 39 | | pub issuer: &'a str, |
| 40 | | pub audience: &'a str, |
| 41 | | pub owner: &'a str, |
| 42 | | pub name: &'a str, |
| 43 | | pub visibility: &'a str, |
| 44 | | pub bookmark: &'a str, |
| 45 | | pub run_number: i64, |
| 46 | | pub job: &'a str, |
| 47 | | pub state: &'a str, |
| 48 | | pub runner: &'a str, |
| 49 | | pub actor: &'a str, |
| 50 | | |
| 51 | | |
| 52 | | pub environment: Option<&'a str>, |
| 53 | | |
| 54 | | pub approved_by: Option<&'a str>, |
| 55 | | pub approval: Option<&'a str>, |
| 56 | | pub now_ms: i64, |
| 57 | | pub ttl_s: u64, |
| 58 | | } |
| 59 | |
|
| 60 | | impl Issuer { |
| 61 | | |
| 62 | | |
| 63 | 12× | pub fn load_or_create(keys: &Path) -> anyhow::Result<Issuer> { |
| 64 | 12× | std::fs::create_dir_all(keys)?; |
| 65 | 12× | let path = keys.join("oidc.pem"); |
| 66 | 12× | let key = match std::fs::read_to_string(&path) { |
| 67 | 1× | Ok(pem) => RsaPrivateKey::from_pkcs8_pem(&pem).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?, |
| 68 | | Err(_) => { |
| 69 | 11× | let key = RsaPrivateKey::new(&mut rand::rngs::OsRng, 2048)?; |
| 70 | 11× | let pem = key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)?; |
| 71 | 11× | std::fs::write(&path, pem.as_bytes())?; |
| 72 | | #[cfg(unix)] |
| 73 | | { |
| 74 | | use std::os::unix::fs::PermissionsExt; |
| 75 | 11× | std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; |
| 76 | | } |
| 77 | 11× | key |
| 78 | | } |
| 79 | | }; |
| 80 | 12× | Ok(Issuer::from_key(key)) |
| 81 | 12× | } |
| 82 | |
|
| 83 | 12× | fn from_key(key: RsaPrivateKey) -> Issuer { |
| 84 | 12× | let public = key.to_public_key(); |
| 85 | 12× | let n_bytes = public.n().to_bytes_be(); |
| 86 | 12× | let kid = blake3::hash(&n_bytes).to_hex()[..16].to_string(); |
| 87 | 12× | Issuer { |
| 88 | 12× | n: b64url(&n_bytes), |
| 89 | 12× | e: b64url(&public.e().to_bytes_be()), |
| 90 | 12× | verifying: VerifyingKey::new(public), |
| 91 | 12× | signing: SigningKey::new(key), |
| 92 | 12× | kid, |
| 93 | 12× | } |
| 94 | 12× | } |
| 95 | |
|
| 96 | | |
| 97 | 1× | pub fn discovery(&self, issuer: &str) -> serde_json::Value { |
| 98 | 1× | serde_json::json!({ |
| 99 | 1× | "issuer": issuer, |
| 100 | 1× | "jwks_uri": format!("{issuer}/.well-known/jwks.json"), |
| 101 | 1× | "response_types_supported": ["id_token"], |
| 102 | 1× | "subject_types_supported": ["public"], |
| 103 | 1× | "id_token_signing_alg_values_supported": ["RS256"], |
| 104 | 1× | "claims_supported": ["iss", "sub", "aud", "exp", "iat", "nbf", "jti", "repository", "repository_owner", |
| 105 | | "repository_visibility", "bookmark", "run_number", "job", "state", "runner", "actor", "environment", "approved_by", "approval"], |
| 106 | 1× | "scopes_supported": ["openid"], |
| 107 | | }) |
| 108 | 1× | } |
| 109 | |
|
| 110 | | |
| 111 | 2× | pub fn jwks(&self) -> serde_json::Value { |
| 112 | 2× | serde_json::json!({ "keys": [{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": self.kid, "n": self.n, "e": self.e }] }) |
| 113 | 2× | } |
| 114 | |
|
| 115 | | |
| 116 | | |
| 117 | 5× | pub fn mint(&self, c: &JobClaims<'_>) -> String { |
| 118 | 5× | let iat = c.now_ms / 1000; |
| 119 | 5× | let exp = iat + c.ttl_s.clamp(60, MAX_TTL_S) as i64; |
| 120 | 5× | let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid }); |
| 121 | 5× | let mut payload = serde_json::json!({ |
| 122 | 5× | "iss": c.issuer, |
| 123 | 5× | "sub": format!("repo:{}/{}:bookmark:{}", c.owner, c.name, c.bookmark), |
| 124 | 5× | "aud": c.audience, |
| 125 | 5× | "iat": iat, |
| 126 | 5× | "nbf": iat - 30, |
| 127 | 5× | "exp": exp, |
| 128 | 5× | "jti": crate::auth::random_hex(16), |
| 129 | 5× | "repository": format!("{}/{}", c.owner, c.name), |
| 130 | 5× | "repository_owner": c.owner, |
| 131 | 5× | "repository_visibility": c.visibility, |
| 132 | 5× | "bookmark": c.bookmark, |
| 133 | 5× | "run_number": c.run_number, |
| 134 | 5× | "job": c.job, |
| 135 | 5× | "state": c.state, |
| 136 | 5× | "runner": c.runner, |
| 137 | 5× | "actor": c.actor, |
| 138 | | }); |
| 139 | 5× | if let Some(e) = c.environment { |
| 140 | 3× | payload["environment"] = serde_json::Value::from(e); |
| 141 | 3× | } |
| 142 | 5× | if let Some(a) = c.approved_by { |
| 143 | 3× | payload["approved_by"] = serde_json::Value::from(a); |
| 144 | 3× | } |
| 145 | 5× | if let Some(a) = c.approval { |
| 146 | 2× | payload["approval"] = serde_json::Value::from(a); |
| 147 | 3× | } |
| 148 | 5× | let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes())); |
| 149 | 5× | let sig = self.signing.sign(signing_input.as_bytes()); |
| 150 | 5× | format!("{signing_input}.{}", b64url(&sig.to_bytes())) |
| 151 | 5× | } |
| 152 | |
|
| 153 | | |
| 154 | | |
| 155 | 15× | pub fn mint_for(&self, issuer: &str, subject: &str, audience: &str, now_ms: i64, ttl_s: u64) -> String { |
| 156 | 15× | let iat = now_ms / 1000; |
| 157 | 15× | let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid }); |
| 158 | 15× | let payload = serde_json::json!({ |
| 159 | 15× | "iss": issuer, |
| 160 | 15× | "sub": subject, |
| 161 | 15× | "aud": audience, |
| 162 | 15× | "iat": iat, |
| 163 | 15× | "nbf": iat - 30, |
| 164 | 15× | "exp": iat + ttl_s.clamp(60, MAX_TTL_S) as i64, |
| 165 | 15× | "jti": crate::auth::random_hex(16), |
| 166 | | }); |
| 167 | 15× | let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes())); |
| 168 | 15× | let sig = self.signing.sign(signing_input.as_bytes()); |
| 169 | 15× | format!("{signing_input}.{}", b64url(&sig.to_bytes())) |
| 170 | 15× | } |
| 171 | |
|
| 172 | | |
| 173 | | |
| 174 | 15× | pub fn verify(&self, token: &str, audience: &str, now_ms: i64) -> Option<serde_json::Value> { |
| 175 | 15× | let mut parts = token.split('.'); |
| 176 | 15× | let (h, p, s) = (parts.next()?, parts.next()?, parts.next()?); |
| 177 | 14× | if parts.next().is_some() { |
| 178 | 0× | return None; |
| 179 | 14× | } |
| 180 | 14× | let header: serde_json::Value = serde_json::from_slice(&b64url_decode(h)?).ok()?; |
| 181 | 14× | if header["alg"] != "RS256" || header["kid"] != self.kid.as_str() { |
| 182 | 1× | return None; |
| 183 | 13× | } |
| 184 | 13× | let sig = rsa::pkcs1v15::Signature::try_from(b64url_decode(s)?.as_slice()).ok()?; |
| 185 | 13× | self.verifying.verify(format!("{h}.{p}").as_bytes(), &sig).ok()?; |
| 186 | 12× | let claims: serde_json::Value = serde_json::from_slice(&b64url_decode(p)?).ok()?; |
| 187 | 12× | let now = now_ms / 1000; |
| 188 | 12× | (claims["aud"] == audience && claims["exp"].as_i64()? > now && claims["nbf"].as_i64()? <= now).then_some(claims) |
| 189 | 15× | } |
| 190 | | } |
| 191 | |
|
| 192 | |
|
| 193 | |
|
| 194 | 2× | pub fn mask(text: &str, kid: &str) -> String { |
| 195 | 2× | if !text.contains("eyJ") { |
| 196 | 0× | return text.to_string(); |
| 197 | 2× | } |
| 198 | 2069× | let is_tok = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'; |
| 199 | 2× | let mut out = String::with_capacity(text.len()); |
| 200 | 2× | let mut rest = text; |
| 201 | 5× | while let Some(i) = rest.find("eyJ") { |
| 202 | 3× | out.push_str(&rest[..i]); |
| 203 | 2069× | let end = rest[i..].find(|c: char| !is_tok(c)).map(|e| i + e).unwrap_or(rest.len()); |
| 204 | 3× | let cand = &rest[i..end]; |
| 205 | 3× | let ours = cand |
| 206 | 3× | .split('.') |
| 207 | 3× | .next() |
| 208 | 3× | .and_then(b64url_decode) |
| 209 | 3× | .and_then(|h| serde_json::from_slice::<serde_json::Value>(&h).ok()) |
| 210 | 3× | .is_some_and(|h| h["kid"] == kid); |
| 211 | 3× | if ours && cand.matches('.').count() == 2 { |
| 212 | 2× | out.push_str("***"); |
| 213 | 2× | } else { |
| 214 | 1× | out.push_str(cand); |
| 215 | 1× | } |
| 216 | 3× | rest = &rest[end..]; |
| 217 | | } |
| 218 | 2× | out.push_str(rest); |
| 219 | 2× | out |
| 220 | 2× | } |
| 221 | |
|
| 222 | 88× | pub(crate) fn b64url(bytes: &[u8]) -> String { |
| 223 | | const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; |
| 224 | 88× | let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); |
| 225 | 5138× | for chunk in bytes.chunks(3) { |
| 226 | 15280× | let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len())); |
| 227 | 20418× | for i in 0..=chunk.len() { |
| 228 | 20418× | out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char); |
| 229 | 20418× | } |
| 230 | | } |
| 231 | 88× | out |
| 232 | 88× | } |
| 233 | |
|
| 234 | 44× | fn b64url_decode(s: &str) -> Option<Vec<u8>> { |
| 235 | 11325× | let val = |c: u8| -> Option<u32> { |
| 236 | 11325× | Some(match c { |
| 237 | 9782× | b'A'..=b'Z' => (c - b'A') as u32, |
| 238 | 4734× | b'a'..=b'z' => (c - b'a') as u32 + 26, |
| 239 | 1527× | b'0'..=b'9' => (c - b'0') as u32 + 52, |
| 240 | 66× | b'-' => 62, |
| 241 | 50× | b'_' => 63, |
| 242 | 0× | _ => return None, |
| 243 | | }) |
| 244 | 11325× | }; |
| 245 | 44× | let bytes = s.trim_end_matches('=').as_bytes(); |
| 246 | 44× | let mut out = Vec::with_capacity(bytes.len() * 3 / 4); |
| 247 | 2849× | for chunk in bytes.chunks(4) { |
| 248 | 2849× | if chunk.len() == 1 { |
| 249 | 0× | return None; |
| 250 | 2849× | } |
| 251 | 2849× | let mut n = 0u32; |
| 252 | 11325× | for (i, c) in chunk.iter().enumerate() { |
| 253 | 11325× | n |= val(*c)? << (18 - 6 * i); |
| 254 | | } |
| 255 | 2849× | out.push((n >> 16) as u8); |
| 256 | 2849× | if chunk.len() > 2 { |
| 257 | 2819× | out.push((n >> 8) as u8); |
| 258 | 2819× | } |
| 259 | 2849× | if chunk.len() > 3 { |
| 260 | 2808× | out.push(n as u8); |
| 261 | 2808× | } |
| 262 | | } |
| 263 | 44× | Some(out) |
| 264 | 44× | } |
| 265 | |
|
| 266 | |
|
| 267 | 1× | pub async fn discovery(State(app): State<Shared>) -> Result<Response, ApiError> { |
| 268 | 1× | let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?; |
| 269 | 1× | Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.discovery(app.config.base_url()))).into_response()) |
| 270 | 1× | } |
| 271 | |
|
| 272 | |
|
| 273 | 1× | pub async fn jwks(State(app): State<Shared>) -> Result<Response, ApiError> { |
| 274 | 1× | let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?; |
| 275 | 1× | Ok(([(axum::http::header::CACHE_CONTROL, "public, max-age=3600")], Json(issuer.jwks())).into_response()) |
| 276 | 1× | } |
| 277 | |
|
| 278 | | #[cfg(test)] |
| 279 | | mod tests { |
| 280 | | use super::*; |
| 281 | |
|
| 282 | | #[test] |
| 283 | 1× | fn tokens_verify_against_the_published_key_and_are_masked() { |
| 284 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 285 | 1× | let issuer = Issuer::load_or_create(dir.path()).unwrap(); |
| 286 | 1× | let again = Issuer::load_or_create(dir.path()).unwrap(); |
| 287 | 1× | assert_eq!(issuer.kid, again.kid, "the key is kept"); |
| 288 | 1× | let claims = JobClaims { |
| 289 | 1× | issuer: "https://hub.example", |
| 290 | 1× | audience: "sts.amazonaws.com", |
| 291 | 1× | owner: "andy", |
| 292 | 1× | name: "demo", |
| 293 | 1× | visibility: "public", |
| 294 | 1× | bookmark: "main", |
| 295 | 1× | run_number: 7, |
| 296 | 1× | job: "deploy", |
| 297 | 1× | state: "ab".repeat(32).leak(), |
| 298 | 1× | runner: "earth-1", |
| 299 | 1× | actor: "andy", |
| 300 | 1× | environment: Some("prod"), |
| 301 | 1× | approved_by: Some("andy"), |
| 302 | 1× | approval: Some("ab12"), |
| 303 | 1× | now_ms: 1_800_000_000_000, |
| 304 | 1× | ttl_s: 900, |
| 305 | 1× | }; |
| 306 | 1× | let token = issuer.mint(&claims); |
| 307 | 1× | assert_eq!(token.matches('.').count(), 2); |
| 308 | 1× | let got = again.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).expect("verifies"); |
| 309 | 1× | assert_eq!(got["sub"], "repo:andy/demo:bookmark:main"); |
| 310 | 1× | assert_eq!(got["run_number"], 7); |
| 311 | 1× | assert_eq!(got["environment"], "prod"); |
| 312 | 1× | assert_eq!((got["approved_by"].as_str(), got["approval"].as_str()), (Some("andy"), Some("ab12"))); |
| 313 | 1× | let plain = issuer.mint(&JobClaims { environment: None, approved_by: None, approval: None, ..claims }); |
| 314 | 1× | assert!(again.verify(&plain, "sts.amazonaws.com", 1_800_000_500_000).unwrap().get("environment").is_none(), "only when approved"); |
| 315 | 1× | assert_eq!(got["exp"].as_i64().unwrap() - got["iat"].as_i64().unwrap(), 900); |
| 316 | 1× | assert!(again.verify(&token, "other", 1_800_000_500_000).is_none(), "another audience"); |
| 317 | 1× | assert!(again.verify(&token, "sts.amazonaws.com", 1_800_001_000_000).is_none(), "expired"); |
| 318 | 1× | let other = Issuer::load_or_create(tempfile::tempdir().unwrap().path()).unwrap(); |
| 319 | 1× | assert!(other.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).is_none(), "another key"); |
| 320 | 1× | let tampered = format!("{}x", token); |
| 321 | 1× | assert!(again.verify(&tampered, "sts.amazonaws.com", 1_800_000_500_000).is_none()); |
| 322 | 1× | let jwks = issuer.jwks(); |
| 323 | 1× | assert_eq!(jwks["keys"][0]["kid"], issuer.kid.as_str()); |
| 324 | 1× | assert_eq!(b64url_decode(jwks["keys"][0]["e"].as_str().unwrap()).unwrap(), vec![1, 0, 1]); |
| 325 | 1× | assert_eq!(mask(&format!("token={token} done eyJnot.a.token"), &issuer.kid), "token=*** done eyJnot.a.token"); |
| 326 | 1× | assert_eq!(b64url_decode(&b64url(b"hello?")).unwrap(), b"hello?"); |
| 327 | 1× | } |
| 328 | | } |