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